diff --git a/build.gradle.kts b/build.gradle.kts index 9f6d404..77722af 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,7 +75,6 @@ dependencies { api(libs.commons.csv) api(libs.commons.net) api(libs.commons.text) - api(libs.commons.vfs2) { exclude(group = "*", module = "*") } api(libs.kotlinx.coroutines.swing) api(libs.kotlinx.coroutines.core) @@ -121,7 +120,8 @@ dependencies { application { val args = mutableListOf( "-Xmx2048m", - "-Drelease-date=${DateFormatUtils.format(Date(), "yyyy-MM-dd")}" + "-Drelease-date=${DateFormatUtils.format(Date(), "yyyy-MM-dd")}", + "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" ) if (os.isMacOsX) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ce076b..7c155ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ slf4j = "2.0.17" pty4j = "0.13.6" tinylog = "2.7.0" kotlinx-coroutines = "1.10.2" -flatlaf = "3.7-SNAPSHOT" +flatlaf = "3.6" kotlinx-serialization-json = "1.8.1" commons-codec = "1.18.0" commons-lang3 = "3.17.0" @@ -46,7 +46,7 @@ h2 = "2.3.232" sqlite = "3.50.1.0" jug = "5.1.0" semver4j = "5.7.1" -jsvg = "2.0.0" +jsvg = "1.4.0" dom4j = "2.1.4" [libraries] diff --git a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorDialog.kt b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorDialog.kt index cf12446..51c30f5 100644 --- a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorDialog.kt +++ b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorDialog.kt @@ -4,21 +4,22 @@ import app.termora.DialogWrapper import app.termora.Disposable import app.termora.Disposer import app.termora.OptionPane -import app.termora.sftp.absolutePathString -import org.apache.commons.vfs2.FileObject import java.awt.Dimension import java.awt.Window import java.awt.event.WindowAdapter import java.awt.event.WindowEvent import java.io.File +import java.nio.file.Path import javax.swing.JComponent import javax.swing.JOptionPane import javax.swing.UIManager +import kotlin.io.path.absolutePathString +import kotlin.io.path.name -class EditorDialog(file: FileObject, owner: Window, myDisposable: Disposable) : DialogWrapper(null) { +class EditorDialog(file: Path, owner: Window, myDisposable: Disposable) : DialogWrapper(null) { - private val filename = file.name.baseName + private val filename = file.name private val filepath = File(file.absolutePathString()) private val editorPanel = EditorPanel(this, filepath) diff --git a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorPlugin.kt b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorPlugin.kt index 01a90ed..3177948 100644 --- a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorPlugin.kt +++ b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/EditorPlugin.kt @@ -3,13 +3,13 @@ package app.termora.plugins.editor import app.termora.plugin.Extension import app.termora.plugin.ExtensionSupport import app.termora.plugin.Plugin -import app.termora.sftp.SFTPEditFileExtension +import app.termora.transfer.TransportEditFileExtension class EditorPlugin : Plugin { private val support = ExtensionSupport() init { - support.addExtension(SFTPEditFileExtension::class.java) { MySFTPEditFileExtension.instance } + support.addExtension(TransportEditFileExtension::class.java) { MyTransportEditFileExtension.instance } } override fun getAuthor(): String { diff --git a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/MySFTPEditFileExtension.kt b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/MySFTPEditFileExtension.kt deleted file mode 100644 index 634df2b..0000000 --- a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/MySFTPEditFileExtension.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/plugins/editor/src/main/kotlin/app/termora/plugins/editor/MyTransportEditFileExtension.kt b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/MyTransportEditFileExtension.kt new file mode 100644 index 0000000..4de7fef --- /dev/null +++ b/plugins/editor/src/main/kotlin/app/termora/plugins/editor/MyTransportEditFileExtension.kt @@ -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 + } +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt index 6f57a45..1edc719 100644 --- a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt @@ -2,8 +2,8 @@ package app.termora.plugins.s3 import app.termora.DynamicIcon import app.termora.Icons -import app.termora.protocol.FileObjectHandler -import app.termora.protocol.FileObjectRequest +import app.termora.protocol.PathHandler +import app.termora.protocol.PathHandlerRequest import app.termora.protocol.TransferProtocolProvider import io.minio.MinioClient import org.apache.commons.lang3.StringUtils @@ -30,7 +30,7 @@ class S3ProtocolProvider private constructor() : TransferProtocolProvider { return S3FileProvider.instance } - override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler { + override fun getRootFileObject(requester: PathHandlerRequest): PathHandler { val host = requester.host val builder = MinioClient.builder() .endpoint(host.host) @@ -53,7 +53,7 @@ class S3ProtocolProvider private constructor() : TransferProtocolProvider { "s3://${StringUtils.defaultIfBlank(defaultPath, "/")}", options ) - return FileObjectHandler(file) + return PathHandler(file) } } \ No newline at end of file diff --git a/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt b/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt index 1aee64c..c885abf 100644 --- a/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt +++ b/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt @@ -3,7 +3,7 @@ package app.termora.plugins.s3 import app.termora.Authentication import app.termora.AuthenticationType import app.termora.Host -import app.termora.protocol.FileObjectRequest +import app.termora.protocol.PathHandlerRequest import app.termora.vfs2.VFSWalker import io.minio.MakeBucketArgs import io.minio.MinioClient @@ -66,7 +66,7 @@ class S3FileProviderTest { ) } - val requester = FileObjectRequest( + val requester = PathHandlerRequest( host = Host( name = "test", protocol = S3ProtocolProvider.PROTOCOL, diff --git a/settings.gradle.kts b/settings.gradle.kts index 65ea550..02f4be3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,7 +3,7 @@ plugins { } rootProject.name = "termora" -include("plugins:s3") +//include("plugins:s3") //include("plugins:oss") //include("plugins:cos") //include("plugins:obs") diff --git a/src/main/kotlin/app/termora/Application.kt b/src/main/kotlin/app/termora/Application.kt index bc83bbe..bbe5ee5 100644 --- a/src/main/kotlin/app/termora/Application.kt +++ b/src/main/kotlin/app/termora/Application.kt @@ -17,6 +17,7 @@ import java.io.File import java.net.URI import java.nio.file.Files import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermission import java.time.Duration import java.util.* import kotlin.math.ln @@ -202,6 +203,42 @@ fun formatBytes(bytes: Long): String { return String.format("%.2f%s", value, units[exp]) } +fun fromSftpPermissions(sftpPermissions: Int): Set { + val result = mutableSetOf() + + // 将十进制权限转换为八进制字符串 + val octalPermissions = sftpPermissions.toString(8) + + // 仅取后三位权限部分 + if (octalPermissions.length < 3) { + return result + } + + val permissionBits = octalPermissions.takeLast(3) + + // 解析每一部分的权限 + val owner = permissionBits[0].digitToInt() + val group = permissionBits[1].digitToInt() + val others = permissionBits[2].digitToInt() + + // 处理所有者权限 + if ((owner and 4) != 0) result.add(PosixFilePermission.OWNER_READ) + if ((owner and 2) != 0) result.add(PosixFilePermission.OWNER_WRITE) + if ((owner and 1) != 0) result.add(PosixFilePermission.OWNER_EXECUTE) + + // 处理组权限 + if ((group and 4) != 0) result.add(PosixFilePermission.GROUP_READ) + if ((group and 2) != 0) result.add(PosixFilePermission.GROUP_WRITE) + if ((group and 1) != 0) result.add(PosixFilePermission.GROUP_EXECUTE) + + // 处理其他用户权限 + if ((others and 4) != 0) result.add(PosixFilePermission.OTHERS_READ) + if ((others and 2) != 0) result.add(PosixFilePermission.OTHERS_WRITE) + if ((others and 1) != 0) result.add(PosixFilePermission.OTHERS_EXECUTE) + + return result +} + fun formatSeconds(seconds: Long): String { val days = seconds / 86400 val hours = (seconds % 86400) / 3600 diff --git a/src/main/kotlin/app/termora/ApplicationRunner.kt b/src/main/kotlin/app/termora/ApplicationRunner.kt index 19e56e6..cb3beb7 100644 --- a/src/main/kotlin/app/termora/ApplicationRunner.kt +++ b/src/main/kotlin/app/termora/ApplicationRunner.kt @@ -5,8 +5,6 @@ import app.termora.database.DatabaseManager import app.termora.keymap.KeymapManager import app.termora.plugin.ExtensionManager import app.termora.plugin.PluginManager -import app.termora.protocol.ProtocolProvider -import app.termora.protocol.TransferProtocolProvider import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatSystemProperties import com.formdev.flatlaf.extras.FlatDesktop @@ -22,9 +20,6 @@ import kotlinx.coroutines.launch import org.apache.commons.io.FileUtils import org.apache.commons.lang3.LocaleUtils import org.apache.commons.lang3.SystemUtils -import org.apache.commons.vfs2.VFS -import org.apache.commons.vfs2.cache.WeakRefFilesCache -import org.apache.commons.vfs2.impl.DefaultFileSystemManager import org.json.JSONObject import org.slf4j.LoggerFactory import java.awt.MenuItem @@ -77,15 +72,6 @@ class ApplicationRunner { // 等待插件加载完成 loadPluginThread.join() - // 初始化 VFS - val fileSystemManager = DefaultFileSystemManager() - for (provider in ProtocolProvider.providers.filterIsInstance()) { - fileSystemManager.addProvider(provider.getProtocol().lowercase(), provider.getFileProvider()) - } - fileSystemManager.filesCache = WeakRefFilesCache() - fileSystemManager.init() - VFS.setManager(fileSystemManager) - // 准备就绪 for (extension in ExtensionManager.getInstance().getExtensions(ApplicationRunnerExtension::class.java)) { extension.ready() @@ -206,11 +192,13 @@ class ApplicationRunner { } } + // init native icon + NativeIcons.folderIcon + themeManager.change(theme, true) - if (Application.isUnknownVersion()) - FlatInspector.install("ctrl shift alt X") + FlatInspector.install("ctrl shift X") UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true) UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false) @@ -218,7 +206,7 @@ class ApplicationRunner { UIManager.put("Component.arc", 5) UIManager.put("TextComponent.arc", UIManager.getInt("Component.arc")) - UIManager.put("Component.hideMnemonics", false) + UIManager.put("Component.hideMnemonics", true) UIManager.put("TitleBar.height", 36) diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt index ed6447b..4f3a9da 100644 --- a/src/main/kotlin/app/termora/Icons.kt +++ b/src/main/kotlin/app/termora/Icons.kt @@ -38,6 +38,7 @@ object Icons { val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") } val error by lazy { DynamicIcon("icons/error.svg", "icons/error_dark.svg") } val cwmUsers by lazy { DynamicIcon("icons/cwmUsers.svg", "icons/cwmUsers_dark.svg") } + val cwmPermissions by lazy { DynamicIcon("icons/cwmPermissions.svg", "icons/cwmPermissions_dark.svg") } val warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_dark.svg") } val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") } val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") } @@ -61,6 +62,7 @@ object Icons { val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") } val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_dark.svg") } val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") } + val playForward by lazy { DynamicIcon("icons/playForward.svg", "icons/playForward_dark.svg") } val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") } val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") } val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") } @@ -142,5 +144,8 @@ object Icons { val forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") } val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_dark.svg") } val nvidia by lazy { DynamicIcon("icons/nvidia.svg", "icons/nvidia_dark.svg") } - + val desktop_windows by lazy { DynamicIcon("icons/desktop_windows.svg", "icons/desktop_windows_dark.svg") } + val desktop_mac by lazy { DynamicIcon("icons/desktop_mac.svg", "icons/desktop_mac_dark.svg") } + val desktop by lazy { DynamicIcon("icons/desktop.svg", "icons/desktop_dark.svg") } + val moreHorizontal by lazy { DynamicIcon("icons/moreHorizontal.svg", "icons/moreHorizontal_dark.svg") } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/NativeIcons.kt b/src/main/kotlin/app/termora/NativeIcons.kt new file mode 100644 index 0000000..acce480 --- /dev/null +++ b/src/main/kotlin/app/termora/NativeIcons.kt @@ -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() + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SettingsOptionsPane.kt b/src/main/kotlin/app/termora/SettingsOptionsPane.kt index 4b587f9..1223f17 100644 --- a/src/main/kotlin/app/termora/SettingsOptionsPane.kt +++ b/src/main/kotlin/app/termora/SettingsOptionsPane.kt @@ -6,11 +6,11 @@ import app.termora.actions.DataProviders import app.termora.database.DatabaseManager import app.termora.keymap.KeymapPanel import app.termora.plugin.ExtensionManager -import app.termora.sftp.SFTPTab import app.termora.terminal.CursorStyle import app.termora.terminal.DataKey import app.termora.terminal.panel.FloatingToolbarPanel import app.termora.terminal.panel.TerminalPanel +import app.termora.transfer.TransportTerminalTab import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.extras.components.FlatPopupMenu @@ -670,8 +670,8 @@ class SettingsOptionsPane : OptionsPane() { val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue if (sftp.pinTab) { - if (manager.getTerminalTabs().none { it is SFTPTab }) { - manager.addTerminalTab(1, SFTPTab(), false) + if (manager.getTerminalTabs().none { it is TransportTerminalTab }) { + manager.addTerminalTab(1, TransportTerminalTab(), false) } } diff --git a/src/main/kotlin/app/termora/TermoraFrame.kt b/src/main/kotlin/app/termora/TermoraFrame.kt index 32b5fa9..a433cf9 100644 --- a/src/main/kotlin/app/termora/TermoraFrame.kt +++ b/src/main/kotlin/app/termora/TermoraFrame.kt @@ -4,9 +4,7 @@ package app.termora import app.termora.actions.DataProvider import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviders -import app.termora.database.DatabaseManager import app.termora.plugin.ExtensionManager -import app.termora.sftp.SFTPTab import app.termora.terminal.DataKey import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.ui.FlatRootPaneUI @@ -23,7 +21,6 @@ import java.util.* import javax.imageio.ImageIO import javax.swing.JComponent import javax.swing.JFrame -import javax.swing.SwingUtilities import javax.swing.SwingUtilities.isEventDispatchThread import javax.swing.UIManager @@ -43,7 +40,6 @@ class TermoraFrame : JFrame(), DataProvider { private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane) private val dataProviderSupport = DataProviderSupport() private val welcomePanel = WelcomePanel(windowScope) - private val sftp get() = DatabaseManager.getInstance().sftp private var notifyListeners = emptyArray() @@ -205,13 +201,6 @@ class TermoraFrame : JFrame(), DataProvider { minimumSize = Dimension(640, 400) terminalTabbed.addTerminalTab(welcomePanel) - // 下一次事件循环检测是否固定 SFTP - if (sftp.pinTab) { - SwingUtilities.invokeLater { - terminalTabbed.addTerminalTab(SFTPTab(), false) - } - } - val glassPane = GlassPane() rootPane.glassPane = glassPane glassPane.isOpaque = false diff --git a/src/main/kotlin/app/termora/actions/ActionManager.kt b/src/main/kotlin/app/termora/actions/ActionManager.kt index e80fb0d..cb08236 100644 --- a/src/main/kotlin/app/termora/actions/ActionManager.kt +++ b/src/main/kotlin/app/termora/actions/ActionManager.kt @@ -6,9 +6,9 @@ import app.termora.findeverywhere.FindEverywhereAction import app.termora.highlight.KeywordHighlightAction import app.termora.keymgr.KeyManagerAction import app.termora.macro.MacroAction -import app.termora.sftp.SFTPAction import app.termora.snippet.SnippetAction import app.termora.tlog.TerminalLoggerAction +import app.termora.transfer.TransferAnAction import javax.swing.Action class ActionManager : org.jdesktop.swingx.action.ActionManager() { @@ -32,7 +32,7 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() { addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance()) addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction()) addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction()) - addAction(Actions.SFTP, SFTPAction()) + addAction(Actions.SFTP, TransferAnAction()) addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction()) addAction(SnippetAction.SNIPPET, SnippetAction.getInstance()) addAction(Actions.MACRO, MacroAction()) diff --git a/src/main/kotlin/app/termora/actions/OpenHostAction.kt b/src/main/kotlin/app/termora/actions/OpenHostAction.kt index c7c703c..7b2803e 100644 --- a/src/main/kotlin/app/termora/actions/OpenHostAction.kt +++ b/src/main/kotlin/app/termora/actions/OpenHostAction.kt @@ -3,7 +3,7 @@ package app.termora.actions import app.termora.* import app.termora.protocol.GenericProtocolProvider import app.termora.protocol.ProtocolProvider -import app.termora.sftp.SFTPActionEvent +import app.termora.transfer.TransferActionEvent import org.apache.commons.lang3.StringUtils import javax.swing.JOptionPane @@ -38,7 +38,7 @@ class OpenHostAction : AnAction() { if (providers.first { StringUtils.equalsIgnoreCase(it.getProtocol(), host.protocol) } .isTransfer()) { ActionManager.getInstance().getAction(Actions.SFTP) - .actionPerformed(SFTPActionEvent(evt.source, evt.host.id, evt.event)) + .actionPerformed(TransferActionEvent(evt.source, evt.host.id, evt.event)) return } diff --git a/src/main/kotlin/app/termora/keymap/KeymapManager.kt b/src/main/kotlin/app/termora/keymap/KeymapManager.kt index 24a1d1b..6b87804 100644 --- a/src/main/kotlin/app/termora/keymap/KeymapManager.kt +++ b/src/main/kotlin/app/termora/keymap/KeymapManager.kt @@ -134,7 +134,7 @@ class KeymapManager private constructor() : Disposable { if (component is JComponent) { // 如果这个键已经被组件注册了,那么忽略 - if (component.getConditionForKeyStroke(keyStroke) != JComponent.UNDEFINED_CONDITION) { + if (getConditionForKeyStroke(component, keyStroke) != JComponent.UNDEFINED_CONDITION) { return false } } @@ -182,6 +182,21 @@ class KeymapManager private constructor() : Disposable { return false } + private fun getConditionForKeyStroke(c: JComponent, keyStroke: KeyStroke): Int { + val condition = c.getConditionForKeyStroke(keyStroke) + + // 如果这个键已经被组件注册了,那么忽略 + if (condition != JComponent.UNDEFINED_CONDITION) { + return condition + } + + if (c.parent is JComponent) { + return getConditionForKeyStroke(c.parent as JComponent, keyStroke) + } + + return JComponent.UNDEFINED_CONDITION + } + } override fun dispose() { diff --git a/src/main/kotlin/app/termora/plugin/PluginDescriptor.kt b/src/main/kotlin/app/termora/plugin/PluginDescriptor.kt index 32e776d..bd9773d 100644 --- a/src/main/kotlin/app/termora/plugin/PluginDescriptor.kt +++ b/src/main/kotlin/app/termora/plugin/PluginDescriptor.kt @@ -2,7 +2,7 @@ package app.termora.plugin import app.termora.I18n import app.termora.Icons -import com.formdev.flatlaf.extras.FlatSVGIcon +import app.termora.transfer.ScaleIcon import org.semver4j.Semver import java.io.File import java.util.* @@ -18,7 +18,7 @@ open class PluginDescriptor( val path: File? = null, ) { companion object { - val defaultIcon: Icon = FlatSVGIcon(Icons.plugin.name, 32, 32) + val defaultIcon: Icon = ScaleIcon(Icons.plugin, 32) } val description: String get() = getBestDescription() diff --git a/src/main/kotlin/app/termora/plugin/PluginManager.kt b/src/main/kotlin/app/termora/plugin/PluginManager.kt index f863fbc..e797495 100644 --- a/src/main/kotlin/app/termora/plugin/PluginManager.kt +++ b/src/main/kotlin/app/termora/plugin/PluginManager.kt @@ -11,9 +11,9 @@ import app.termora.plugin.internal.rdp.RDPInternalPlugin import app.termora.plugin.internal.serial.SerialInternalPlugin import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin import app.termora.plugin.internal.ssh.SSHInternalPlugin -import app.termora.sftp.internal.local.LocalPlugin -import app.termora.sftp.internal.sftp.SFTPPlugin import app.termora.swingCoroutineScope +import app.termora.transfer.internal.local.LocalPlugin +import app.termora.transfer.internal.sftp.SFTPPlugin import com.formdev.flatlaf.util.SystemInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/src/main/kotlin/app/termora/plugin/internal/plugin/PluginSVGIcon.kt b/src/main/kotlin/app/termora/plugin/internal/plugin/PluginSVGIcon.kt index 0cbc499..bf16026 100644 --- a/src/main/kotlin/app/termora/plugin/internal/plugin/PluginSVGIcon.kt +++ b/src/main/kotlin/app/termora/plugin/internal/plugin/PluginSVGIcon.kt @@ -7,7 +7,6 @@ import app.termora.setupAntialiasing import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.util.UIScale import com.github.weisj.jsvg.SVGDocument -import com.github.weisj.jsvg.parser.LoaderContext import com.github.weisj.jsvg.parser.SVGLoader import java.awt.Component import java.awt.Graphics @@ -22,8 +21,8 @@ class PluginSVGIcon(input: InputStream, dark: InputStream? = null) : Icon { } - private val document = svgLoader.load(input, null, LoaderContext.createDefault()) - private val darkDocument = dark?.let { svgLoader.load(it, null, LoaderContext.createDefault()) } + private val document = svgLoader.load(input) + private val darkDocument = dark?.let { svgLoader.load(it) } override fun getIconHeight(): Int { return 32 diff --git a/src/main/kotlin/app/termora/protocol/FileObjectHandler.kt b/src/main/kotlin/app/termora/protocol/FileObjectHandler.kt deleted file mode 100644 index eb1be87..0000000 --- a/src/main/kotlin/app/termora/protocol/FileObjectHandler.kt +++ /dev/null @@ -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 { - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/protocol/PathHandler.kt b/src/main/kotlin/app/termora/protocol/PathHandler.kt new file mode 100644 index 0000000..5262956 --- /dev/null +++ b/src/main/kotlin/app/termora/protocol/PathHandler.kt @@ -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 { + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/protocol/FileObjectRequest.kt b/src/main/kotlin/app/termora/protocol/PathHandlerRequest.kt similarity index 83% rename from src/main/kotlin/app/termora/protocol/FileObjectRequest.kt rename to src/main/kotlin/app/termora/protocol/PathHandlerRequest.kt index 8a161e6..e4dbcb2 100644 --- a/src/main/kotlin/app/termora/protocol/FileObjectRequest.kt +++ b/src/main/kotlin/app/termora/protocol/PathHandlerRequest.kt @@ -3,7 +3,7 @@ package app.termora.protocol import app.termora.Host import java.awt.Window -class FileObjectRequest( +class PathHandlerRequest( val host: Host, val owner: Window? = null, ) \ No newline at end of file diff --git a/src/main/kotlin/app/termora/protocol/TransferProtocolProvider.kt b/src/main/kotlin/app/termora/protocol/TransferProtocolProvider.kt index e410af9..a1a1caf 100644 --- a/src/main/kotlin/app/termora/protocol/TransferProtocolProvider.kt +++ b/src/main/kotlin/app/termora/protocol/TransferProtocolProvider.kt @@ -4,10 +4,9 @@ import app.termora.plugin.internal.local.LocalProtocolProvider import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider import app.termora.plugin.internal.ssh.SSHProtocolProvider import app.termora.protocol.ProtocolProvider.Companion.providers -import app.termora.sftp.internal.local.LocalTransferProtocolProvider -import app.termora.sftp.internal.sftp.SFTPTransferProtocolProvider +import app.termora.transfer.internal.local.LocalTransferProtocolProvider +import app.termora.transfer.internal.sftp.SFTPTransferProtocolProvider import org.apache.commons.lang3.StringUtils -import org.apache.commons.vfs2.provider.FileProvider interface TransferProtocolProvider : ProtocolProvider { @@ -32,14 +31,9 @@ interface TransferProtocolProvider : ProtocolProvider { } /** - * 获取文件提供者 + * 创建一个文件 */ - fun getFileProvider(): FileProvider - - /** - * 获取根文件 - */ - fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler + fun createPathHandler(requester: PathHandlerRequest): PathHandler override fun isTransfer(): Boolean { return true diff --git a/src/main/kotlin/app/termora/sftp/FileSystemProvider.kt b/src/main/kotlin/app/termora/sftp/FileSystemProvider.kt deleted file mode 100644 index 04a776e..0000000 --- a/src/main/kotlin/app/termora/sftp/FileSystemProvider.kt +++ /dev/null @@ -1,9 +0,0 @@ -package app.termora.sftp - -import org.apache.commons.vfs2.FileSystem - - -interface FileSystemProvider { - fun getFileSystem(): FileSystem - fun setFileSystem(fileSystem: FileSystem) -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt deleted file mode 100644 index a1ad4ff..0000000 --- a/src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt +++ /dev/null @@ -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() - private val layeredPane = LayeredPane() - private val downBtn = JButton(Icons.chevronDown) - private val comboBox = object : JComboBox() { - 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) - } - } - } - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt deleted file mode 100644 index 63cc396..0000000 --- a/src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt +++ /dev/null @@ -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>() - 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 = 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) { - nextReloadTicks += Consumer { 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 getData(dataKey: DataKey): 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) - } - } - } - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt deleted file mode 100644 index eac75c8..0000000 --- a/src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt +++ /dev/null @@ -1,1064 +0,0 @@ -package app.termora.sftp - -import app.termora.* -import app.termora.actions.AnActionEvent -import app.termora.actions.SettingsAction -import app.termora.database.DatabaseManager -import app.termora.plugin.ExtensionManager -import app.termora.sftp.FileSystemViewTable.AskTransfer.Action -import app.termora.vfs2.VFSWalker -import app.termora.vfs2.sftp.MySftpFileObject -import app.termora.vfs2.sftp.MySftpFileSystem -import com.formdev.flatlaf.FlatClientProperties -import com.formdev.flatlaf.extras.FlatSVGIcon -import com.formdev.flatlaf.extras.components.FlatPopupMenu -import com.formdev.flatlaf.util.SystemInfo -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.exception.ExceptionUtils -import org.apache.commons.vfs2.FileObject -import org.apache.commons.vfs2.VFS -import org.apache.commons.vfs2.provider.local.LocalFileSystem -import org.jdesktop.swingx.action.ActionManager -import org.slf4j.LoggerFactory -import java.awt.Component -import java.awt.Dimension -import java.awt.Insets -import java.awt.datatransfer.DataFlavor -import java.awt.datatransfer.StringSelection -import java.awt.datatransfer.Transferable -import java.awt.datatransfer.UnsupportedFlavorException -import java.awt.event.* -import java.io.File -import java.io.IOException -import java.io.OutputStream -import java.nio.file.FileVisitResult -import java.nio.file.FileVisitor -import java.nio.file.Paths -import java.nio.file.StandardOpenOption -import java.nio.file.attribute.BasicFileAttributes -import java.text.MessageFormat -import java.util.* -import java.util.concurrent.atomic.AtomicBoolean -import java.util.regex.Pattern -import javax.swing.* -import javax.swing.event.PopupMenuEvent -import javax.swing.event.PopupMenuListener -import javax.swing.table.DefaultTableCellRenderer -import kotlin.collections.ArrayDeque -import kotlin.collections.List -import kotlin.collections.all -import kotlin.collections.contains -import kotlin.collections.filter -import kotlin.collections.filterIsInstance -import kotlin.collections.find -import kotlin.collections.firstOrNull -import kotlin.collections.forEach -import kotlin.collections.isEmpty -import kotlin.collections.isNotEmpty -import kotlin.collections.last -import kotlin.collections.listOf -import kotlin.collections.map -import kotlin.collections.mapOf -import kotlin.collections.mutableListOf -import kotlin.collections.sortedArray -import kotlin.io.path.absolutePathString -import kotlin.math.max -import kotlin.time.Duration.Companion.milliseconds - - -@Suppress("DuplicatedCode", "CascadeIf") -class FileSystemViewTable( - private val fileSystemProvider: FileSystemProvider, - private val transportManager: TransportManager, - private val coroutineScope: CoroutineScope -) : JTable(), Disposable { - - companion object { - private val log = LoggerFactory.getLogger(FileSystemViewTable::class.java) - } - - private val sftp get() = DatabaseManager.getInstance().sftp - private val model = FileSystemViewTableModel() - private val table = this - private val owner get() = SwingUtilities.getWindowAncestor(this) - private val sftpPanel - get() = SwingUtilities.getAncestorOfClass(SFTPPanel::class.java, this) - as SFTPPanel - private val fileSystemViewPanel - get() = SwingUtilities.getAncestorOfClass(FileSystemViewPanel::class.java, this) - as FileSystemViewPanel - private val actionManager get() = ActionManager.getInstance() - private val isDisposed = AtomicBoolean(false) - private var isPopupMenu = false - - init { - initViews() - initEvents() - } - - private fun initViews() { - super.setModel(model) - - super.getTableHeader().setReorderingAllowed(false) - super.setDragEnabled(true) - super.setDropMode(DropMode.ON_OR_INSERT_ROWS) - super.setCellSelectionEnabled(false) - super.setRowSelectionAllowed(true) - super.setRowHeight(UIManager.getInt("Table.rowHeight")) - super.setAutoResizeMode(AUTO_RESIZE_OFF) - super.setFillsViewportHeight(true) - super.putClientProperty( - FlatClientProperties.STYLE, mapOf( - "showHorizontalLines" to true, - "showVerticalLines" to true, - "cellMargins" to Insets(0, 4, 0, 4), - ) - ) - - setDefaultRenderer(Any::class.java, object : DefaultTableCellRenderer() { - override fun getTableCellRendererComponent( - table: JTable, - value: Any?, - isSelected: Boolean, - hasFocus: Boolean, - row: Int, - column: Int - ): Component { - foreground = null - val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) - var icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getFileIcon(row) else null - if ((table.hasFocus() || isPopupMenu) && isSelected && icon is DynamicIcon) { - icon = icon.dark - } - super.icon = icon - foreground = if (!isSelected && model.getFileObject(row).isHidden) - UIManager.getColor("textInactiveText") else foreground - return c - } - }) - - columnModel.getColumn(FileSystemViewTableModel.COLUMN_NAME).preferredWidth = 250 - columnModel.getColumn(FileSystemViewTableModel.COLUMN_LAST_MODIFIED_TIME).preferredWidth = 130 - - } - - 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.sortedArray(), e) - } else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { - val row = table.selectedRow - if (row <= 0 || row >= table.rowCount) return - val file = model.getFileObject(row) - if (file.isFolder) return - // 传输 - transfer(listOf(file)) - } - } - }) - - - // 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 rows = selectedRows - if (rows.contains(0)) return - val files = rows.map { model.getFileObject(it) } - deletePaths(files, false) - } else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) { - fileSystemViewPanel.reload(true) - } else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F2) { - renameSelection() - } - } - }) - - table.transferHandler = object : TransferHandler() { - override fun canImport(support: TransferSupport): Boolean { - val dropLocation = support.dropLocation as? JTable.DropLocation ?: return false - // 如果不是新增行,并且光标不在第一列,那么不允许 - if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false - // 如果不是新增行,如果在一个文件上,那么不允许 - if (!dropLocation.isInsertRow && model.getFileObject(dropLocation.row).isFile) return false - // 如果不是新增行,在 .. 上面,不允许 - if (!dropLocation.isInsertRow && model.hasParent && dropLocation.row == 0) return false - - if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { - val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) - return data is FileSystemTableRowTransferable && data.source != table - } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { - return fileSystemProvider.getFileSystem() !is LocalFileSystem - } - - return false - } - - override fun importData(support: TransferSupport): Boolean { - val dropLocation = support.dropLocation as? JTable.DropLocation ?: return false - // 如果不是新增行,并且光标不在第一列,那么不允许 - if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false - // 如果不是新增行,如果在一个文件上,那么不允许 - if (!dropLocation.isInsertRow && model.getFileObject(dropLocation.row).isFile) return false - - var targetWorkdir: FileObject? = null - - // 变更工作目录 - if (!dropLocation.isInsertRow) { - targetWorkdir = model.getFileObject(dropLocation.row) - } - - if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { - val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) - if (data !is FileSystemTableRowTransferable) return false - // 委托源表开始传输 - data.source.transfer(data.files, false, targetWorkdir) - return true - } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { - val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*> - if (files.isEmpty()) return false - val paths = files.filterIsInstance().map { VFS.getManager().resolveFile(it.toURI()) } - if (paths.isEmpty()) return false - val localTarget = sftpPanel.getLocalTarget() - val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false - // 委托最左侧的本地文件系统传输 - table.transfer(paths, true, targetWorkdir, fileSystemViewPanel) - return true - } - return false - } - - override fun getSourceActions(c: JComponent?): Int { - return COPY - } - - override fun createTransferable(c: JComponent?): Transferable? { - val files = table.selectedRows.filter { it != 0 }.map { model.getFileObject(it) } - if (files.isEmpty()) return null - return FileSystemTableRowTransferable(table, files) - } - } - - // 快速导航 - addKeyListener(object : KeyAdapter() { - override fun keyPressed(e: KeyEvent) { - val c = e.keyChar - val count = model.rowCount - val row = selectedRow + 1 - for (i in row until count) if (navigate(i, c)) return - for (i in 0 until count) if (navigate(i, c)) return - } - - private fun navigate(row: Int, c: Char): Boolean { - val name = model.getFileObject(row).name.baseName - if (name.startsWith(c, true)) { - clearSelection() - addRowSelectionInterval(row, row) - table.scrollRectToVisible(table.getCellRect(row, 0, true)) - return true - } - return false - } - }) - } - - private fun showContextMenu(rows: IntArray, e: MouseEvent) { - val files = rows.map { model.getFileObject(it) } - val hasParent = rows.contains(0) - val fileSystem = fileSystemProvider.getFileSystem() - - val popupMenu = FlatPopupMenu() - val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new")) - // 创建文件夹 - val newFolder = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder")) - // 创建文件 - val newFile = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file")) - // 传输 - val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer")) - // 编辑 - val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit")) - edit.isEnabled = fileSystem is MySftpFileSystem && files.all { it.isFile } - popupMenu.addSeparator() - // 复制路径 - val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path")) - - // 如果是本地,那么支持打开本地路径 - if (fileSystem is LocalFileSystem) { - popupMenu.add( - I18n.getString( - "termora.transport.table.contextmenu.open-in-folder", - if (SystemInfo.isMacOS) I18n.getString("termora.finder") - else if (SystemInfo.isWindows) I18n.getString("termora.explorer") - else I18n.getString("termora.folder") - ) - ).addActionListener { - Application.browseInFolder(File(files.last().absolutePathString())) - } - - } - popupMenu.addSeparator() - - // 重命名 - val rename = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.rename")) - - // 删除 - val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete")) - // rm -rf - val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction)) - // 只有 SFTP 可以 - rmrf.isVisible = fileSystem is MySftpFileSystem - - // 修改权限 - val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions")) - permission.isEnabled = false - - // 如果是本地系统文件,那么不允许修改权限,用户应该自己修改 - if (fileSystem is MySftpFileSystem && rows.isNotEmpty()) { - permission.isEnabled = true - } - popupMenu.addSeparator() - - // 刷新 - val refresh = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.refresh")) - popupMenu.add(refresh) - popupMenu.addSeparator() - - // 新建 - popupMenu.add(newMenu) - - // 新建文件夹 - newFolder.addActionListener(object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - newFolderOrFile(false) - } - }) - // 新建文件 - newFile.addActionListener(object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - newFolderOrFile(true) - } - }) - rename.addActionListener(object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - renameSelection() - } - }) - delete.addActionListener(object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - deletePaths(files, false) - } - }) - rmrf.addActionListener(object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - deletePaths(files, true) - } - }) - copyPath.addActionListener { - val sb = StringBuilder() - files.forEach { sb.append(it.absolutePathString()).appendLine() } - sb.deleteCharAt(sb.length - 1) - toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null) - } - edit.addActionListener { if (files.isNotEmpty()) editFiles(files) } - permission.addActionListener(object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - val last = files.last() - if (last !is MySftpFileObject) return - changePermission(last) - } - }) - refresh.addActionListener { fileSystemViewPanel.reload() } - transfer.addActionListener { transfer(files) } - - if (rows.isEmpty() || hasParent) { - transfer.isEnabled = false - rename.isEnabled = false - delete.isEnabled = false - edit.isEnabled = false - rmrf.isEnabled = false - copyPath.isEnabled = false - permission.isEnabled = false - } else { - transfer.isEnabled = sftpPanel.canTransfer(table) - } - - popupMenu.addPopupMenuListener(object : PopupMenuListener { - override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { - isPopupMenu = true - } - - override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) { - isPopupMenu = false - } - - override fun popupMenuCanceled(e: PopupMenuEvent?) { - } - - }) - - popupMenu.show(table, e.x, e.y) - } - - private fun changePermission(file: MySftpFileObject) { - - val dialog = PosixFilePermissionDialog( - SwingUtilities.getWindowAncestor(table), - model.getFilePermissions(file) - ) - val permissions = dialog.open() ?: return - val isIncludeSubdirectories = dialog.isIncludeSubdirectories() - - if (fileSystemViewPanel.requestLoading()) { - coroutineScope.launch(Dispatchers.IO) { - val c = runCatching { - file.setPosixFilePermissions(permissions) - if (isIncludeSubdirectories && file.isFolder) { - file.refresh() - VFSWalker.walk(file, object : FileVisitor { - override fun preVisitDirectory( - dir: FileObject, - attrs: BasicFileAttributes - ): FileVisitResult { - dir.refresh() - if (dir is MySftpFileObject) { - dir.setPosixFilePermissions(permissions) - } - return FileVisitResult.CONTINUE - } - - override fun visitFile( - file: FileObject, - attrs: BasicFileAttributes - ): FileVisitResult { - if (file is MySftpFileObject) { - file.setPosixFilePermissions(permissions) - } - return FileVisitResult.CONTINUE - } - - override fun visitFileFailed( - file: FileObject, - exc: IOException - ): FileVisitResult { - return FileVisitResult.TERMINATE - } - - override fun postVisitDirectory( - dir: FileObject, - exc: IOException? - ): FileVisitResult { - return FileVisitResult.CONTINUE - } - - }) - } - }.onFailure { - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog( - owner, - ExceptionUtils.getMessage(it), - messageType = JOptionPane.ERROR_MESSAGE - ) - } - } - - // stop loading - fileSystemViewPanel.stopLoading() - - // reload - if (c.isSuccess) { - fileSystemViewPanel.reload(true) - } - } - } - } - - private fun renameSelection() { - val index = selectedRow - if (index < 0) return - val file = model.getFileObject(index) - val text = OptionPane.showInputDialog( - owner, - value = file.name.baseName, - title = I18n.getString("termora.transport.table.contextmenu.rename") - ) ?: return - if (text.isBlank() || text == file.name.baseName) return - if (model.getPathNames().contains(text)) { - OptionPane.showMessageDialog( - owner, - I18n.getString("termora.transport.file-already-exists", text), - messageType = JOptionPane.ERROR_MESSAGE - ) - return - } - - fileSystemViewPanel.renameTo(file, file.parent.resolveFile(text)) - } - - private fun editFiles(files: List) { - if (files.isEmpty()) return - - if (SystemInfo.isLinux) { - if (sftp.editCommand.isBlank()) { - OptionPane.showMessageDialog( - owner, - I18n.getString("termora.transport.table.contextmenu.edit-command"), - messageType = JOptionPane.INFORMATION_MESSAGE - ) - actionManager.getAction(SettingsAction.SETTING) - ?.actionPerformed(AnActionEvent(this, StringUtils.EMPTY, EventObject(this))) - return - } - } - - for (file in files) { - val dir = Application.createSubTemporaryDir() - val path = Paths.get(dir.absolutePathString(), file.name.baseName) - val target = VFS.getManager().resolveFile("file://" + path.absolutePathString()) - - val newTransport = createTransport(file, false, 0L) - .apply { this.target = target } - - transportManager.addTransportListener(object : TransportListener { - override fun onTransportChanged(transport: Transport) { - if (transport != newTransport) return - if (transport.status != TransportStatus.Done && transport.status != TransportStatus.Failed) return - transportManager.removeTransportListener(this) - if (transport.status != TransportStatus.Done) return - // 监听文件变动 - listenFileChange(target, file) - } - }) - - transportManager.addTransport(newTransport) - - } - } - - private fun listenFileChange(localPath: FileObject, remotePath: FileObject) { - - val editCommand = sftp.editCommand - var disposable: Disposable? = null - val extension = ExtensionManager.getInstance() - .getExtensions(SFTPEditFileExtension::class.java).firstOrNull() - - if (editCommand.isBlank() && extension != null) { - try { - disposable = extension.edit(owner, localPath) - } catch (e: Exception) { - SwingUtilities.invokeLater { - OptionPane.showMessageDialog( - owner, - ExceptionUtils.getRootCauseMessage(e), - messageType = JOptionPane.ERROR_MESSAGE - ) - } - return - } - } else { - try { - val p = localPath.absolutePathString() - if (editCommand.isNotBlank()) { - ProcessBuilder(parseCommand(MessageFormat.format(editCommand, p))).start() - } else if (SystemInfo.isMacOS) { - ProcessBuilder("open", "-a", "TextEdit", p).start() - } else if (SystemInfo.isWindows) { - ProcessBuilder("notepad", p).start() - } else { - return - } - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - return - } - } - - var lastModifiedTime = localPath.content.lastModifiedTime - val job = coroutineScope.launch(Dispatchers.IO) { - while (isActive) { - try { - - if (isDisposed.get()) break - localPath.refresh() - if (!localPath.exists()) break - - val nowModifiedTime = localPath.content.lastModifiedTime - if (nowModifiedTime != lastModifiedTime) { - lastModifiedTime = nowModifiedTime - if (log.isDebugEnabled) { - log.debug("Listening to file {} changes", localPath.absolutePathString()) - } - withContext(Dispatchers.Swing) { - transportManager.addTransport( - createTransport(localPath, false, 0L) - .apply { target = remotePath }) - } - } - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - break - } - - delay(500.milliseconds) - } - - } - - if (disposable != null) { - Disposer.register(disposable, object : Disposable { - override fun dispose() { - if (job.isActive) job.cancel() - } - }) - } - } - - private fun parseCommand(command: String): List { - val result = mutableListOf() - val matcher = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(command) - - while (matcher.find()) { - if (matcher.group(1) != null) { - result.add(matcher.group(1)) // 处理双引号部分 - } else { - result.add(matcher.group(2).replace("\\\\ ", " ")) - } - } - return result - } - - private fun newFolderOrFile(isFile: Boolean) { - val name = if (isFile) I18n.getString("termora.transport.table.contextmenu.new.file") - else I18n.getString("termora.welcome.contextmenu.new.folder.name") - val text = OptionPane.showInputDialog(owner, title = name, value = name) ?: return - if (text.isBlank()) return - if (model.getPathNames().contains(text)) { - OptionPane.showMessageDialog( - owner, - I18n.getString("termora.transport.file-already-exists", text), - messageType = JOptionPane.ERROR_MESSAGE - ) - return - } - fileSystemViewPanel.newFolderOrFile(text, isFile) - } - - private fun deletePaths(paths: List, rm: Boolean = false) { - if (OptionPane.showConfirmDialog( - SwingUtilities.getWindowAncestor(this), - I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"), - messageType = if (rm) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE - ) != JOptionPane.YES_OPTION - ) { - return - } - - if (!fileSystemViewPanel.requestLoading()) { - return - } - - coroutineScope.launch(Dispatchers.IO) { - - runCatching { - if (fileSystemProvider.getFileSystem() is MySftpFileSystem) { - deleteSftpPaths(paths, rm) - } else { - deleteRecursively(paths) - } - }.onFailure { - if (log.isErrorEnabled) { - log.error(it.message, it) - } - } - - withContext(Dispatchers.Swing) { - // 停止加载 - fileSystemViewPanel.stopLoading() - // 刷新 - fileSystemViewPanel.reload() - } - - } - } - - private fun deleteSftpPaths(files: List, rm: Boolean = false) { - if (rm) { - val session = (this.fileSystemProvider.getFileSystem() as MySftpFileSystem).getClientSession() - for (path in files) { - session.executeRemoteCommand( - "rm -rf '${path.absolutePathString()}'", - OutputStream.nullOutputStream(), - Charsets.UTF_8 - ) - } - } else { - deleteRecursively(files) - } - } - - private fun deleteRecursively(files: List) { - for (path in files) { - path.deleteAll() - path.close() - } - } - - - private fun transfer( - files: List, - fromLocalSystem: Boolean = false, - targetWorkdir: FileObject? = null, - target: FileSystemViewPanel? = null, - ) { - - assertEventDispatchThread() - - val target = (target ?: sftpPanel.getTarget(table)) ?: return - val table = target.getData(SFTPDataProviders.FileSystemViewTable) ?: return - var isApplyAll = false - var lastAction = Action.Overwrite - - for (file in files) { - if (!isApplyAll && (targetWorkdir == null || target.getWorkdir() == targetWorkdir)) { - val targetAttr = 0.rangeUntil(table.model.rowCount).map { table.model.getFileObject(it) } - .find { it.name.baseName == file.name.baseName } - if (targetAttr != null) { - val askTransfer = askTransfer(file, targetAttr) - if (askTransfer.option != JOptionPane.YES_OPTION) { - continue - } - if (askTransfer.action == Action.Skip) { - if (askTransfer.applyAll) break - continue - } else { - lastAction = askTransfer.action - isApplyAll = askTransfer.applyAll - } - } - } - - coroutineScope.launch { - try { - doTransfer(file, lastAction, fromLocalSystem, targetWorkdir, target) - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - } - } - } - - } - - private data class AskTransfer( - val option: Int, - val action: Action, - val applyAll: Boolean - ) { - enum class Action { - Overwrite, - Append, - Skip - } - } - - private fun askTransfer( - sourceFile: FileObject, - targetFile: FileObject - ): 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 - - val targetIcon = if (SystemInfo.isWindows) - model.getFileIcon(targetFile, iconSize, iconSize) - else if (targetFile.isFolder) { - FlatSVGIcon(Icons.folder.name, iconSize, iconSize) - } else { - FlatSVGIcon(Icons.file.name, iconSize, iconSize) - } - - val sourceIcon = if (SystemInfo.isWindows) - model.getFileIcon(sourceFile, iconSize, iconSize) - else if (sourceFile.isFolder) { - FlatSVGIcon(Icons.folder.name, iconSize, iconSize) - } else { - FlatSVGIcon(Icons.file.name, iconSize, iconSize) - } - - - val sourceModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(sourceFile), "-") - val targetModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(targetFile), "-") - - val actionsComBoBox = JComboBox() - actionsComBoBox.addItem(Action.Overwrite) - actionsComBoBox.addItem(Action.Append) - actionsComBoBox.addItem(Action.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 == Action.Overwrite) { - text = I18n.getString("termora.transport.sftp.already-exists.overwrite") - } else if (value == Action.Skip) { - text = I18n.getString("termora.transport.sftp.already-exists.skip") - } else if (value == Action.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 = FlatSVGIcon( - Icons.warningIntroduction.name, - iconSize, - 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(sourceFile.name.baseName).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 = sourceFile.name.baseName, - initialValue = JOptionPane.YES_OPTION, - ) { - it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height) - it.setLocationRelativeTo(it.owner) - }, - action = actionsComBoBox.selectedItem as Action, - applyAll = applyAllCheckbox.isSelected - ) - - } - - - /** - * 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止 - */ - private fun doTransfer( - file: FileObject, - action: Action, - fromLocalSystem: Boolean, - targetWorkdir: FileObject?, - target: FileSystemViewPanel? = null - ) { - val sftpPanel = this.sftpPanel - val target = (target ?: sftpPanel.getTarget(table)) ?: return - - /** - * 定义一个添加器,它可以自动的判断导入/拖拽行为 - */ - val adder = object { - fun add(transport: Transport): Boolean { - if (action == Action.Append) { - transport.mode = StandardOpenOption.APPEND - } else { - transport.mode = StandardOpenOption.TRUNCATE_EXISTING - } - return addTransport( - sftpPanel, - if (fromLocalSystem) file.parent else null, - target, - targetWorkdir, - transport - ) - } - } - - if (file.isFile) { - adder.add(createTransport(file, false, 0).apply { scanned() }) - return - } - - val queue = ArrayDeque() - var isTerminate = false - - try { - walk(file, object : FileVisitor { - override fun preVisitDirectory(dir: FileObject, attrs: BasicFileAttributes): FileVisitResult { - val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L) - .apply { queue.addLast(this) } - if (adder.add(transport)) return FileVisitResult.CONTINUE - return FileVisitResult.TERMINATE.apply { isTerminate = true } - } - - override fun visitFile(file: FileObject, attrs: BasicFileAttributes): FileVisitResult { - if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS - val transport = createTransport(file, false, queue.last().id).apply { scanned() } - if (adder.add(transport)) return FileVisitResult.CONTINUE - return FileVisitResult.TERMINATE.apply { isTerminate = true } - } - - override fun visitFileFailed(file: FileObject, exc: IOException): FileVisitResult { - return FileVisitResult.CONTINUE - } - - override fun postVisitDirectory(dir: FileObject, exc: IOException?): FileVisitResult { - // 标记为扫描完毕 - queue.removeLast().scanned() - return FileVisitResult.CONTINUE - } - - }) - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - SwingUtilities.invokeLater { - OptionPane.showMessageDialog( - owner, - message = ExceptionUtils.getRootCauseMessage(e), - messageType = JOptionPane.ERROR_MESSAGE - ) - } - isTerminate = true - } - - if (isTerminate) { - // 把剩余的文件夹标记为扫描完毕 - while (queue.isNotEmpty()) queue.removeLast().scanned() - } - } - - - private fun walk( - dir: FileObject, - visitor: FileVisitor, - ): FileVisitResult { - return VFSWalker.walk(dir, visitor) - } - - private fun addTransport( - sftpPanel: SFTPPanel, - sourceWorkdir: FileObject?, - target: FileSystemViewPanel, - targetWorkdir: FileObject?, - transport: Transport - ): Boolean { - return try { - sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport) - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - false - } - } - - private fun createTransport(source: FileObject, isDirectory: Boolean, parentId: Long): Transport { - val transport = Transport( - source = source, - target = source, - parentId = parentId, - isDirectory = isDirectory, - ) - if (transport.isFile) { - transport.filesize.addAndGet(source.content.size) - } - return transport - } - - - private class FileSystemTableRowTransferable( - val source: FileSystemViewTable, - val files: List - ) : Transferable { - companion object { - val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable") - } - - override fun getTransferDataFlavors(): Array { - return arrayOf(dataFlavor) - } - - override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean { - return flavor == dataFlavor - } - - override fun getTransferData(flavor: DataFlavor?): Any { - if (flavor != dataFlavor) { - throw UnsupportedFlavorException(flavor) - } - return this - } - - } - - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt deleted file mode 100644 index 568fc68..0000000 --- a/src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt +++ /dev/null @@ -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 { - val result = mutableSetOf() - - // 将十进制权限转换为八进制字符串 - 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 { - val permissions = file.content.getAttribute(MySftpFileObject.POSIX_FILE_PERMISSIONS) - as Int? ?: return emptySet() - return fromSftpPermissions(permissions) - } - - override fun getDataVector(): Vector> { - 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 { - val names = linkedSetOf() - 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() - - withContext(Dispatchers.IO) { - dir.refresh() - for (file in dir.children) { - if (useFileHiding && file.isHidden) continue - files.add(file) - } - } - - files.sortWith(compareBy { !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)) } - } - - - } - - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/NativeFileIcons.kt b/src/main/kotlin/app/termora/sftp/NativeFileIcons.kt deleted file mode 100644 index 2386bf8..0000000 --- a/src/main/kotlin/app/termora/sftp/NativeFileIcons.kt +++ /dev/null @@ -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: - */ - private val cache = LRUMap>(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 { - 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) - ) - } - - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPDataProviders.kt b/src/main/kotlin/app/termora/sftp/SFTPDataProviders.kt deleted file mode 100644 index 218486b..0000000 --- a/src/main/kotlin/app/termora/sftp/SFTPDataProviders.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPEditFileExtension.kt b/src/main/kotlin/app/termora/sftp/SFTPEditFileExtension.kt deleted file mode 100644 index 9d9e46d..0000000 --- a/src/main/kotlin/app/termora/sftp/SFTPEditFileExtension.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPExtension.kt b/src/main/kotlin/app/termora/sftp/SFTPExtension.kt deleted file mode 100644 index c975f5f..0000000 --- a/src/main/kotlin/app/termora/sftp/SFTPExtension.kt +++ /dev/null @@ -1,6 +0,0 @@ -package app.termora.sftp - -import app.termora.plugin.Extension - -interface SFTPExtension : Extension { -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPKit.kt b/src/main/kotlin/app/termora/sftp/SFTPKit.kt deleted file mode 100644 index a4c5f09..0000000 --- a/src/main/kotlin/app/termora/sftp/SFTPKit.kt +++ /dev/null @@ -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 -} diff --git a/src/main/kotlin/app/termora/sftp/SFTPPanel.kt b/src/main/kotlin/app/termora/sftp/SFTPPanel.kt deleted file mode 100644 index 1d67cbf..0000000 --- a/src/main/kotlin/app/termora/sftp/SFTPPanel.kt +++ /dev/null @@ -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() - 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 getData(dataKey: DataKey): T? { - return dataProviderSupport.getData(dataKey) - } - - override fun dispose() { - coroutineScope.cancel() - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPTabbed.kt b/src/main/kotlin/app/termora/sftp/SFTPTabbed.kt deleted file mode 100644 index 2b8f5bf..0000000 --- a/src/main/kotlin/app/termora/sftp/SFTPTabbed.kt +++ /dev/null @@ -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) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SpeedReporter.kt b/src/main/kotlin/app/termora/sftp/SpeedReporter.kt deleted file mode 100644 index f8ed7cf..0000000 --- a/src/main/kotlin/app/termora/sftp/SpeedReporter.kt +++ /dev/null @@ -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>() - - 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() - - // 收集 - 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) - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/Transport.kt b/src/main/kotlin/app/termora/sftp/Transport.kt deleted file mode 100644 index c900e9d..0000000 --- a/src/main/kotlin/app/termora/sftp/Transport.kt +++ /dev/null @@ -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>() - 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 } - } - -} diff --git a/src/main/kotlin/app/termora/sftp/TransportListener.kt b/src/main/kotlin/app/termora/sftp/TransportListener.kt deleted file mode 100644 index f26289e..0000000 --- a/src/main/kotlin/app/termora/sftp/TransportListener.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.termora.sftp - -import java.util.* - -interface TransportListener : EventListener { - /** - * 状态变化 - */ - fun onTransportChanged(transport: Transport) {} -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/TransportManager.kt b/src/main/kotlin/app/termora/sftp/TransportManager.kt deleted file mode 100644 index bd11aae..0000000 --- a/src/main/kotlin/app/termora/sftp/TransportManager.kt +++ /dev/null @@ -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 - fun getTransportCount(): Int - fun removeTransport(id: Long) - fun addTransportListener(listener: TransportListener) - fun removeTransportListener(listener: TransportListener) -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/TransportStatusException.kt b/src/main/kotlin/app/termora/sftp/TransportStatusException.kt deleted file mode 100644 index cc090a2..0000000 --- a/src/main/kotlin/app/termora/sftp/TransportStatusException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package app.termora.sftp - -class TransportStatusException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/TransportTable.kt b/src/main/kotlin/app/termora/sftp/TransportTable.kt deleted file mode 100644 index d759860..0000000 --- a/src/main/kotlin/app/termora/sftp/TransportTable.kt +++ /dev/null @@ -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().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().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() - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/TransportTableModel.kt b/src/main/kotlin/app/termora/sftp/TransportTableModel.kt deleted file mode 100644 index 774ed2d..0000000 --- a/src/main/kotlin/app/termora/sftp/TransportTableModel.kt +++ /dev/null @@ -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()) - private val reporter = SpeedReporter(coroutineScope) - private var listeners = emptyArray() - private val activeTransports = linkedMapOf() - - /** - * 最多的平行任务 - */ - 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 { - lock.withLock { - if (pId == 0L) { - return getRoot().children().toList().filterIsInstance() - .map { it.transport } - } - val p = transports[pId] ?: return emptyList() - return p.children().toList().filterIsInstance() - .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() - 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() - 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() - - // 同步传输 - 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 { - val nodes = mutableListOf() - val removeNodes = mutableListOf() - - lock.withLock { - - val stack = ArrayDeque() - 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() - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt b/src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt deleted file mode 100644 index c31066b..0000000 --- a/src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt +++ /dev/null @@ -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) - } - } - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/internal/sftp/SFTPFileObjectHandler.kt b/src/main/kotlin/app/termora/sftp/internal/sftp/SFTPFileObjectHandler.kt deleted file mode 100644 index b25fac1..0000000 --- a/src/main/kotlin/app/termora/sftp/internal/sftp/SFTPFileObjectHandler.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/AbstractTransfer.kt b/src/main/kotlin/app/termora/transfer/AbstractTransfer.kt new file mode 100644 index 0000000..5c772e3 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/AbstractTransfer.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/BookmarkButton.kt b/src/main/kotlin/app/termora/transfer/BookmarkButton.kt similarity index 98% rename from src/main/kotlin/app/termora/sftp/BookmarkButton.kt rename to src/main/kotlin/app/termora/transfer/BookmarkButton.kt index 6984488..c5df035 100644 --- a/src/main/kotlin/app/termora/sftp/BookmarkButton.kt +++ b/src/main/kotlin/app/termora/transfer/BookmarkButton.kt @@ -1,4 +1,4 @@ -package app.termora.sftp +package app.termora.transfer import app.termora.Application.ohMyJson import app.termora.DynamicColor @@ -28,7 +28,7 @@ class BookmarkButton : JButton(Icons.bookmarks) { * 为 true 表示在书签内 */ var isBookmark = false - set(value) { + set(value) { field = value icon = if (value) { Icons.bookmarksOff @@ -42,7 +42,7 @@ class BookmarkButton : JButton(Icons.bookmarks) { val oldWidth = preferredSize.width preferredSize = Dimension(oldWidth + arrowWidth, preferredSize.height) - horizontalAlignment = SwingConstants.LEFT + horizontalAlignment = LEFT addMouseListener(object : MouseAdapter() { diff --git a/src/main/kotlin/app/termora/sftp/BookmarksDialog.kt b/src/main/kotlin/app/termora/transfer/BookmarksDialog.kt similarity index 99% rename from src/main/kotlin/app/termora/sftp/BookmarksDialog.kt rename to src/main/kotlin/app/termora/transfer/BookmarksDialog.kt index fb029bf..8d2dfe7 100644 --- a/src/main/kotlin/app/termora/sftp/BookmarksDialog.kt +++ b/src/main/kotlin/app/termora/transfer/BookmarksDialog.kt @@ -1,4 +1,4 @@ -package app.termora.sftp +package app.termora.transfer import app.termora.DialogWrapper import app.termora.DynamicColor diff --git a/src/main/kotlin/app/termora/transfer/ChangePermissionTransfer.kt b/src/main/kotlin/app/termora/transfer/ChangePermissionTransfer.kt new file mode 100644 index 0000000..a222ece --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/ChangePermissionTransfer.kt @@ -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, + 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) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/DeleteTransfer.kt b/src/main/kotlin/app/termora/transfer/DeleteTransfer.kt new file mode 100644 index 0000000..3184549 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/DeleteTransfer.kt @@ -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) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/DirectoryTransfer.kt b/src/main/kotlin/app/termora/transfer/DirectoryTransfer.kt new file mode 100644 index 0000000..0b28b22 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/DirectoryTransfer.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/FileTransfer.kt b/src/main/kotlin/app/termora/transfer/FileTransfer.kt new file mode 100644 index 0000000..8ab93ee --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/FileTransfer.kt @@ -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) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/InternalTransferManager.kt b/src/main/kotlin/app/termora/transfer/InternalTransferManager.kt new file mode 100644 index 0000000..9a3a8fc --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/InternalTransferManager.kt @@ -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): Boolean + + /** + * 添加任务,如果是文件夹会递归查询子然后传递 + */ + fun addTransfer( + paths: List>, + mode: TransferMode + ): CompletableFuture + + /** + * 手动指定传输到哪个目录 + */ + fun addTransfer( + paths: List>, + targetWorkdir: Path, + mode: TransferMode + ): CompletableFuture + + /** + * 添加高优先级的传输,当有多个高优先级起的时候则有序传输,该方法通常用于编辑目的 + * + * @return id + */ + fun addHighTransfer(source: Path, target: Path): String + + fun addTransferListener(listener: TransferListener): Disposable +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/PathWalker.kt b/src/main/kotlin/app/termora/transfer/PathWalker.kt new file mode 100644 index 0000000..e3dfd8f --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/PathWalker.kt @@ -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) { + 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): 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") + } + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/PosixFilePermissionDialog.kt b/src/main/kotlin/app/termora/transfer/PosixFilePermissionPanel.kt similarity index 80% rename from src/main/kotlin/app/termora/sftp/PosixFilePermissionDialog.kt rename to src/main/kotlin/app/termora/transfer/PosixFilePermissionPanel.kt index f0cb4d6..a21ae2e 100644 --- a/src/main/kotlin/app/termora/sftp/PosixFilePermissionDialog.kt +++ b/src/main/kotlin/app/termora/transfer/PosixFilePermissionPanel.kt @@ -1,19 +1,16 @@ -package app.termora.sftp +package app.termora.transfer -import app.termora.DialogWrapper import app.termora.I18n +import app.termora.OptionsPane.Companion.FORM_MARGIN import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout +import java.awt.BorderLayout import java.awt.Dimension -import java.awt.Window import java.nio.file.attribute.PosixFilePermission import javax.swing.* import kotlin.math.max -class PosixFilePermissionDialog( - owner: Window, - private val permissions: Set -) : DialogWrapper(owner) { +class PosixFilePermissionPanel(private val permissions: Set) : JPanel(BorderLayout()) { private val ownerRead = JCheckBox(I18n.getString("termora.transport.permissions.read")) @@ -27,18 +24,9 @@ class PosixFilePermissionDialog( private val otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute")) private val includeSubFolder = JCheckBox(I18n.getString("termora.transport.permissions.include-subfolder")) - private var isCancelled = false init { - isModal = true - isResizable = false - controlsVisible = false - title = I18n.getString("termora.transport.permissions") initView() - init() - pack() - size = Dimension(max(size.width, UIManager.getInt("Dialog.width") - 300), size.height) - setLocationRelativeTo(null) } private fun initView() { @@ -62,17 +50,24 @@ class PosixFilePermissionDialog( otherWrite.isFocusable = false otherExecute.isFocusable = false includeSubFolder.isFocusable = false - } - override fun createCenterPanel(): JComponent { - val formMargin = "7dlu" - val layout = FormLayout( - "default:grow, $formMargin, default:grow, $formMargin, default:grow", - "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" + add(createCenterPanel(), BorderLayout.CENTER) + + preferredSize = Dimension( + max(preferredSize.width, UIManager.getInt("Dialog.width") - 350), + preferredSize.height ) - val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin") - .layout(layout).debug(false) + } + + private fun createCenterPanel(): JComponent { + val formMargin = FORM_MARGIN + val layout = FormLayout( + "default:grow, $formMargin, default:grow, $formMargin, default:grow", + "pref, $formMargin, pref, $formMargin, pref" + ) + + val builder = FormBuilder.create().layout(layout).debug(false) builder.add("${I18n.getString("termora.transport.permissions.file-folder-permissions")}:").xyw(1, 1, 5) @@ -102,25 +97,12 @@ class PosixFilePermissionDialog( return builder.build() } - override fun doCancelAction() { - this.isCancelled = true - super.doCancelAction() - } fun isIncludeSubdirectories(): Boolean { return includeSubFolder.isSelected } - /** - * @return 返回空表示取消了 - */ - fun open(): Set? { - isModal = true - isVisible = true - - if (isCancelled) { - return null - } + fun getPermissions(): Set { val permissions = mutableSetOf() if (ownerRead.isSelected) { diff --git a/src/main/kotlin/app/termora/transfer/ScaleIcon.kt b/src/main/kotlin/app/termora/transfer/ScaleIcon.kt new file mode 100644 index 0000000..d37860f --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/ScaleIcon.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/Transfer.kt b/src/main/kotlin/app/termora/transfer/Transfer.kt new file mode 100644 index 0000000..04d78a4 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/Transfer.kt @@ -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 + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransferAction.kt b/src/main/kotlin/app/termora/transfer/TransferAction.kt new file mode 100644 index 0000000..1e8acf5 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransferAction.kt @@ -0,0 +1,7 @@ +package app.termora.transfer + +enum class TransferAction { + Overwrite, + Append, + Skip, +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPActionEvent.kt b/src/main/kotlin/app/termora/transfer/TransferActionEvent.kt similarity index 79% rename from src/main/kotlin/app/termora/sftp/SFTPActionEvent.kt rename to src/main/kotlin/app/termora/transfer/TransferActionEvent.kt index ed813bf..9bb703e 100644 --- a/src/main/kotlin/app/termora/sftp/SFTPActionEvent.kt +++ b/src/main/kotlin/app/termora/transfer/TransferActionEvent.kt @@ -1,10 +1,10 @@ -package app.termora.sftp +package app.termora.transfer import app.termora.actions.AnActionEvent import org.apache.commons.lang3.StringUtils import java.util.* -class SFTPActionEvent( +class TransferActionEvent( source: Any, val hostId: String, event: EventObject diff --git a/src/main/kotlin/app/termora/sftp/SFTPAction.kt b/src/main/kotlin/app/termora/transfer/TransferAnAction.kt similarity index 62% rename from src/main/kotlin/app/termora/sftp/SFTPAction.kt rename to src/main/kotlin/app/termora/transfer/TransferAnAction.kt index 2fdf957..dd2ddc6 100644 --- a/src/main/kotlin/app/termora/sftp/SFTPAction.kt +++ b/src/main/kotlin/app/termora/transfer/TransferAnAction.kt @@ -1,4 +1,4 @@ -package app.termora.sftp +package app.termora.transfer import app.termora.HostManager import app.termora.HostTerminalTab @@ -7,19 +7,17 @@ import app.termora.Icons import app.termora.actions.AnAction import app.termora.actions.AnActionEvent import app.termora.actions.DataProviders -import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider -import app.termora.plugin.internal.ssh.SSHProtocolProvider +import app.termora.protocol.TransferProtocolProvider import org.apache.commons.lang3.StringUtils -class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.folder) { +class TransferAnAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.folder) { private val hostManager get() = HostManager.getInstance() - override fun actionPerformed(evt: AnActionEvent) { - val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return - var sftpTab: SFTPTab? = null + + var sftpTab: TransportTerminalTab? = null for (tab in terminalTabbedManager.getTerminalTabs()) { - if (tab is SFTPTab) { + if (tab is TransportTerminalTab) { sftpTab = tab break } @@ -27,17 +25,17 @@ class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.fold // 创建一个新的 if (sftpTab == null) { - sftpTab = SFTPTab() + sftpTab = TransportTerminalTab() terminalTabbedManager.addTerminalTab(sftpTab, false) } - var hostId = if (evt is SFTPActionEvent) evt.hostId else StringUtils.EMPTY + var hostId = if (evt is TransferActionEvent) evt.hostId else StringUtils.EMPTY // 如果不是特定事件,那么尝试获取选中的Tab,如果是一个 Host 并且是 SSH 协议那么直接打开 if (hostId.isBlank()) { val tab = terminalTabbedManager.getSelectedTerminalTab() if (tab is HostTerminalTab) { - if (tab.host.protocol == SSHProtocolProvider.PROTOCOL || tab.host.protocol == SFTPPtyProtocolProvider.PROTOCOL) { + if (TransferProtocolProvider.valueOf(tab.host.protocol) != null) { hostId = tab.host.id } } @@ -47,11 +45,11 @@ class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.fold if (hostId.isBlank()) return - val tabbed = sftpTab.getData(SFTPDataProviders.RightSFTPTabbed) ?: return + val tabbed = sftpTab.rightTabbed // 如果已经打开了 那么直接选中 for (i in 0 until tabbed.tabCount) { - val fileSystemViewPanel = tabbed.getFileSystemViewPanel(i) ?: continue - if (fileSystemViewPanel.host.id == hostId) { + val panel = tabbed.getTransportPanel(i) ?: continue + if (panel.host.id == hostId) { tabbed.selectedIndex = i return } @@ -60,15 +58,15 @@ class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.fold val host = hostManager.getHost(hostId) ?: return for (i in 0 until tabbed.tabCount) { val c = tabbed.getComponentAt(i) - if (c is SFTPFileSystemViewPanel) { - if (c.state == SFTPFileSystemViewPanel.State.Initialized) { - c.selectHost(host) + if (c is TransportSelectionPanel) { + if (c.state == TransportSelectionPanel.State.Initialized) { + c.connect(host) return } } } - tabbed.addSFTPFileSystemViewPanelTab(host) + tabbed.addSelectionTab() } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransferDisposable.kt b/src/main/kotlin/app/termora/transfer/TransferDisposable.kt new file mode 100644 index 0000000..1506799 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransferDisposable.kt @@ -0,0 +1,6 @@ +package app.termora.transfer + +import app.termora.Disposable + +class TransferDisposable(val id: String) : Disposable { +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransferHandler.kt b/src/main/kotlin/app/termora/transfer/TransferHandler.kt new file mode 100644 index 0000000..0086043 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransferHandler.kt @@ -0,0 +1,14 @@ +package app.termora.transfer + +interface TransferHandler { + companion object { + val EMPTY: TransferHandler = object : TransferHandler { + override fun isDisposed() = false + } + } + + /** + * 持有者已经销毁 + */ + fun isDisposed(): Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransferListener.kt b/src/main/kotlin/app/termora/transfer/TransferListener.kt new file mode 100644 index 0000000..f154ecf --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransferListener.kt @@ -0,0 +1,10 @@ +package app.termora.transfer + +import java.util.* + +interface TransferListener : EventListener { + /** + * 状态变化 + */ + fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransferManager.kt b/src/main/kotlin/app/termora/transfer/TransferManager.kt new file mode 100644 index 0000000..17765fe --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransferManager.kt @@ -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 + + /** + * 任务数量 + */ + fun getTransferCount(): Int + + /** + * 传输监听器 + */ + fun addTransferListener(listener: TransferListener): Disposable + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransferScanner.kt b/src/main/kotlin/app/termora/transfer/TransferScanner.kt new file mode 100644 index 0000000..9c12ed4 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransferScanner.kt @@ -0,0 +1,6 @@ +package app.termora.transfer + +interface TransferScanner { + fun scanning(): Boolean + fun scanned() +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransferTable.kt b/src/main/kotlin/app/termora/transfer/TransferTable.kt new file mode 100644 index 0000000..dfa1104 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransferTable.kt @@ -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().map { it.userObject } + .filterIsInstance() + 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) + } + } +} + diff --git a/src/main/kotlin/app/termora/transfer/TransferTableModel.kt b/src/main/kotlin/app/termora/transfer/TransferTableModel.kt new file mode 100644 index 0000000..51e9c2e --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransferTableModel.kt @@ -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() + 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 { + return map.values.map { it.transfer } + } + + override fun removeTransfer(id: String) { + assertEventDispatchThread() + + val stack = ArrayDeque>() + 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 + ) { + 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() + 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() + 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>() + 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() + + // 收集 + 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>() + 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 } + } + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransferTreeTableNode.kt b/src/main/kotlin/app/termora/transfer/TransferTreeTableNode.kt new file mode 100644 index 0000000..bb8dc36 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransferTreeTableNode.kt @@ -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 + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransportEditFileExtension.kt b/src/main/kotlin/app/termora/transfer/TransportEditFileExtension.kt new file mode 100644 index 0000000..73d2cbb --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportEditFileExtension.kt @@ -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 +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransportNavigationPanel.kt b/src/main/kotlin/app/termora/transfer/TransportNavigationPanel.kt new file mode 100644 index 0000000..11585a7 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportNavigationPanel.kt @@ -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, + 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() { + 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() + + 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() + + 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() + 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) { + 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) + } + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransportNavigator.kt b/src/main/kotlin/app/termora/transfer/TransportNavigator.kt new file mode 100644 index 0000000..29135c7 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportNavigator.kt @@ -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 + + fun canRedo(): Boolean + fun canUndo(): Boolean + fun back() + fun forward() +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransportPanel.kt b/src/main/kotlin/app/termora/transfer/TransportPanel.kt new file mode 100644 index 0000000..93955a8 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportPanel.kt @@ -0,0 +1,1220 @@ +package app.termora.transfer + + +import app.termora.* +import app.termora.actions.DataProvider +import app.termora.database.DatabaseManager +import app.termora.plugin.ExtensionManager +import app.termora.transfer.TransportTableModel.Attributes +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.extras.components.FlatToolBar +import com.formdev.flatlaf.icons.FlatTreeClosedIcon +import com.formdev.flatlaf.icons.FlatTreeLeafIcon +import com.formdev.flatlaf.util.SystemInfo +import kotlinx.coroutines.* +import kotlinx.coroutines.swing.Swing +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.ArrayUtils +import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.exception.ExceptionUtils +import org.apache.commons.lang3.time.DateFormatUtils +import org.apache.sshd.sftp.client.fs.SftpFileSystem +import org.apache.sshd.sftp.client.fs.WithFileAttributes +import org.jdesktop.swingx.JXBusyLabel +import org.jdesktop.swingx.JXPanel +import org.slf4j.LoggerFactory +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Insets +import java.awt.Rectangle +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException +import java.awt.event.* +import java.beans.PropertyChangeEvent +import java.beans.PropertyChangeListener +import java.io.File +import java.io.OutputStream +import java.nio.file.FileSystem +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributeView +import java.nio.file.attribute.FileOwnerAttributeView +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFilePermissions +import java.text.MessageFormat +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.Future +import java.util.concurrent.atomic.AtomicBoolean +import java.util.regex.Pattern +import java.util.stream.Stream +import javax.swing.* +import javax.swing.TransferHandler +import javax.swing.filechooser.FileSystemView +import javax.swing.table.DefaultTableCellRenderer +import javax.swing.table.TableRowSorter +import javax.swing.undo.AbstractUndoableEdit +import javax.swing.undo.UndoManager +import javax.swing.undo.UndoableEdit +import kotlin.io.path.* +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class TransportPanel( + private val transferManager: InternalTransferManager, + val host: Host, + val loader: TransportSupportLoader, +) : JPanel(BorderLayout()), DataProvider, Disposable, TransportNavigator { + companion object { + private val log = LoggerFactory.getLogger(TransportPanel::class.java) + private val folderIcon = FlatTreeClosedIcon() + private val fileIcon = FlatTreeLeafIcon() + + internal fun FileSystem.isWindowsFileSystem(): Boolean { + return SystemInfo.isWindows && isLocallyFileSystem() + } + + internal fun FileSystem.isLocallyFileSystem(): Boolean { + return this == FileSystems.getDefault() + } + + } + + private val owner get() = SwingUtilities.getWindowAncestor(this) + private val lru = object : LinkedHashMap() { + override fun removeEldestEntry(eldest: Map.Entry?): Boolean { + return size > 2048 + } + } + + private val toolbar = FlatToolBar() + private val homeBtn = JButton(Icons.homeFolder) + private val prevBtn = JButton(Icons.left) + private val nextBtn = JButton(Icons.right) + private val eyeBtn = JButton(Icons.eye) + private val parentBtn = JButton(Icons.up) + private val refreshBtn = JButton(Icons.refresh) + private val bookmarkBtn = BookmarkButton().apply { name = "Host.${host.id}.Bookmarks" } + + private val layeredPane = LayeredPane() + private val loadingPanel = LoadingPanel() + private val model = TransportTableModel() + private val table = JTable(model) + private val sorter = TableRowSorter(table.model) + private var hasParent = false + private val panel get() = this + + private val enableManager get() = EnableManager.getInstance() + private val showHiddenFilesKey = "termora.transport.host.${host.id}.show-hidden-files" + private var showHiddenFiles: Boolean + get() = enableManager.getFlag(showHiddenFilesKey, true) + set(value) = enableManager.setFlag(showHiddenFilesKey, value) + private val navigator get() = this + private val nextReloadCallbacks = mutableListOf<() -> Unit>() + private val history = linkedSetOf() + private val undoManager = MyUndoManager() + private val editTransferListener = EditTransferListener() + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val disposed = AtomicBoolean(false) + private val futures = CopyOnWriteArraySet>() + + private val _fileSystem by lazy { getSupport().fileSystem } + private val defaultPath by lazy { getSupport().path } + + + /** + * 工作目录 + */ + override var workdir: Path? = null + private set + + override var loading = false + private set(value) { + val oldValue = field + field = value + if (oldValue != value) { + firePropertyChange("loading", oldValue, value) + } + } + + init { + initView() + initEvents() + initTableEvents() + initTransferHandler() + } + + private fun initView() { + + prevBtn.isEnabled = false + nextBtn.isEnabled = false + + eyeBtn.icon = if (showHiddenFiles) Icons.eye else Icons.eyeClose + + toolbar.add(prevBtn) + toolbar.add(homeBtn) + toolbar.add(nextBtn) + toolbar.add(TransportNavigationPanel(loader, this)) + toolbar.add(bookmarkBtn) + toolbar.add(parentBtn) + toolbar.add(eyeBtn) + toolbar.add(refreshBtn) + + sorter.maxSortKeys = 1 + table.setRowSorter(sorter) + table.setAutoCreateRowSorter(false) + table.getTableHeader().setReorderingAllowed(false) + table.setDragEnabled(true) + table.setDropMode(DropMode.ON_OR_INSERT_ROWS) + table.setCellSelectionEnabled(false) + table.setRowSelectionAllowed(true) + table.setRowHeight(UIManager.getInt("Table.rowHeight")) + table.setAutoResizeMode(JTable.AUTO_RESIZE_OFF) + table.setFillsViewportHeight(true) + table.setShowGrid(true) + table.showVerticalLines = true + table.showHorizontalLines = true + table.putClientProperty( + FlatClientProperties.STYLE, mapOf( + "cellMargins" to Insets(0, 4, 0, 4), + ) + ) + table.columnModel.getColumn(TransportTableModel.COLUMN_NAME).preferredWidth = 220 + table.columnModel.getColumn(TransportTableModel.COLUMN_LAST_MODIFIED_TIME).preferredWidth = 120 + + + val sorts = mutableMapOf( + // @formatter:off + TransportTableModel.COLUMN_NAME to Comparator { o1, o2 -> NativeStringComparator.getInstance().compare(o1.name, o2.name) }, + TransportTableModel.COLUMN_FILE_SIZE to Comparator { o1, o2 -> o1.fileSize.compareTo(o2.fileSize) }, + TransportTableModel.COLUMN_TYPE to Comparator { o1, o2 -> o1.type.compareTo(o2.type) }, + TransportTableModel.COLUMN_LAST_MODIFIED_TIME to Comparator { o1, o2 -> o1.lastModifiedTime.compareTo(o2.lastModifiedTime) }, + TransportTableModel.COLUMN_OWNER to Comparator { o1, o2 -> o1.owner.compareTo(o2.owner) }, + TransportTableModel.COLUMN_ATTRS to Comparator { o1, o2 -> PosixFilePermissions.toString(o1.permissions).compareTo(PosixFilePermissions.toString(o2.permissions)) }, + // @formatter:on + ) + + for (i in 0 until table.columnCount) { + sorter.setSortable(i, false) + } + + for (e in sorts) { + sorter.setSortable(e.key, true) + // @formatter:off + sorter.setComparator(e.key, Comparator { o1, o2 -> navigator.compare(o1, o2) ?: e.value.compare(o1, o2) }) + // @formatter:on + } + + + table.setDefaultRenderer(Any::class.java, MyDefaultTableCellRenderer()) + val scrollPane = JScrollPane(table) + scrollPane.apply { 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) + + add(toolbar, BorderLayout.NORTH) + add(layeredPane, BorderLayout.CENTER) + } + + private fun compare(o1: Attributes, o2: Attributes): Int? { + val sortOrder = sorter.sortKeys.first().sortOrder + if (sortOrder == SortOrder.ASCENDING) { + if (o1.isParent && o2.isParent) return 0 + if (o1.isParent) return -1 + if (o2.isParent) return 1 + } else { + if (o1.isParent && o2.isParent) return 0 + if (o1.isParent) return 1 + if (o2.isParent) return -1 + } + return null + } + + private fun initEvents() { + + Disposer.register(this, editTransferListener) + + refreshBtn.addActionListener { reload() } + + prevBtn.addActionListener { navigator.back() } + nextBtn.addActionListener { navigator.forward() } + + parentBtn.addActionListener(createSmartAction(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + if (hasParent.not()) return + navigator.navigateTo(model.getPath(0)) + } + })) + + bookmarkBtn.addActionListener(createSmartAction(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val workdir = workdir ?: return + if (e.actionCommand.isNullOrBlank()) { + if (bookmarkBtn.isBookmark) { + bookmarkBtn.deleteBookmark(workdir.absolutePathString()) + } else { + bookmarkBtn.addBookmark(workdir.absolutePathString()) + } + bookmarkBtn.isBookmark = bookmarkBtn.isBookmark.not() + } else { + navigateTo(_fileSystem.getPath(e.actionCommand)) + } + } + })) + + homeBtn.addActionListener(createSmartAction(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + navigator.navigateTo(_fileSystem.getPath(defaultPath)) + } + })) + + eyeBtn.addActionListener(createSmartAction(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + showHiddenFiles = showHiddenFiles.not() + eyeBtn.icon = if (showHiddenFiles) Icons.eye else Icons.eyeClose + reload() + } + })) + + + undoManager.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + prevBtn.isEnabled = undoManager.canUndo() + nextBtn.isEnabled = undoManager.canRedo() + } + }) + + // 传输完成之后刷新 + transferManager.addTransferListener(object : TransferListener { + override fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) { + if (state != TransferTreeTableNode.State.Done) return + if (transfer.target().fileSystem != _fileSystem) return + if (transfer.target() == workdir || transfer.target().parent == workdir) { + reload(requestFocus = false) + } + } + }).let { Disposer.register(this, it) } + + // High 专门用于编辑目的,下载完成之后立即去编辑 + transferManager.addTransferListener(editTransferListener).let { Disposer.register(this, it) } + + // parent button + addPropertyChangeListener("loading") { evt -> + if (evt.newValue == false) { + parentBtn.isEnabled = hasParent + } + } + + // loading ui + addPropertyChangeListener("loading", object : PropertyChangeListener { + private var job: Job? = null + override fun propertyChange(evt: PropertyChangeEvent) { + job?.cancel() + job = null + val loading = evt.newValue == true + if (loading) { + job = coroutineScope.launch(Dispatchers.Unconfined) { + delay(150.milliseconds) + withContext(Dispatchers.Swing) { + loadingPanel.busyLabel.isBusy = true + loadingPanel.isVisible = true + } + } + } else { + loadingPanel.busyLabel.isBusy = false + loadingPanel.isVisible = false + } + } + }) + + // history + addPropertyChangeListener("workdir") { evt -> + val newValue = evt.newValue + if (newValue is Path) { + if ((newValue.fileSystem.isWindowsFileSystem() && newValue.fileSystem.separator == newValue.pathString).not()) { + history.add(newValue) + } + } + } + + // bookmark + addPropertyChangeListener("workdir") { evt -> + val newValue = evt.newValue + if (newValue is Path) { + bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(newValue.absolutePathString()) + } + } + + // undo or redo + addPropertyChangeListener("workdir", object : PropertyChangeListener { + private var undoOrRedo = false + private var undoOrRedoPath: Path? = null + + override fun propertyChange(evt: PropertyChangeEvent) { + val newValue = evt.newValue + val oldValue = evt.oldValue + if (newValue is Path && oldValue is Path) { + if (undoOrRedo && (undoOrRedoPath == newValue || undoOrRedoPath == oldValue)) { + undoOrRedo = false + return + } + undoManager.addEdit(object : AbstractUndoableEdit() { + override fun undo() { + super.undo() + if (navigator.navigateTo(oldValue)) { + undoOrRedo = true + undoOrRedoPath = oldValue + } + } + + override fun redo() { + super.redo() + if (navigator.navigateTo(newValue)) { + undoOrRedo = true + undoOrRedoPath = newValue + } + } + + override fun getPresentationName(): String { + return "Path" + } + }) + + } + } + }) + + addPropertyChangeListener("workdir") { evt -> reload() } + + reload() + } + + private fun initTableEvents() { + + table.tableHeader.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (SwingUtilities.isRightMouseButton(e)) { + val column = table.tableHeader.columnAtPoint(e.point) + if (column < 0) return + sorter.sortKeys = null + } + } + }) + + table.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER) { + enterSelectionFolder() + } + } + }) + + table.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { + var row = table.selectedRow + if (row < 0) return + row = sorter.convertRowIndexToModel(table.selectedRow) + val attributes = model.getAttributes(row) + if (attributes.isDirectory) { + enterSelectionFolder() + } else { + transferManager.addTransfer( + listOf(model.getPath(row) to attributes), + InternalTransferManager.TransferMode.Transfer + ) + } + } else 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() + } + + if (table.hasFocus().not()) { + table.requestFocusInWindow() + } + + val rows = table.selectedRows.map { sorter.convertRowIndexToModel(it) }.toTypedArray() + showContextmenu(rows, e) + } + } + }) + + table.actionMap.put("Reload", object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + reload() + } + }) + + // 快速导航 + table.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.modifiersEx > 0) return + val c = e.keyChar + val count = model.rowCount + val row = table.selectedRow + 1 + for (i in row until count) if (navigate(i, c)) return + for (i in 0 until count) if (navigate(i, c)) return + } + + private fun navigate(row: Int, c: Char): Boolean { + val row = sorter.convertRowIndexToModel(row) + val name = model.getAttributes(row).name + if (name.startsWith(c, true)) { + table.setRowSelectionInterval(row, row) + table.scrollRectToVisible(table.getCellRect(row, 0, true)) + return true + } + return false + } + }) + + val inputMap = table.getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT) + if (SystemInfo.isMacOS.not()) { + inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0), "Reload") + } + inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_R, toolkit.menuShortcutKeyMaskEx), "Reload") + } + + private fun initTransferHandler() { + data class TransferData( + // true 就是本地拖拽上传 + val locally: Boolean, + val row: Int, + val insertRow: Boolean, + val workdir: Path, + val files: List> + ) + + + + table.transferHandler = object : TransferHandler() { + override fun canImport(support: TransferSupport): Boolean { + return getTransferData(support, false) != null + } + + override fun importData(support: TransferSupport): Boolean { + val data = getTransferData(support, true) ?: return false + + val future = transferManager + .addTransfer(data.files, data.workdir, InternalTransferManager.TransferMode.Transfer) + + mountFuture(future) + + return true + } + + private fun getTransferData(support: TransferSupport, load: Boolean): TransferData? { + if (loader.isLoaded.not()) return null + val workdir = workdir ?: return null + val dropLocation = support.dropLocation as? JTable.DropLocation ?: return null + val row = if (dropLocation.isInsertRow) 0 else sorter.convertRowIndexToModel(dropLocation.row) + if (dropLocation.isInsertRow.not() && dropLocation.column != TransportTableModel.COLUMN_NAME) return null + if (dropLocation.isInsertRow.not() && model.getAttributes(row).isDirectory.not()) return null + if (hasParent && dropLocation.row == 0) return null + val paths = mutableListOf>() + var locally = false + + if (support.isDataFlavorSupported(TransferTransferable.FLAVOR)) { + val transferTransferable = support.transferable.getTransferData(TransferTransferable.FLAVOR) + as? TransferTransferable ?: return null + if (transferTransferable.component == panel) return null + paths.addAll(transferTransferable.files) + } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + if (_fileSystem.isLocallyFileSystem()) return null + if (load) { + val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*> + if (files.isEmpty()) return null + for (file in files.filterIsInstance()) { + val path = file.toPath() + paths.add(path to getAttributes(path)) + } + } + locally = true + } else { + return null + } + + return TransferData( + locally = locally, + row = row, + insertRow = dropLocation.isInsertRow, + workdir = if (dropLocation.isInsertRow) workdir else model.getPath(row), + files = paths + ) + } + + override fun getSourceActions(c: JComponent?): Int { + return COPY + } + + override fun createTransferable(c: JComponent?): Transferable? { + val rows = table.selectedRows.map { sorter.convertRowIndexToModel(it) }.toTypedArray() + if (rows.isEmpty()) return null + return TransferTransferable(panel, rows.map { model.getPath(it) to model.getAttributes(it) }) + } + + } + + } + + fun getTableModel(): TransportTableModel { + return model + } + + fun getFileSystem(): FileSystem { + return _fileSystem + } + + /** + * 不能在 EDT 线程调用 + */ + private fun getSupport(): TransportSupport { + if (SwingUtilities.isEventDispatchThread()) { + throw WrongThreadException("AWT EventQueue") + } + return loader.get() + } + + private fun enterSelectionFolder() { + var row = table.selectedRow + if (row < 0) return + row = sorter.convertRowIndexToModel(table.selectedRow) + val attributes = model.getAttributes(row) + if (attributes.isDirectory.not()) return + + // 记住当前目录名称 + val path = model.getPath(row) + if (attributes.isParent) { + val workdir = workdir + if (workdir != null) registerSelectRow(workdir.name) + } + + navigator.navigateTo(path) + } + + private fun registerSelectRow(name: String) { + nextReloadCallbacks.add { + for (i in 0 until model.rowCount) { + if (model.getAttributes(i).name == name) { + val c = sorter.convertRowIndexToView(i) + table.clearSelection() + table.setRowSelectionInterval(c, c) + table.scrollRectToVisible(table.getCellRect(c, TransportTableModel.COLUMN_NAME, true)) + break + } + } + } + } + + private fun reload(oldPath: Path? = workdir, newPath: Path? = workdir, requestFocus: Boolean = true): Boolean { + assertEventDispatchThread() + + if (loading) return false + loading = true + + coroutineScope.launch { + try { + + val workdir = doReload(oldPath, newPath, requestFocus) + + withContext(Dispatchers.Swing) { + setNewWorkdir(workdir) + nextReloadCallbacks.forEach { runCatching { it.invoke() } } + } + + } catch (e: Exception) { + if (log.isErrorEnabled) log.error(e.message, e) + coroutineScope.launch(Dispatchers.Swing) { + OptionPane.showMessageDialog( + owner, + ExceptionUtils.getRootCauseMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + } finally { + withContext(Dispatchers.Swing) { + loading = false + nextReloadCallbacks.clear() + } + } + } + + return true + } + + private suspend fun doReload(oldPath: Path? = null, newPath: Path? = null, requestFocus: Boolean = true): Path { + + val workdir = newPath ?: oldPath + + if (workdir == null) { + val path = _fileSystem.getPath(defaultPath) + return doReload(null, path) + } + + val path = workdir + val first = AtomicBoolean(false) + var parent = path.parent + if (parent == null && _fileSystem.isWindowsFileSystem() && workdir.pathString != _fileSystem.separator) { + parent = _fileSystem.getPath(_fileSystem.separator) + } + val files = mutableListOf>() + if ((parent != null).also { hasParent = it }) { + val attributes = getAttributes(parent) + files.add(parent to attributes.copy(name = "..", lastModifiedTime = Long.MIN_VALUE)) + } + + val consume = suspend { + withContext(Dispatchers.Swing) { + if (first.compareAndSet(false, true)) { + model.clear() + table.scrollRectToVisible(Rectangle()) + } + for (pair in files) { + model.addRow(arrayOf(pair.first, pair.second)) + } + } + files.clear() + } + + if (_fileSystem.isWindowsFileSystem() && workdir.pathString == _fileSystem.separator) { + for (path in _fileSystem.rootDirectories) { + val attributes = getAttributes(path) + files.add(path to attributes) + } + } else { + listFiles(path).use { paths -> + for (item in paths) { + files.add(item) + if (files.size > 50) consume.invoke() + } + } + } + + if (files.isNotEmpty()) + consume.invoke() + + if (requestFocus) + coroutineScope.launch(Dispatchers.Swing) { table.requestFocusInWindow() } + + return workdir + } + + private fun listFiles(path: Path): Stream> { + val stream = Files.list(path) + .map { it to getAttributes(it) } + // @formatter:off + .sorted(compareBy> { it.second.isDirectory.not() }.thenComparing { a, b -> NativeStringComparator.getInstance().compare(a.second.name, b.second.name) }) + // @formatter:on + + if (showHiddenFiles.not()) { + return stream.filter { it.second.name.startsWith(".").not() } + } + + return stream + } + + private fun getAttributes(path: Path): Attributes { + if (path is WithFileAttributes) { + val attributes = path.attributes + if (attributes != null) { + return Attributes( + name = path.name, + type = Attributes.computeType(attributes.isSymbolicLink, attributes.isDirectory, path.name), + isDirectory = attributes.isDirectory, + isFile = attributes.isRegularFile, + isSymbolicLink = attributes.isSymbolicLink, + fileSize = attributes.size, + permissions = fromSftpPermissions(attributes.permissions), + owner = attributes.owner ?: StringUtils.EMPTY, + lastModifiedTime = attributes.modifyTime.toMillis() + ) + } + } + + val basicAttributes = runCatching { path.fileAttributesView().readAttributes() } + .getOrNull() + val fileOwnerAttribute = runCatching { path.fileAttributesView().owner } + .getOrNull() + val posixFileAttribute = runCatching { path.fileAttributesView().readAttributes() } + .getOrNull() + + val fileSize = basicAttributes?.size() ?: 0 + val permissions = posixFileAttribute?.permissions() ?: emptySet() + val owner = fileOwnerAttribute?.name ?: StringUtils.EMPTY + val lastModifiedTime = basicAttributes?.lastModifiedTime()?.toMillis() ?: 0 + val isDirectory = basicAttributes?.isDirectory ?: false + val isSymbolicLink = basicAttributes?.isSymbolicLink ?: false + + return Attributes( + name = StringUtils.defaultIfBlank(path.name, path.pathString), + type = Attributes.computeType(isSymbolicLink, isDirectory, path.name), + isDirectory = isDirectory, + isFile = basicAttributes?.isRegularFile ?: false, + isSymbolicLink = isSymbolicLink, + fileSize = fileSize, + permissions = permissions, + owner = owner, + lastModifiedTime = lastModifiedTime + ) + } + + private fun createSmartAction(action: Action): Action { + return object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + if (loading) return + action.actionPerformed(e) + } + } + } + + + private fun showContextmenu(rows: Array, e: MouseEvent) { + val files = rows.map { model.getPath(it) to model.getAttributes(it) } + val popupMenu = TransportPopupMenu(owner, model, transferManager, _fileSystem, files) + popupMenu.addActionListener(PopupMenuActionListener(files)) + popupMenu.show(table, e.x, e.y) + } + + override fun navigateTo(destination: Path): Boolean { + assertEventDispatchThread() + + if (loading) return false + if (workdir == destination) return false + + return reload(workdir, destination) + } + + override fun getHistory(): List { + return history.toList() + } + + override fun canRedo(): Boolean { + return undoManager.canRedo() && loading.not() + } + + override fun canUndo(): Boolean { + return undoManager.canUndo() && loading.not() + } + + override fun back() { + if (loading.not() && undoManager.canUndo()) undoManager.undo() + } + + override fun forward() { + if (loading.not() && undoManager.canRedo()) undoManager.redo() + } + + private fun setNewWorkdir(destination: Path) { + val oldValue = workdir + workdir = destination + firePropertyChange("workdir", oldValue, destination) + } + + + private fun mountFuture(future: CompletableFuture<*>) { + if (disposed.get()) return + futures.add(future) + future.whenComplete { _, e -> + if (disposed.get().not() && e is Exception) { + SwingUtilities.invokeLater { + OptionPane.showMessageDialog( + owner, + ExceptionUtils.getRootCauseMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + } + futures.remove(future) + } + } + + override fun dispose() { + if (disposed.compareAndSet(false, true)) { + futures.forEach { it.cancel(true) } + futures.clear() + coroutineScope.cancel() + if (loader.isLoaded && _fileSystem.isOpen) IOUtils.closeQuietly(_fileSystem) + loadingPanel.busyLabel.isBusy = false + } + } + + private class TransferTransferable(val component: TransportPanel, val files: List>) : + Transferable { + companion object { + val FLAVOR = DataFlavor("termora/transfers", "Termora transfers") + } + + override fun getTransferDataFlavors(): Array { + return arrayOf(FLAVOR) + } + + override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean { + return flavor == FLAVOR + } + + override fun getTransferData(flavor: DataFlavor?): Any { + return if (flavor == FLAVOR) this else throw UnsupportedFlavorException(flavor) + } + + } + + private inner class EditTransferListener : TransferListener, Disposable { + private val transferIds = mutableSetOf() + private val sftp get() = DatabaseManager.getInstance().sftp + + override fun onTransferChanged( + transfer: Transfer, + state: TransferTreeTableNode.State + ) { + // 只处理最终状态 + if (state != TransferTreeTableNode.State.Done && state != TransferTreeTableNode.State.Failed) return + // 删除失败就是不存在 + if (transferIds.remove(transfer.id()).not()) return + if (state == TransferTreeTableNode.State.Done) { + listenFileChanged(transfer.target(), transfer.source()) + } + } + + private fun listenFileChanged(localPath: Path, target: Path) { + + val disposable = startEditor(localPath) + + val job = coroutineScope.launch { + var oldMillis = Files.getLastModifiedTime(localPath).toMillis() + while (coroutineScope.isActive) { + delay(1.seconds) + + if (Files.exists(localPath).not()) break + val millis = Files.getLastModifiedTime(localPath).toMillis() + if (oldMillis == millis) continue + + // 发送到服务器 + transferManager.addHighTransfer(localPath, target) + oldMillis = millis + } + } + + Disposer.register(disposable, object : Disposable { + override fun dispose() { + job.cancel() + } + }) + + Disposer.register(this, disposable) + } + + private fun startEditor(localPath: Path): Disposable { + val editCommand = sftp.editCommand + val extension = ExtensionManager.getInstance() + .getExtensions(TransportEditFileExtension::class.java).firstOrNull() + + if (editCommand.isBlank() && extension != null) { + try { + return extension.edit(owner, localPath) + } catch (e: Exception) { + OptionPane.showMessageDialog( + owner, + ExceptionUtils.getRootCauseMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + } + + val disposed = AtomicBoolean(false) + val disposable = object : Disposable { + override fun dispose() { + disposed.compareAndSet(false, true) + } + } + + try { + + val p = localPath.absolutePathString() + if (editCommand.isNotBlank()) { + ProcessBuilder(parseCommand(MessageFormat.format(editCommand, p))).start() + } else if (SystemInfo.isMacOS) { + ProcessBuilder("open", "-a", "TextEdit", "-W", p).start().onExit() + .whenComplete { _, _ -> if (disposed.get().not()) Disposer.dispose(disposable) } + } else if (SystemInfo.isWindows) { + ProcessBuilder("notepad", p).start().onExit() + .whenComplete { _, _ -> if (disposed.get().not()) Disposer.dispose(disposable) } + } + + } catch (e: Exception) { + if (log.isErrorEnabled) log.error(e.message, e) + OptionPane.showMessageDialog( + owner, + ExceptionUtils.getRootCauseMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + + return disposable + } + + private fun parseCommand(command: String): List { + val result = mutableListOf() + val matcher = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(command) + + while (matcher.find()) { + if (matcher.group(1) != null) { + result.add(matcher.group(1)) // 处理双引号部分 + } else { + result.add(matcher.group(2).replace("\\\\ ", " ")) + } + } + return result + } + + override fun dispose() { + transferIds.clear() + } + + fun addListenTransfer(id: String) { + transferIds.add(id) + } + } + + private inner class PopupMenuActionListener(private val files: List>) : ActionListener { + @Suppress("CascadeIf") + override fun actionPerformed(e: ActionEvent) { + val actionCommand = TransportPopupMenu.ActionCommand.valueOf(e.actionCommand) + if (actionCommand == TransportPopupMenu.ActionCommand.Transfer) { + transfer(InternalTransferManager.TransferMode.Transfer) + } else if (actionCommand == TransportPopupMenu.ActionCommand.Delete) { + transfer(InternalTransferManager.TransferMode.Delete) + } else if (actionCommand == TransportPopupMenu.ActionCommand.Refresh) { + reload(requestFocus = true) + } else if (actionCommand == TransportPopupMenu.ActionCommand.Edit) { + edit() + } else if (actionCommand == TransportPopupMenu.ActionCommand.NewFolder || actionCommand == TransportPopupMenu.ActionCommand.NewFile) { + val name = e.source.toString() + val workdir = workdir ?: return + val path = workdir.resolve(name) + processPath(e.source.toString()) { + if (actionCommand == TransportPopupMenu.ActionCommand.NewFolder) + path.createDirectories() + else + path.createFile() + } + } else if (actionCommand == TransportPopupMenu.ActionCommand.Rename) { + val source = files.first().first + val target = source.parent.resolve(e.source.toString()) + processPath(e.source.toString()) { source.moveTo(target) } + } else if (actionCommand == TransportPopupMenu.ActionCommand.Rmrf) { + processPath(StringUtils.EMPTY) { + val session = (_fileSystem as SftpFileSystem).clientSession + for (path in files.map { it.first }) { + session.executeRemoteCommand( + "rm -rf '${path.absolutePathString()}'", + OutputStream.nullOutputStream(), + Charsets.UTF_8 + ) + } + } + } else if (actionCommand == TransportPopupMenu.ActionCommand.ChangePermissions) { + val c = e.source as TransportPopupMenu.ChangePermission + val path = files.first().first + processPath(path.name) { + if (c.includeSubFolder) { + val future = transferManager.addTransfer( + listOf(path to files.first().second.copy(permissions = c.permissions)), + InternalTransferManager.TransferMode.ChangePermission + ) + mountFuture(future) + future.get() + } else { + Files.setPosixFilePermissions(path, c.permissions) + } + } + } + } + + private fun transfer(mode: InternalTransferManager.TransferMode) { + val future = transferManager.addTransfer(files, mode) + mountFuture(future) + } + + private fun edit() { + for (path in files.map { it.first }) { + val target = Application.createSubTemporaryDir().resolve(path.name) + val transferId = transferManager.addHighTransfer(path, target) + editTransferListener.addListenTransfer(transferId) + } + } + + private fun processPath(name: String, action: () -> Unit) { + coroutineScope.launch { + try { + action.invoke() + withContext(Dispatchers.Swing) { + if (name.isNotBlank()) registerSelectRow(name) + reload() + } + } catch (_: CancellationException) { + } catch (e: Exception) { + if (log.isErrorEnabled) log.error(e.message, e) + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog( + owner, + ExceptionUtils.getRootCauseMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + } + } + } + + } + + private class LayeredPane : JLayeredPane() { + override fun doLayout() { + synchronized(treeLock) { + for (c in components) { + c.setBounds(0, 0, width, height) + } + } + } + } + + private inner class LoadingPanel : JXPanel() { + val busyLabel = JXBusyLabel() + + init { + isOpaque = false + border = BorderFactory.createEmptyBorder(50, 0, 0, 0) + add(busyLabel, BorderLayout.CENTER) + addMouseListener(object : MouseAdapter() {}) + isVisible = false + } + + } + + private inner class MyDefaultTableCellRenderer : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: JTable?, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int + ): Component? { + val attributes = value as? Attributes + if (attributes == null) { + return super.getTableCellRendererComponent(table, text, isSelected, hasFocus, row, column) + } + + var text = when (column) { + TransportTableModel.COLUMN_NAME -> attributes.name + TransportTableModel.COLUMN_TYPE -> attributes.type + TransportTableModel.COLUMN_FILE_SIZE -> formatBytes(attributes.fileSize) + // @formatter:off + TransportTableModel.COLUMN_LAST_MODIFIED_TIME -> DateFormatUtils.format(Date(attributes.lastModifiedTime), I18n.getString("termora.date-format")) + // @formatter:on + TransportTableModel.COLUMN_ATTRS -> PosixFilePermissions.toString(attributes.permissions) + TransportTableModel.COLUMN_OWNER -> attributes.owner + else -> StringUtils.EMPTY + } + + // 父行只显示名称 + if (attributes.isParent && column != TransportTableModel.COLUMN_NAME) { + text = StringUtils.EMPTY + } + + val c = super.getTableCellRendererComponent(table, text, isSelected, hasFocus, row, column) + icon = null + + if (column == TransportTableModel.COLUMN_NAME) { + if (_fileSystem.isWindowsFileSystem()) { + val path = model.getPath(sorter.convertRowIndexToModel(row)) + icon = if (attributes.isParent) { + NativeIcons.folderIcon + } else { + lru.computeIfAbsent(path.absolutePathString()) { + FileSystemView.getFileSystemView().getSystemIcon(File(it)) + } + } + } + + if (SystemInfo.isLinux) { + icon = if (attributes.isDirectory) { + folderIcon + } else { + fileIcon + } + } + + if (icon == null) { + icon = if (attributes.isDirectory) { + NativeIcons.folderIcon + } else { + NativeIcons.fileIcon + } + } + } + + return c + } + } + + private inner class MyUndoManager : UndoManager() { + init { + limit = 128 + } + + private var listeners = emptyArray() + + override fun undo() { + super.undo() + fireActionPerformed("undo") + } + + override fun redo() { + super.redo() + fireActionPerformed("redo") + } + + override fun undoOrRedo() { + super.undoOrRedo() + fireActionPerformed("undoOrRedo") + } + + override fun undoTo(edit: UndoableEdit?) { + super.undoTo(edit) + fireActionPerformed("undoTo") + } + + override fun redoTo(edit: UndoableEdit?) { + super.redoTo(edit) + fireActionPerformed("redoTo") + } + + override fun addEdit(anEdit: UndoableEdit?): Boolean { + val c = super.addEdit(anEdit) + fireActionPerformed("addEdit") + return c + } + + fun addActionListener(listener: ActionListener) { + listeners += listener + } + + fun removeActionListener(listener: ActionListener) { + listeners = ArrayUtils.removeElement(listeners, listener) + } + + private fun fireActionPerformed(command: String) { + listeners.forEach { it.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, command)) } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransportPopupMenu.kt b/src/main/kotlin/app/termora/transfer/TransportPopupMenu.kt new file mode 100644 index 0000000..9e0a3a6 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportPopupMenu.kt @@ -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> +) : 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, val includeSubFolder: Boolean) +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt b/src/main/kotlin/app/termora/transfer/TransportSelectionPanel.kt similarity index 57% rename from src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt rename to src/main/kotlin/app/termora/transfer/TransportSelectionPanel.kt index 72002f7..ed3e3df 100644 --- a/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt +++ b/src/main/kotlin/app/termora/transfer/TransportSelectionPanel.kt @@ -1,15 +1,9 @@ -package app.termora.sftp +package app.termora.transfer -import app.termora.Disposable -import app.termora.Disposer -import app.termora.Host -import app.termora.I18n -import app.termora.actions.DataProvider +import app.termora.* import app.termora.database.DatabaseManager -import app.termora.protocol.FileObjectHandler -import app.termora.protocol.FileObjectRequest +import app.termora.protocol.PathHandlerRequest import app.termora.protocol.TransferProtocolProvider -import app.termora.terminal.DataKey import app.termora.tree.* import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon import com.jgoodies.forms.builder.FormBuilder @@ -18,7 +12,6 @@ import kotlinx.coroutines.* import kotlinx.coroutines.swing.Swing import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.exception.ExceptionUtils -import org.apache.commons.vfs2.FileObject import org.jdesktop.swingx.JXBusyLabel import org.jdesktop.swingx.JXHyperlink import org.slf4j.LoggerFactory @@ -27,43 +20,42 @@ import java.awt.CardLayout import java.awt.event.ActionEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.Executors import javax.swing.* import javax.swing.event.TreeExpansionEvent import javax.swing.event.TreeExpansionListener +import kotlin.io.path.absolutePathString -class SFTPFileSystemViewPanel( - var host: Host? = null, - private val transportManager: TransportManager, -) : JPanel(BorderLayout()), Disposable, DataProvider { +class TransportSelectionPanel( + private val tabbed: TransportTabbed, + private val transferManager: InternalTransferManager, +) : JPanel(BorderLayout()), Disposable { companion object { - private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java) + private val log = LoggerFactory.getLogger(TransportSelectionPanel::class.java) } enum class State { Initialized, Connecting, - Connected, ConnectFailed, } - @Volatile var state = State.Initialized private set private val cardLayout = CardLayout() private val cardPanel = JPanel(cardLayout) - private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val connectingPanel = ConnectingPanel() private val selectHostPanel = SelectHostPanel() private val connectFailedPanel = ConnectFailedPanel() - private val isDisposed = AtomicBoolean(false) - private val that = this private val properties get() = DatabaseManager.getInstance().properties + private val owner get() = SwingUtilities.getWindowAncestor(this) + private val executorService = Executors.newVirtualThreadPerTaskExecutor() + private val coroutineDispatcher = executorService.asCoroutineDispatcher() + private val coroutineScope = CoroutineScope(coroutineDispatcher) - private var handler: FileObjectHandler? = null - private var fileSystemPanel: FileSystemViewPanel? = null - + private val that get() = this + private var host: Host? = null init { initView() @@ -71,6 +63,7 @@ class SFTPFileSystemViewPanel( } private fun initView() { + isFocusable = false cardPanel.add(selectHostPanel, State.Initialized.name) cardPanel.add(connectingPanel, State.Connecting.name) cardPanel.add(connectFailedPanel, State.ConnectFailed.name) @@ -82,110 +75,67 @@ class SFTPFileSystemViewPanel( Disposer.register(this, selectHostPanel) } - fun connect() { + fun connect(host: Host) { + if (state == State.Connecting) return + state = State.Connecting + this.host = host + + connectingPanel.busyLabel.isBusy = true + cardLayout.show(cardPanel, State.Connecting.name) + coroutineScope.launch { - if (state != State.Connecting) { - state = State.Connecting - + try { + doConnect(host) + } catch (e: Exception) { + if (log.isErrorEnabled) log.error(e.message, e) withContext(Dispatchers.Swing) { - connectingPanel.start() - cardLayout.show(cardPanel, State.Connecting.name) + state = State.ConnectFailed + connectFailedPanel.errorLabel.text = ExceptionUtils.getRootCauseMessage(e) + cardLayout.show(cardPanel, State.ConnectFailed.name) } + } + }.invokeOnCompletion { swingCoroutineScope.launch { connectingPanel.busyLabel.isBusy = false } } + } - runCatching { doConnect() }.onFailure { - if (log.isErrorEnabled) { - log.error(it.message, it) - } - withContext(Dispatchers.Swing) { - state = State.ConnectFailed - connectFailedPanel.errorLabel.text = ExceptionUtils.getRootCauseMessage(it) - cardLayout.show(cardPanel, State.ConnectFailed.name) - } - } + private suspend fun doConnect(host: Host) { - withContext(Dispatchers.Swing) { - connectingPanel.stop() + val provider = TransferProtocolProvider.valueOf(host.protocol) + if (provider == null) { + throw IllegalStateException("Protocol ${host.protocol} not supported") + } + + val handler = provider.createPathHandler(PathHandlerRequest(host, owner)) + val support = TransportSupport(handler.fileSystem, handler.path.absolutePathString()) + + withContext(Dispatchers.Swing) { + val panel = TransportPanel(transferManager, host, TransportSupportLoader { support }) + Disposer.register(panel, object : Disposable { + override fun dispose() { + Disposer.dispose(handler) } + }) + swingCoroutineScope.launch { + tabbed.remove(that) + tabbed.addTab(host.name, panel) + tabbed.selectedIndex = tabbed.tabCount - 1 } } } - private suspend fun doConnect() { - val thisHost = this.host ?: return - - closeIO() - - val file: FileObject - val provider = TransferProtocolProvider.valueOf(thisHost.protocol) - ?: throw IllegalStateException("Protocol ${thisHost.protocol} not supported") - - try { - val owner = SwingUtilities.getWindowAncestor(that) - val requester = FileObjectRequest(host = thisHost, owner = owner) - provider.getRootFileObject(requester) - val handler = provider.getRootFileObject(requester).apply { handler = this } - file = handler.file - Disposer.register(handler, object : Disposable { - override fun dispose() { - onClose() - } - }) - } catch (e: Exception) { - closeIO() - throw e - } - - if (isDisposed.get()) { - throw IllegalStateException("Closed") - } - - - withContext(Dispatchers.Swing) { - state = State.Connected - val fileSystemPanel = FileSystemViewPanel(thisHost, file, transportManager, coroutineScope) - cardPanel.add(fileSystemPanel, State.Connected.name) - cardLayout.show(cardPanel, State.Connected.name) - that.fileSystemPanel = fileSystemPanel - } - - } - - private fun onClose() { - if (isDisposed.get()) { - return - } - - SwingUtilities.invokeLater { - closeIO() - state = State.ConnectFailed - connectFailedPanel.errorLabel.text = I18n.getString("termora.transport.sftp.closed") - cardLayout.show(cardPanel, State.ConnectFailed.name) - } - } - - private fun closeIO() { - val host = host - - fileSystemPanel?.let { Disposer.dispose(it) } - fileSystemPanel = null - - handler?.let { Disposer.dispose(it) } - handler = null - - if (host != null && log.isInfoEnabled) { - log.info("Sftp ${host.name} is closed") - } + override fun requestFocusInWindow(): Boolean { + return selectHostPanel.tree.requestFocusInWindow() } override fun dispose() { - if (isDisposed.compareAndSet(false, true)) { - closeIO() - coroutineScope.cancel() - } + coroutineScope.cancel() + coroutineDispatcher.close() + executorService.shutdownNow() + connectingPanel.busyLabel.isBusy = false } + private class ConnectingPanel : JPanel(BorderLayout()) { - private val busyLabel = JXBusyLabel() + val busyLabel = JXBusyLabel() init { initView() @@ -210,13 +160,6 @@ class SFTPFileSystemViewPanel( add(builder.build(), BorderLayout.CENTER) } - fun start() { - busyLabel.isBusy = true - } - - fun stop() { - busyLabel.isBusy = false - } } private inner class ConnectFailedPanel : JPanel(BorderLayout()) { @@ -240,7 +183,7 @@ class SFTPFileSystemViewPanel( builder.add(errorLabel).xyw(1, 4, 3, "fill, center") builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.retry")) { override fun actionPerformed(e: ActionEvent) { - connect() + host?.let { connect(it) } } }).apply { horizontalAlignment = SwingConstants.CENTER @@ -251,8 +194,8 @@ class SFTPFileSystemViewPanel( AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) { override fun actionPerformed(e: ActionEvent) { state = State.Initialized - that.setTabTitle(I18n.getString("termora.transport.sftp.select-host")) cardLayout.show(cardPanel, State.Initialized.name) + selectHostPanel.tree.requestFocusInWindow() } }).apply { horizontalAlignment = SwingConstants.CENTER @@ -264,7 +207,7 @@ class SFTPFileSystemViewPanel( } private inner class SelectHostPanel : JPanel(BorderLayout()), Disposable { - private val tree = NewHostTree() + val tree = NewHostTree() init { initView() @@ -306,7 +249,7 @@ class SFTPFileSystemViewPanel( val node = tree.getLastSelectedPathNode() ?: return if (node.isFolder) return val host = node.data as Host - selectHost(host) + connect(host) } } }) @@ -324,31 +267,5 @@ class SFTPFileSystemViewPanel( } - @Suppress("UNCHECKED_CAST") - override fun getData(dataKey: DataKey): 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 - } - } - } - } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransportSupport.kt b/src/main/kotlin/app/termora/transfer/TransportSupport.kt new file mode 100644 index 0000000..3bdf3fb --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportSupport.kt @@ -0,0 +1,9 @@ +package app.termora.transfer + +import java.nio.file.FileSystem + + +class TransportSupport( + val fileSystem: FileSystem, + val path: String +) \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransportSupportLoader.kt b/src/main/kotlin/app/termora/transfer/TransportSupportLoader.kt new file mode 100644 index 0000000..1116f66 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportSupportLoader.kt @@ -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) : Supplier { + private val loading = AtomicBoolean(false) + private lateinit var mySupport: TransportSupport + private val lock = ReentrantLock() + private val condition = lock.newCondition() + private val exceptionReference = AtomicReference(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() + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransportTabbed.kt b/src/main/kotlin/app/termora/transfer/TransportTabbed.kt new file mode 100644 index 0000000..0a741b9 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportTabbed.kt @@ -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) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransportTableModel.kt b/src/main/kotlin/app/termora/transfer/TransportTableModel.kt new file mode 100644 index 0000000..b035108 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportTableModel.kt @@ -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, + 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 == ".." + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPTab.kt b/src/main/kotlin/app/termora/transfer/TransportTerminalTab.kt similarity index 66% rename from src/main/kotlin/app/termora/sftp/SFTPTab.kt rename to src/main/kotlin/app/termora/transfer/TransportTerminalTab.kt index e0253d6..00cd176 100644 --- a/src/main/kotlin/app/termora/sftp/SFTPTab.kt +++ b/src/main/kotlin/app/termora/transfer/TransportTerminalTab.kt @@ -1,20 +1,24 @@ -package app.termora.sftp +package app.termora.transfer import app.termora.* import app.termora.database.DatabaseManager import app.termora.terminal.DataKey import java.beans.PropertyChangeListener +import java.nio.file.FileSystems import javax.swing.Icon import javax.swing.JComponent import javax.swing.JOptionPane import javax.swing.SwingUtilities -class SFTPTab : RememberFocusTerminalTab() { - private val sftpPanel = SFTPPanel() +class TransportTerminalTab : RememberFocusTerminalTab() { + private val transportViewer = TransportViewer() private val sftp get() = DatabaseManager.getInstance().sftp + private val transferManager get() = transportViewer.getTransferManager() + val leftTabbed get() = transportViewer.getLeftTabbed() + val rightTabbed get() = transportViewer.getRightTabbed() init { - Disposer.register(this, sftpPanel) + Disposer.register(this, transportViewer) } override fun getTitle(): String { @@ -32,14 +36,13 @@ class SFTPTab : RememberFocusTerminalTab() { } override fun canClose(): Boolean { - return !sftp.pinTab + return sftp.pinTab.not() } override fun willBeClose(): Boolean { - if (!canClose()) return false + if (canClose().not()) return false - val transportManager = sftpPanel.getData(SFTPDataProviders.TransportManager) ?: return true - if (transportManager.getTransportCount() > 0) { + if (transferManager.getTransferCount() > 0) { return OptionPane.showConfirmDialog( SwingUtilities.getWindowAncestor(getJComponent()), I18n.getString("termora.transport.sftp.close-tab"), @@ -48,8 +51,6 @@ class SFTPTab : RememberFocusTerminalTab() { ) == JOptionPane.OK_OPTION } - val leftTabbed = sftpPanel.getData(SFTPDataProviders.LeftSFTPTabbed) ?: return true - val rightTabbed = sftpPanel.getData(SFTPDataProviders.RightSFTPTabbed) ?: return true if (hasActiveTab(leftTabbed) || hasActiveTab(rightTabbed)) { return OptionPane.showConfirmDialog( SwingUtilities.getWindowAncestor(getJComponent()), @@ -59,25 +60,26 @@ class SFTPTab : RememberFocusTerminalTab() { ) == JOptionPane.OK_OPTION } - return true } - private fun hasActiveTab(tabbed: SFTPTabbed): Boolean { + private fun hasActiveTab(tabbed: TransportTabbed): Boolean { for (i in 0 until tabbed.tabCount) { - val c = tabbed.getFileSystemViewPanel(i) ?: continue - if (c.host.id != "local") { - return true + val c = tabbed.getComponentAt(i) ?: continue + if (c is TransportPanel && c.loader.isLoaded) { + if (c.getFileSystem() != FileSystems.getDefault()) { + return true + } } } return false } override fun getJComponent(): JComponent { - return sftpPanel + return transportViewer } override fun getData(dataKey: DataKey): T? { - return sftpPanel.getData(dataKey) + return null } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransportViewer.kt b/src/main/kotlin/app/termora/transfer/TransportViewer.kt new file mode 100644 index 0000000..81c9fa1 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportViewer.kt @@ -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): Boolean { + return target.getSelectedTransportPanel()?.workdir != null + } + + override fun addTransfer( + paths: List>, + mode: TransferMode + ): CompletableFuture { + 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>, + targetWorkdir: Path, + mode: TransferMode + ): CompletableFuture { + assertEventDispatchThread() + + if (paths.isEmpty()) return CompletableFuture.completedFuture(Unit) + + val future = CompletableFuture() + 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() + 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, + mode: TransferMode, + action: TransferAction, + future: CompletableFuture + ): 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() + 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 { + 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? = 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) + ) + } + + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/internal/local/LocalPlugin.kt b/src/main/kotlin/app/termora/transfer/internal/local/LocalPlugin.kt similarity index 92% rename from src/main/kotlin/app/termora/sftp/internal/local/LocalPlugin.kt rename to src/main/kotlin/app/termora/transfer/internal/local/LocalPlugin.kt index f40c410..269000a 100644 --- a/src/main/kotlin/app/termora/sftp/internal/local/LocalPlugin.kt +++ b/src/main/kotlin/app/termora/transfer/internal/local/LocalPlugin.kt @@ -1,4 +1,4 @@ -package app.termora.sftp.internal.local +package app.termora.transfer.internal.local import app.termora.plugin.Extension import app.termora.plugin.InternalPlugin diff --git a/src/main/kotlin/app/termora/sftp/internal/local/LocalProtocolProviderExtension.kt b/src/main/kotlin/app/termora/transfer/internal/local/LocalProtocolProviderExtension.kt similarity index 90% rename from src/main/kotlin/app/termora/sftp/internal/local/LocalProtocolProviderExtension.kt rename to src/main/kotlin/app/termora/transfer/internal/local/LocalProtocolProviderExtension.kt index 2841416..b7fa354 100644 --- a/src/main/kotlin/app/termora/sftp/internal/local/LocalProtocolProviderExtension.kt +++ b/src/main/kotlin/app/termora/transfer/internal/local/LocalProtocolProviderExtension.kt @@ -1,4 +1,4 @@ -package app.termora.sftp.internal.local +package app.termora.transfer.internal.local import app.termora.protocol.ProtocolProvider import app.termora.protocol.ProtocolProviderExtension diff --git a/src/main/kotlin/app/termora/sftp/internal/local/LocalTransferProtocolProvider.kt b/src/main/kotlin/app/termora/transfer/internal/local/LocalTransferProtocolProvider.kt similarity index 52% rename from src/main/kotlin/app/termora/sftp/internal/local/LocalTransferProtocolProvider.kt rename to src/main/kotlin/app/termora/transfer/internal/local/LocalTransferProtocolProvider.kt index 994eef1..688e7a3 100644 --- a/src/main/kotlin/app/termora/sftp/internal/local/LocalTransferProtocolProvider.kt +++ b/src/main/kotlin/app/termora/transfer/internal/local/LocalTransferProtocolProvider.kt @@ -1,41 +1,34 @@ -package app.termora.sftp.internal.local +package app.termora.transfer.internal.local import app.termora.database.DatabaseManager -import app.termora.protocol.FileObjectHandler -import app.termora.protocol.FileObjectRequest +import app.termora.protocol.PathHandler +import app.termora.protocol.PathHandlerRequest import app.termora.protocol.TransferProtocolProvider import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.SystemUtils -import org.apache.commons.vfs2.VFS -import org.apache.commons.vfs2.provider.FileProvider -import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider +import java.nio.file.FileSystems internal class LocalTransferProtocolProvider : TransferProtocolProvider { companion object { val instance by lazy { LocalTransferProtocolProvider() } - private val localFileProvider by lazy { DefaultLocalFileProvider() } private val sftp get() = DatabaseManager.getInstance().sftp const val PROTOCOL = "file" } - override fun getFileProvider(): FileProvider { - return localFileProvider - } - - override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler { - var defaultDirectory = sftp.defaultDirectory - if (StringUtils.isBlank(defaultDirectory)) { - defaultDirectory = SystemUtils.USER_HOME - } - val file = VFS.getManager().resolveFile("file://${defaultDirectory}") - return FileObjectHandler(file) - } - override fun isTransient(): Boolean { return true } override fun getProtocol(): String { - return "file" + return PROTOCOL + } + + override fun createPathHandler(requester: PathHandlerRequest): PathHandler { + var defaultDirectory = sftp.defaultDirectory + if (StringUtils.isBlank(defaultDirectory)) { + defaultDirectory = SystemUtils.USER_HOME + } + val fileSystem = FileSystems.getDefault() + return PathHandler(fileSystem, fileSystem.getPath(defaultDirectory)) } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPFrameExtension.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPFrameExtension.kt new file mode 100644 index 0000000..7b48bfb --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPFrameExtension.kt @@ -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) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPPathHandler.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPPathHandler.kt new file mode 100644 index 0000000..88c2cc3 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPPathHandler.kt @@ -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 { Disposer.dispose(this) } + + init { + session.addCloseFutureListener(listener) + } + + override fun dispose() { + session.removeCloseFutureListener(listener) + IOUtils.closeQuietly(fileSystem) + IOUtils.closeQuietly(session) + IOUtils.closeQuietly(client) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/internal/sftp/SFTPPlugin.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPPlugin.kt similarity index 70% rename from src/main/kotlin/app/termora/sftp/internal/sftp/SFTPPlugin.kt rename to src/main/kotlin/app/termora/transfer/internal/sftp/SFTPPlugin.kt index 073b2a4..4be8bd0 100644 --- a/src/main/kotlin/app/termora/sftp/internal/sftp/SFTPPlugin.kt +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPPlugin.kt @@ -1,5 +1,6 @@ -package app.termora.sftp.internal.sftp +package app.termora.transfer.internal.sftp +import app.termora.FrameExtension import app.termora.plugin.Extension import app.termora.plugin.InternalPlugin import app.termora.protocol.ProtocolProviderExtension @@ -7,10 +8,11 @@ import app.termora.protocol.ProtocolProviderExtension internal class SFTPPlugin : InternalPlugin() { init { support.addExtension(ProtocolProviderExtension::class.java) { SFTPProtocolProviderExtension.instance } + support.addExtension(FrameExtension::class.java) { SFTPFrameExtension.instance } } override fun getName(): String { - return "Transfer" + return "Local Transfer" } override fun getExtensions(clazz: Class): List { diff --git a/src/main/kotlin/app/termora/sftp/internal/sftp/SFTPProtocolProviderExtension.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPProtocolProviderExtension.kt similarity index 90% rename from src/main/kotlin/app/termora/sftp/internal/sftp/SFTPProtocolProviderExtension.kt rename to src/main/kotlin/app/termora/transfer/internal/sftp/SFTPProtocolProviderExtension.kt index f99301c..96db9ad 100644 --- a/src/main/kotlin/app/termora/sftp/internal/sftp/SFTPProtocolProviderExtension.kt +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPProtocolProviderExtension.kt @@ -1,4 +1,4 @@ -package app.termora.sftp.internal.sftp +package app.termora.transfer.internal.sftp import app.termora.protocol.ProtocolProvider import app.termora.protocol.ProtocolProviderExtension diff --git a/src/main/kotlin/app/termora/sftp/internal/sftp/SFTPTransferProtocolProvider.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt similarity index 52% rename from src/main/kotlin/app/termora/sftp/internal/sftp/SFTPTransferProtocolProvider.kt rename to src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt index d739241..58648bd 100644 --- a/src/main/kotlin/app/termora/sftp/internal/sftp/SFTPTransferProtocolProvider.kt +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt @@ -1,32 +1,32 @@ -package app.termora.sftp.internal.sftp +package app.termora.transfer.internal.sftp import app.termora.SshClients -import app.termora.protocol.FileObjectRequest +import app.termora.database.DatabaseManager +import app.termora.protocol.PathHandler +import app.termora.protocol.PathHandlerRequest import app.termora.protocol.TransferProtocolProvider -import app.termora.vfs2.sftp.MySftpFileProvider -import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder import org.apache.commons.io.IOUtils import org.apache.commons.lang3.StringUtils -import org.apache.commons.vfs2.FileSystemOptions -import org.apache.commons.vfs2.VFS -import org.apache.commons.vfs2.provider.FileProvider import org.apache.sshd.client.SshClient import org.apache.sshd.client.session.ClientSession import org.apache.sshd.sftp.client.SftpClientFactory -import kotlin.io.path.absolutePathString internal class SFTPTransferProtocolProvider : TransferProtocolProvider { companion object { val instance by lazy { SFTPTransferProtocolProvider() } + private val sftp get() = DatabaseManager.getInstance().sftp const val PROTOCOL = "sftp" - } - override fun getFileProvider(): FileProvider { - return MySftpFileProvider.instance + override fun isTransient(): Boolean { + return true } - override fun getRootFileObject(requester: FileObjectRequest): SFTPFileObjectHandler { + override fun getProtocol(): String { + return PROTOCOL + } + + override fun createPathHandler(requester: PathHandlerRequest): PathHandler { var client: SshClient? = null var session: ClientSession? = null try { @@ -37,24 +37,16 @@ internal class SFTPTransferProtocolProvider : TransferProtocolProvider { val fileSystem = SftpClientFactory.instance().createSftpFileSystem(session) val host = requester.host - var defaultDirectory = host.options.sftpDefaultDirectory - if (StringUtils.isBlank(defaultDirectory)) { - defaultDirectory = fileSystem.defaultDir.absolutePathString() + var path = fileSystem.defaultDir + val defaultDirectory = host.options.sftpDefaultDirectory + if (StringUtils.isNotBlank(defaultDirectory)) { + path = fileSystem.getPath(defaultDirectory) } - - val options = FileSystemOptions() - MySftpFileSystemConfigBuilder.getInstance().setSftpFileSystem(options, fileSystem) - val file = VFS.getManager().resolveFile("sftp://${defaultDirectory}", options) - return SFTPFileObjectHandler(file, client, session, fileSystem) + return SFTPPathHandler(fileSystem, path, client, session) } catch (e: Exception) { IOUtils.closeQuietly(session) IOUtils.closeQuietly(client) throw e } } - - override fun getProtocol(): String { - return "sftp" - } - } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/tree/NewHostTree.kt b/src/main/kotlin/app/termora/tree/NewHostTree.kt index b9738bb..d5e0de3 100644 --- a/src/main/kotlin/app/termora/tree/NewHostTree.kt +++ b/src/main/kotlin/app/termora/tree/NewHostTree.kt @@ -8,7 +8,6 @@ import app.termora.database.DatabaseManager import app.termora.plugin.ExtensionManager import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider import app.termora.plugin.internal.ssh.SSHProtocolProvider -import app.termora.sftp.SFTPActionEvent import app.termora.tag.TagDialog import app.termora.tag.TagManager import app.termora.tag.TagSimpleTreeCellRendererExtension @@ -477,7 +476,7 @@ class NewHostTree : SimpleTree(), Disposable { if (nodes.isEmpty()) return for (node in nodes) { - sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt)) +// sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt)) } } diff --git a/src/main/kotlin/app/termora/vfs2/FileObjectDescriptor.kt b/src/main/kotlin/app/termora/vfs2/FileObjectDescriptor.kt deleted file mode 100644 index 07d1911..0000000 --- a/src/main/kotlin/app/termora/vfs2/FileObjectDescriptor.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/vfs2/VFSWalker.kt b/src/main/kotlin/app/termora/vfs2/VFSWalker.kt deleted file mode 100644 index 0366b96..0000000 --- a/src/main/kotlin/app/termora/vfs2/VFSWalker.kt +++ /dev/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, - ): 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") - } - - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/vfs2/s3/AbstractS3FileSystemConfigBuilder.kt b/src/main/kotlin/app/termora/vfs2/s3/AbstractS3FileSystemConfigBuilder.kt deleted file mode 100644 index 64b74e0..0000000 --- a/src/main/kotlin/app/termora/vfs2/s3/AbstractS3FileSystemConfigBuilder.kt +++ /dev/null @@ -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"), "/") - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileObject.kt b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileObject.kt deleted file mode 100644 index 47cb524..0000000 --- a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileObject.kt +++ /dev/null @@ -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(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() - - 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? { - return null - } - - override fun doListChildrenResolved(): Array? { - if (isFile) return null - - val children = mutableListOf() - - 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 { - 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) { - 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 { - return FileSystemViewTableModel.fromSftpPermissions(getAttributes()?.permissions ?: return setOf()) - } - - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileProvider.kt b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileProvider.kt deleted file mode 100644 index a057dc4..0000000 --- a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileProvider.kt +++ /dev/null @@ -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 { - 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 - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystem.kt b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystem.kt deleted file mode 100644 index caae2f2..0000000 --- a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystem.kt +++ /dev/null @@ -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) { - 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 - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystemConfigBuilder.kt b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystemConfigBuilder.kt deleted file mode 100644 index 620f575..0000000 --- a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystemConfigBuilder.kt +++ /dev/null @@ -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 { - return MySftpFileSystem::class.java - } - - - - fun setSftpFileSystem(options: FileSystemOptions, sftpFileSystem: SftpFileSystem) { - setParam(options, "sftpFileSystem", sftpFileSystem) - } - - fun getSftpFileSystem(options: FileSystemOptions): SftpFileSystem? { - return getParam(options, "sftpFileSystem") - } -} \ No newline at end of file diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index b2fa688..b1478f6 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -312,7 +312,7 @@ termora.transport.table.contextmenu.transfer=Transfer termora.transport.table.contextmenu.edit=${termora.keymgr.edit} termora.transport.table.contextmenu.edit-command=You must configure the "Edit Command" in "Settings - SFTP" before you can edit the file termora.transport.table.contextmenu.copy-path=Copy Path -termora.transport.table.contextmenu.open-in-folder=Open in {0} +termora.transport.table.contextmenu.open-in-folder=Open in ${termora.finder} termora.transport.table.contextmenu.rename=${termora.welcome.contextmenu.rename} termora.transport.table.contextmenu.delete=${termora.remove} termora.transport.table.contextmenu.delete-warning=If the folder is too large, deleting it may take some time @@ -343,6 +343,7 @@ termora.transport.sftp.closed=The connection has been closed termora.transport.sftp.close-tab=Transfer is still in activated status. Are you sure you want to remove all jobs and close this session? termora.transport.sftp.close-tab-has-active-session=Session is still active. Do you want to close all sessions? termora.transport.sftp.status.transporting=In progress +termora.transport.sftp.status.deleting=Deleting termora.transport.sftp.status.waiting=Waiting termora.transport.sftp.status.done=Done termora.transport.sftp.status.failed=Failed diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 67452f3..7359dec 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -304,7 +304,7 @@ termora.transport.table.owner=所有者 termora.transport.table.contextmenu.transfer=传输 termora.transport.table.contextmenu.copy-path=复制路径 termora.transport.table.contextmenu.edit-command=你必须在 “设置 - SFTP” 中配置 “编辑命令” 后才能编辑文件 -termora.transport.table.contextmenu.open-in-folder=在{0}中打开 +termora.transport.table.contextmenu.open-in-folder=在${termora.finder}中打开 termora.transport.table.contextmenu.change-permissions=更改权限... termora.transport.table.contextmenu.refresh=刷新 termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件 @@ -321,6 +321,7 @@ termora.transport.sftp.close-tab=传输还处于活动状态,是否删除所 termora.transport.sftp.close-tab-has-active-session=会话还处于活动状态,是否关闭所有会话? termora.transport.sftp.status.transporting=传输中 +termora.transport.sftp.status.deleting=删除中 termora.transport.sftp.status.waiting=等待中 termora.transport.sftp.status.done=已完成 termora.transport.sftp.status.failed=已失败 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 9269dae..04a1076 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -302,7 +302,7 @@ termora.transport.table.owner=所有者 termora.transport.table.contextmenu.transfer=傳輸 termora.transport.table.contextmenu.copy-path=複製路徑 termora.transport.table.contextmenu.edit-command=你必須在 “設定 - SFTP” 中設定 “編輯指令” 後才能編輯文件 -termora.transport.table.contextmenu.open-in-folder=在{0}中打開 +termora.transport.table.contextmenu.open-in-folder=在${termora.finder}中打開 termora.transport.table.contextmenu.change-permissions=更改權限... termora.transport.table.contextmenu.refresh=刷新 termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件 @@ -318,6 +318,7 @@ termora.transport.sftp.closed=連線已經關閉 termora.transport.sftp.close-tab=傳輸仍處於活動狀態,是否刪除所有傳輸任務並關閉此會話? termora.transport.sftp.close-tab-has-active-session=會話仍處於活動狀態,是否關閉所有會話? termora.transport.sftp.status.transporting=傳輸中 +termora.transport.sftp.status.deleting=刪除中 termora.transport.sftp.status.waiting=等待中 termora.transport.sftp.status.done=已完成 termora.transport.sftp.status.failed=已失敗 diff --git a/src/main/resources/icons/cwmPermissions.svg b/src/main/resources/icons/cwmPermissions.svg new file mode 100644 index 0000000..7accd95 --- /dev/null +++ b/src/main/resources/icons/cwmPermissions.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/cwmPermissions_dark.svg b/src/main/resources/icons/cwmPermissions_dark.svg new file mode 100644 index 0000000..67119d3 --- /dev/null +++ b/src/main/resources/icons/cwmPermissions_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/desktop.svg b/src/main/resources/icons/desktop.svg new file mode 100644 index 0000000..6b09338 --- /dev/null +++ b/src/main/resources/icons/desktop.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/desktop_dark.svg b/src/main/resources/icons/desktop_dark.svg new file mode 100644 index 0000000..c227c81 --- /dev/null +++ b/src/main/resources/icons/desktop_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/desktop_mac.svg b/src/main/resources/icons/desktop_mac.svg new file mode 100644 index 0000000..e8dc2d0 --- /dev/null +++ b/src/main/resources/icons/desktop_mac.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/desktop_mac_dark.svg b/src/main/resources/icons/desktop_mac_dark.svg new file mode 100644 index 0000000..8efd018 --- /dev/null +++ b/src/main/resources/icons/desktop_mac_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/desktop_windows.svg b/src/main/resources/icons/desktop_windows.svg new file mode 100644 index 0000000..69a76c9 --- /dev/null +++ b/src/main/resources/icons/desktop_windows.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/desktop_windows_dark.svg b/src/main/resources/icons/desktop_windows_dark.svg new file mode 100644 index 0000000..2bcda0b --- /dev/null +++ b/src/main/resources/icons/desktop_windows_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/moreHorizontal.svg b/src/main/resources/icons/moreHorizontal.svg new file mode 100644 index 0000000..5ff864a --- /dev/null +++ b/src/main/resources/icons/moreHorizontal.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/icons/moreHorizontal_dark.svg b/src/main/resources/icons/moreHorizontal_dark.svg new file mode 100644 index 0000000..97e2845 --- /dev/null +++ b/src/main/resources/icons/moreHorizontal_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/icons/playForward.svg b/src/main/resources/icons/playForward.svg new file mode 100644 index 0000000..6f446d4 --- /dev/null +++ b/src/main/resources/icons/playForward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/playForward_dark.svg b/src/main/resources/icons/playForward_dark.svg new file mode 100644 index 0000000..1fb740d --- /dev/null +++ b/src/main/resources/icons/playForward_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/test/kotlin/app/termora/vfs/TestFileSystem.kt b/src/test/kotlin/app/termora/vfs/TestFileSystem.kt new file mode 100644 index 0000000..b632bf2 --- /dev/null +++ b/src/test/kotlin/app/termora/vfs/TestFileSystem.kt @@ -0,0 +1,11 @@ +package app.termora.vfs + +import okio.Path.Companion.toPath +import kotlin.test.Test + +class TestFileSystem { + @Test + fun test() { + println(okio.FileSystem.SYSTEM.list(".".toPath())) + } +} \ No newline at end of file