From 66a81a5da3df4d60b0c1f4ccea5f10d7b688683a Mon Sep 17 00:00:00 2001 From: hstyi Date: Mon, 7 Jul 2025 15:38:45 +0800 Subject: [PATCH] feat: transfer support compress and extract --- .../app/termora/transfer/CommandTransfer.kt | 4 +- .../DefaultInternalTransferManager.kt | 2 +- .../transfer/InternalTransferManager.kt | 2 +- .../termora/transfer/TransferIndeterminate.kt | 3 + .../app/termora/transfer/TransferManager.kt | 4 + .../app/termora/transfer/TransferTable.kt | 78 +++++++- .../termora/transfer/TransferTableModel.kt | 6 +- .../termora/transfer/TransferTreeTableNode.kt | 9 +- .../transfer/TransportContextMenuExtension.kt | 22 ++ .../app/termora/transfer/TransportPanel.kt | 43 ++-- .../termora/transfer/TransportPopupMenu.kt | 45 +++-- .../termora/transfer/TransportTableModel.kt | 4 +- .../app/termora/transfer/TransportViewer.kt | 12 ++ .../transfer/internal/sftp/CompressMode.kt | 8 + .../CompressTransportContextMenuExtension.kt | 153 ++++++++++++++ .../ExtractTransportContextMenuExtension.kt | 189 ++++++++++++++++++ .../sftp/RmrfTransportContextMenuExtension.kt | 48 +++++ .../transfer/internal/sftp/SFTPPlugin.kt | 4 + src/main/resources/i18n/messages.properties | 5 + .../resources/i18n/messages_zh_CN.properties | 5 + .../resources/i18n/messages_zh_TW.properties | 5 + src/test/resources/sshd/Dockerfile | 2 +- 22 files changed, 603 insertions(+), 50 deletions(-) create mode 100644 src/main/kotlin/app/termora/transfer/TransferIndeterminate.kt create mode 100644 src/main/kotlin/app/termora/transfer/TransportContextMenuExtension.kt create mode 100644 src/main/kotlin/app/termora/transfer/internal/sftp/CompressMode.kt create mode 100644 src/main/kotlin/app/termora/transfer/internal/sftp/CompressTransportContextMenuExtension.kt create mode 100644 src/main/kotlin/app/termora/transfer/internal/sftp/ExtractTransportContextMenuExtension.kt create mode 100644 src/main/kotlin/app/termora/transfer/internal/sftp/RmrfTransportContextMenuExtension.kt diff --git a/src/main/kotlin/app/termora/transfer/CommandTransfer.kt b/src/main/kotlin/app/termora/transfer/CommandTransfer.kt index f6b3fd7..08f1faa 100644 --- a/src/main/kotlin/app/termora/transfer/CommandTransfer.kt +++ b/src/main/kotlin/app/termora/transfer/CommandTransfer.kt @@ -11,14 +11,14 @@ class CommandTransfer( isDirectory: Boolean, private val size: Long, val command: String, -) : AbstractTransfer(parentId, path, path, isDirectory) { +) : AbstractTransfer(parentId, path, path, isDirectory), TransferIndeterminate { private var executed = false override suspend fun transfer(bufferSize: Int): Long { if (executed) return 0 val fs = source().fileSystem as SftpFileSystem - fs.session.executeRemoteCommand(command) + fs.clientSession.executeRemoteCommand(command) executed = true return this.size() } diff --git a/src/main/kotlin/app/termora/transfer/DefaultInternalTransferManager.kt b/src/main/kotlin/app/termora/transfer/DefaultInternalTransferManager.kt index 5f970d2..922b4cf 100644 --- a/src/main/kotlin/app/termora/transfer/DefaultInternalTransferManager.kt +++ b/src/main/kotlin/app/termora/transfer/DefaultInternalTransferManager.kt @@ -38,7 +38,7 @@ import kotlin.io.path.name import kotlin.io.path.pathString import kotlin.math.max -class DefaultInternalTransferManager( +internal class DefaultInternalTransferManager( private val owner: Supplier, private val coroutineScope: CoroutineScope, private val transferManager: TransferManager, diff --git a/src/main/kotlin/app/termora/transfer/InternalTransferManager.kt b/src/main/kotlin/app/termora/transfer/InternalTransferManager.kt index 176be3d..07270aa 100644 --- a/src/main/kotlin/app/termora/transfer/InternalTransferManager.kt +++ b/src/main/kotlin/app/termora/transfer/InternalTransferManager.kt @@ -4,7 +4,7 @@ import app.termora.Disposable import java.nio.file.Path import java.util.concurrent.CompletableFuture -interface InternalTransferManager { +internal interface InternalTransferManager { enum class TransferMode { Delete, Transfer, diff --git a/src/main/kotlin/app/termora/transfer/TransferIndeterminate.kt b/src/main/kotlin/app/termora/transfer/TransferIndeterminate.kt new file mode 100644 index 0000000..a72d028 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransferIndeterminate.kt @@ -0,0 +1,3 @@ +package app.termora.transfer + +interface TransferIndeterminate \ 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 index 17765fe..94312bd 100644 --- a/src/main/kotlin/app/termora/transfer/TransferManager.kt +++ b/src/main/kotlin/app/termora/transfer/TransferManager.kt @@ -30,4 +30,8 @@ interface TransferManager { */ fun addTransferListener(listener: TransferListener): Disposable + /** + * 移除传输监听器 + */ + fun removeTransferListener(listener: TransferListener) } \ 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 index 815a8b9..607a144 100644 --- a/src/main/kotlin/app/termora/transfer/TransferTable.kt +++ b/src/main/kotlin/app/termora/transfer/TransferTable.kt @@ -1,11 +1,10 @@ package app.termora.transfer -import app.termora.Disposable -import app.termora.I18n -import app.termora.NativeIcons -import app.termora.OptionPane +import app.termora.* +import app.termora.transfer.TransferTreeTableNode.State import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.components.FlatPopupMenu +import com.formdev.flatlaf.util.SoftCache import kotlinx.coroutines.* import kotlinx.coroutines.swing.Swing import org.apache.commons.lang3.StringUtils @@ -15,6 +14,7 @@ import java.awt.Component import java.awt.Graphics import java.awt.Insets import java.awt.event.ActionEvent +import java.awt.event.ActionListener import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.util.concurrent.atomic.AtomicBoolean @@ -23,6 +23,7 @@ import javax.swing.table.DefaultTableCellRenderer import javax.swing.tree.DefaultTreeCellRenderer import kotlin.io.path.name import kotlin.math.floor +import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds @@ -85,6 +86,7 @@ class TransferTable(private val coroutineScope: CoroutineScope, private val tabl columnModel.getColumn(TransferTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer columnModel.getColumn(TransferTableModel.COLUMN_ESTIMATED_TIME).cellRenderer = centerTableCellRenderer columnModel.getColumn(TransferTableModel.COLUMN_PROGRESS).cellRenderer = ProgressTableCellRenderer() + .apply { Disposer.register(table, this) } } private fun initEvents() { @@ -169,10 +171,16 @@ class TransferTable(private val coroutineScope: CoroutineScope, private val tabl disposed.set(true) } - private inner class ProgressTableCellRenderer : DefaultTableCellRenderer() { + data class Indeterminate(val progress: Int = 0) + + private inner class ProgressTableCellRenderer : DefaultTableCellRenderer(), Disposable, ActionListener { private var progress = 0.0 private var progressInt = 0 private val padding = 4 + private val map = SoftCache() + private val timer = Timer(1000 / 40, this).apply { start() } + private var value: Any? = null + private val block = 36 init { horizontalAlignment = CENTER @@ -189,9 +197,19 @@ class TransferTable(private val coroutineScope: CoroutineScope, private val tabl this.progress = 0.0 this.progressInt = 0 + this.value = value if (value is TransferTreeTableNode) { - if (value.state() == TransferTreeTableNode.State.Processing || value.waitingChildrenCompleted() || value.transfer is DeleteTransfer) { + + if (value.transfer is TransferIndeterminate) { + if (map.containsKey(value).not()) { + map[value] = Indeterminate() + } + } else if (map.containsKey(value)) { + map.remove(value) + } + + if (value.state() == 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 @@ -200,6 +218,7 @@ class TransferTable(private val coroutineScope: CoroutineScope, private val tabl this.progressInt = floor(progress * 100.0).toInt() } } + } return super.getTableCellRendererComponent( @@ -213,6 +232,9 @@ class TransferTable(private val coroutineScope: CoroutineScope, private val tabl } override fun paintComponent(g: Graphics) { + val width = width + val height = height + // 原始背景 g.color = background g.fillRect(0, 0, width, height) @@ -221,6 +243,25 @@ class TransferTable(private val coroutineScope: CoroutineScope, private val tabl g.color = UIManager.getColor("Table.selectionInactiveBackground") g.fillRect(0, padding, width, height - padding * 2) + if (map.containsKey(value)) { + val state = getState(value) + if (state == State.Processing || state == State.Failed) { + val indeterminate = map.getValue(value) + + g.color = if (state == State.Processing) UIManager.getColor("ProgressBar.foreground") + else UIManager.getColor("Component.error.focusedBorderColor") + + g.fillRect(indeterminate.progress, padding, block, height - padding * 2) + if (indeterminate.progress + block > width) { + val c = width - indeterminate.progress - block + val x = -block - c + g.fillRect(x, padding, block, height - padding * 2) + } + return + } + + } + // 进度条颜色 g.color = UIManager.getColor("ProgressBar.foreground") g.fillRect(0, padding, (width * progress).toInt(), height - padding * 2) @@ -233,6 +274,31 @@ class TransferTable(private val coroutineScope: CoroutineScope, private val tabl // 绘制文字 ui.paint(g, this) } + + override fun dispose() { + timer.stop() + } + + override fun actionPerformed(e: ActionEvent) { + for (i in 0 until table.rowCount) { + val row = table.getPathForRow(i).lastPathComponent ?: continue + val node = tableModel.getValueAt(row, TransferTableModel.COLUMN_PROGRESS) + if (node !is TransferTreeTableNode) continue + if (node.state() != State.Processing) continue + val c = map[node] ?: continue + val rect = table.getCellRect(i, TransferTableModel.COLUMN_PROGRESS, false) + val indeterminate = c.copy(progress = min(c.progress + block / 10, rect.width)) + map[node] = if (indeterminate.progress == rect.width) Indeterminate() else indeterminate + table.repaint(rect) + } + } + + private fun getState(value: Any?): State? { + if (value == null) return null + val c = tableModel.getValueAt(value, TransferTableModel.COLUMN_PROGRESS) + if (c !is TransferTreeTableNode) return null + return c.state() + } } } diff --git a/src/main/kotlin/app/termora/transfer/TransferTableModel.kt b/src/main/kotlin/app/termora/transfer/TransferTableModel.kt index 351a5d1..d9fb8a7 100644 --- a/src/main/kotlin/app/termora/transfer/TransferTableModel.kt +++ b/src/main/kotlin/app/termora/transfer/TransferTableModel.kt @@ -87,11 +87,15 @@ class TransferTableModel(private val coroutineScope: CoroutineScope) : eventListener.add(TransferListener::class.java, listener) return object : Disposable { override fun dispose() { - eventListener.remove(TransferListener::class.java, listener) + removeTransferListener(listener) } } } + override fun removeTransferListener(listener: TransferListener) { + 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 diff --git a/src/main/kotlin/app/termora/transfer/TransferTreeTableNode.kt b/src/main/kotlin/app/termora/transfer/TransferTreeTableNode.kt index e9d1ac3..f64d850 100644 --- a/src/main/kotlin/app/termora/transfer/TransferTreeTableNode.kt +++ b/src/main/kotlin/app/termora/transfer/TransferTreeTableNode.kt @@ -61,15 +61,18 @@ class TransferTreeTableNode(transfer: Transfer) : DefaultMutableTreeTableNode(tr (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) + val indeterminate = transfer is TransferIndeterminate + val formatSize = "${formatBytes(totalBytesTransferred)} / ${formatBytes(filesize)}" + val formatEstimatedTime = if (indeterminate) "-" else if (isProcessing) formatSeconds(estimatedTime) else "-" 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_SIZE -> if (indeterminate) "-" else formatSize + TransferTableModel.COLUMN_SPEED -> if (indeterminate) "-" else if (isProcessing) "${formatBytes(speed)}/s" else "-" + TransferTableModel.COLUMN_ESTIMATED_TIME -> formatEstimatedTime TransferTableModel.COLUMN_PROGRESS -> this else -> StringUtils.EMPTY } diff --git a/src/main/kotlin/app/termora/transfer/TransportContextMenuExtension.kt b/src/main/kotlin/app/termora/transfer/TransportContextMenuExtension.kt new file mode 100644 index 0000000..a0dc3c9 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/TransportContextMenuExtension.kt @@ -0,0 +1,22 @@ +package app.termora.transfer + +import app.termora.WindowScope +import app.termora.plugin.Extension +import java.nio.file.FileSystem +import java.nio.file.Path +import javax.swing.JMenuItem + +internal interface TransportContextMenuExtension : Extension { + + /** + * 抛出 [UnsupportedOperationException] 表示不支持 + * + * @param fileSystem 为 null 表示可能已经断线,处于不可用状态 + */ + fun createJMenuItem( + windowScope: WindowScope, + fileSystem: FileSystem?, + popupMenu: TransportPopupMenu, + files: List> + ): JMenuItem +} \ 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 index 75bcc06..db4c43d 100644 --- a/src/main/kotlin/app/termora/transfer/TransportPanel.kt +++ b/src/main/kotlin/app/termora/transfer/TransportPanel.kt @@ -3,9 +3,11 @@ package app.termora.transfer import app.termora.* import app.termora.actions.DataProvider +import app.termora.actions.DataProviderSupport import app.termora.database.DatabaseManager import app.termora.plugin.ExtensionManager import app.termora.plugin.internal.wsl.WSLHostTerminalTab +import app.termora.terminal.DataKey import app.termora.transfer.TransportTableModel.Attributes import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.components.FlatToolBar @@ -60,11 +62,13 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds internal class TransportPanel( - private val transferManager: InternalTransferManager, + private val internalTransferManager: InternalTransferManager, val host: Host, val loader: TransportSupportLoader, ) : JPanel(BorderLayout()), DataProvider, Disposable, TransportNavigator { companion object { + val MyTransportPanel = DataKey(TransportPanel::class) + private val log = LoggerFactory.getLogger(TransportPanel::class.java) private val folderIcon = FlatTreeClosedIcon() private val fileIcon = FlatTreeLeafIcon() @@ -117,7 +121,7 @@ internal class TransportPanel( private val disposed = AtomicBoolean(false) private val futures = Collections.synchronizedSet(mutableSetOf>()) - + private val support = DataProviderSupport() /** * 工作目录 @@ -212,6 +216,8 @@ internal class TransportPanel( add(toolbar, BorderLayout.NORTH) add(layeredPane, BorderLayout.CENTER) + + support.addData(MyTransportPanel, this) } private fun compare(o1: Attributes, o2: Attributes): Int? { @@ -286,7 +292,7 @@ internal class TransportPanel( }) // 传输完成之后刷新 - transferManager.addTransferListener(object : TransferListener { + internalTransferManager.addTransferListener(object : TransferListener { override fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) { if (state != TransferTreeTableNode.State.Done && state != TransferTreeTableNode.State.Failed) return val target = transfer.target() @@ -294,13 +300,17 @@ internal class TransportPanel( if (target.fileSystem != loader.getSyncTransportSupport().getFileSystem()) return } if (target.pathString == workdir?.pathString || target.parent.pathString == workdir?.pathString) { - reload(requestFocus = false) + if (loading) { + nextReloadCallbacks.add { reload(requestFocus = false) } + } else { + reload(requestFocus = false) + } } } }).let { Disposer.register(this, it) } // High 专门用于编辑目的,下载完成之后立即去编辑 - transferManager.addTransferListener(editTransferListener).let { Disposer.register(this, it) } + internalTransferManager.addTransferListener(editTransferListener).let { Disposer.register(this, it) } // parent button addPropertyChangeListener("loading") { evt -> @@ -427,8 +437,8 @@ internal class TransportPanel( enterSelectionFolder() } else { val paths = listOf(model.getPath(row) to attributes) - if (loader.isOpened() && transferManager.canTransfer(paths.map { it.first })) { - transferManager.addTransfer(paths, InternalTransferManager.TransferMode.Transfer) + if (loader.isOpened() && internalTransferManager.canTransfer(paths.map { it.first })) { + internalTransferManager.addTransfer(paths, InternalTransferManager.TransferMode.Transfer) } } } else if (SwingUtilities.isRightMouseButton(e)) { @@ -517,7 +527,7 @@ internal class TransportPanel( override fun importData(support: TransferSupport): Boolean { val data = getTransferData(support, true) ?: return false - val future = transferManager + val future = internalTransferManager .addTransfer(data.files, data.workdir, InternalTransferManager.TransferMode.Transfer) mountFuture(future) @@ -609,7 +619,7 @@ internal class TransportPanel( } } - private fun registerSelectRow(name: String) { + fun registerSelectRow(name: String) { nextReloadCallbacks.add { for (i in 0 until model.rowCount) { if (model.getAttributes(i).name == name) { @@ -796,11 +806,14 @@ internal class TransportPanel( private fun showContextmenu(rows: Array, e: MouseEvent) { val files = rows.map { model.getPath(it) to model.getAttributes(it) } - val popupMenu = TransportPopupMenu(owner, model, transferManager, loader, files) + val popupMenu = TransportPopupMenu(owner, model, internalTransferManager, loader, files) popupMenu.addActionListener(PopupMenuActionListener(files)) popupMenu.show(table, e.x, e.y) } + override fun getData(dataKey: DataKey): T? { + return support.getData(dataKey) + } override fun navigateTo(destination: String): Boolean { assertEventDispatchThread() @@ -927,7 +940,7 @@ internal class TransportPanel( if (fs.isOpen.not()) continue // 发送到服务器 - transferManager.addHighTransfer(localPath, fs.getPath(target.absolutePathString())) + internalTransferManager.addHighTransfer(localPath, fs.getPath(target.absolutePathString())) oldMillis = millis } } @@ -1036,7 +1049,7 @@ internal class TransportPanel( val target = source.parent.resolve(e.source.toString()) processPath(e.source.toString()) { source.moveTo(target) } } else if (actionCommand == TransportPopupMenu.ActionCommand.Rmrf) { - transferManager.addTransfer(files, InternalTransferManager.TransferMode.Rmrf) + internalTransferManager.addTransfer(files, InternalTransferManager.TransferMode.Rmrf) } else if (actionCommand == TransportPopupMenu.ActionCommand.Reconnect) { // reload now reload() @@ -1046,7 +1059,7 @@ internal class TransportPanel( processPath(path.name) { if (c.includeSubFolder) { val future = withContext(Dispatchers.Swing) { - transferManager.addTransfer( + internalTransferManager.addTransfer( listOf(path to files.first().second.copy(permissions = c.permissions)), InternalTransferManager.TransferMode.ChangePermission ) @@ -1061,14 +1074,14 @@ internal class TransportPanel( } private fun transfer(mode: InternalTransferManager.TransferMode) { - val future = transferManager.addTransfer(files, mode) + val future = internalTransferManager.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) + val transferId = internalTransferManager.addHighTransfer(path, target) editTransferListener.addListenTransfer(transferId) } } diff --git a/src/main/kotlin/app/termora/transfer/TransportPopupMenu.kt b/src/main/kotlin/app/termora/transfer/TransportPopupMenu.kt index bd30522..7b128fe 100644 --- a/src/main/kotlin/app/termora/transfer/TransportPopupMenu.kt +++ b/src/main/kotlin/app/termora/transfer/TransportPopupMenu.kt @@ -1,9 +1,10 @@ package app.termora.transfer import app.termora.Application +import app.termora.ApplicationScope import app.termora.I18n -import app.termora.Icons import app.termora.OptionPane +import app.termora.plugin.ExtensionManager import app.termora.transfer.TransportPanel.Companion.isLocallyFileSystem import com.formdev.flatlaf.extras.components.FlatPopupMenu import org.apache.commons.io.IOUtils @@ -20,7 +21,6 @@ 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 @@ -42,7 +42,6 @@ internal class TransportPopupMenu( 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")) @@ -52,6 +51,7 @@ internal class TransportPopupMenu( 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 extensionManager get() = ExtensionManager.getInstance() private val eventListeners = EventListenerList() private val mnemonics = mapOf( refreshMenu to KeyEvent.VK_R, @@ -89,13 +89,32 @@ internal class TransportPopupMenu( addSeparator() add(renameMenu) add(deleteMenu) - if (fileSystem is SftpFileSystem) { - add(rmrfMenu) - } add(changePermissionsMenu) + + val menus = mutableListOf() + for (extension in extensionManager.getExtensions(TransportContextMenuExtension::class.java)) { + try { + val menu = extension.createJMenuItem( + ApplicationScope.forWindowScope(owner), + fileSystem, + this, + files + ) + menus.add(menu) + } catch (_: UnsupportedOperationException) { + continue + } + } + + if (menus.isNotEmpty()) { + addSeparator() + menus.forEach { add(it) } + } + addSeparator() add(refreshMenu) addSeparator() + add(newMenu) // 开发环境提供断线 @@ -113,7 +132,6 @@ internal class TransportPopupMenu( && 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.isVisible = hasParent.not() && fileSystem is SftpFileSystem && files.size == 1 for ((item, mnemonic) in mnemonics) { @@ -134,16 +152,7 @@ internal class TransportPopupMenu( 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) } @@ -159,7 +168,7 @@ internal class TransportPopupMenu( } } - private fun fireActionPerformed(evt: ActionEvent, command: ActionCommand) { + fun fireActionPerformed(evt: ActionEvent, command: ActionCommand) { for (listener in eventListeners.getListeners(ActionListener::class.java)) { listener.actionPerformed(ActionEvent(evt.source, evt.id, command.name)) } diff --git a/src/main/kotlin/app/termora/transfer/TransportTableModel.kt b/src/main/kotlin/app/termora/transfer/TransportTableModel.kt index 61765b2..e6d7745 100644 --- a/src/main/kotlin/app/termora/transfer/TransportTableModel.kt +++ b/src/main/kotlin/app/termora/transfer/TransportTableModel.kt @@ -7,7 +7,7 @@ import java.nio.file.Path import java.nio.file.attribute.PosixFilePermission import javax.swing.table.DefaultTableModel -class TransportTableModel() : DefaultTableModel() { +internal class TransportTableModel() : DefaultTableModel() { companion object { const val COLUMN_NAME = 0 const val COLUMN_TYPE = 1 @@ -60,7 +60,7 @@ class TransportTableModel() : DefaultTableModel() { } - data class Attributes( + internal data class Attributes( val name: String, val type: String, val isDirectory: Boolean, diff --git a/src/main/kotlin/app/termora/transfer/TransportViewer.kt b/src/main/kotlin/app/termora/transfer/TransportViewer.kt index 6fe6855..1132fd0 100644 --- a/src/main/kotlin/app/termora/transfer/TransportViewer.kt +++ b/src/main/kotlin/app/termora/transfer/TransportViewer.kt @@ -4,6 +4,8 @@ import app.termora.Disposable import app.termora.Disposer import app.termora.DynamicColor import app.termora.actions.DataProvider +import app.termora.actions.DataProviderSupport +import app.termora.terminal.DataKey import kotlinx.coroutines.* import kotlinx.coroutines.swing.Swing import java.awt.* @@ -15,6 +17,9 @@ import kotlin.time.Duration.Companion.milliseconds internal class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable { + companion object { + val MyTransferManager = DataKey(TransferManager::class) + } private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val splitPane = JSplitPane() @@ -26,6 +31,7 @@ internal class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposabl private val rightTransferManager = createInternalTransferManager(rightTabbed, leftTabbed) private val rootSplitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT) private val owner get() = SwingUtilities.getWindowAncestor(this) + private val support = DataProviderSupport() init { initView() @@ -56,6 +62,8 @@ internal class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposabl rootSplitPane.topComponent = splitPane rootSplitPane.bottomComponent = scrollPane + support.addData(MyTransferManager, transferManager) + add(rootSplitPane, BorderLayout.CENTER) } @@ -147,6 +155,10 @@ internal class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposabl coroutineScope.cancel() } + override fun getData(dataKey: DataKey): T? { + return support.getData(dataKey) + } + internal class MyIcon(private val color: Color) : Icon { private val size = 10 diff --git a/src/main/kotlin/app/termora/transfer/internal/sftp/CompressMode.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/CompressMode.kt new file mode 100644 index 0000000..c239723 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/CompressMode.kt @@ -0,0 +1,8 @@ +package app.termora.transfer.internal.sftp + +internal enum class CompressMode(val extension: String) { + TarGz("tar.gz"), + Tar("tar"), + Zip("zip"), + SevenZ("7z"), +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/internal/sftp/CompressTransportContextMenuExtension.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/CompressTransportContextMenuExtension.kt new file mode 100644 index 0000000..8ff5730 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/CompressTransportContextMenuExtension.kt @@ -0,0 +1,153 @@ +package app.termora.transfer.internal.sftp + +import app.termora.I18n +import app.termora.WindowScope +import app.termora.actions.AnAction +import app.termora.actions.AnActionEvent +import app.termora.randomUUID +import app.termora.transfer.* +import org.apache.commons.lang3.StringUtils +import org.apache.sshd.common.file.util.MockPath +import org.apache.sshd.sftp.client.fs.SftpFileSystem +import java.nio.file.FileSystem +import java.nio.file.Path +import javax.swing.JMenu +import javax.swing.JMenuItem +import kotlin.io.path.absolutePathString +import kotlin.io.path.name +import kotlin.io.path.pathString + +internal class CompressTransportContextMenuExtension private constructor() : TransportContextMenuExtension { + companion object { + val instance = CompressTransportContextMenuExtension() + } + + override fun createJMenuItem( + windowScope: WindowScope, + fileSystem: FileSystem?, + popupMenu: TransportPopupMenu, + files: List> + ): JMenuItem { + if (files.isEmpty() || fileSystem !is SftpFileSystem) throw UnsupportedOperationException() + val hasParent = files.any { it.second.isParent } + if (hasParent) throw UnsupportedOperationException() + + val compressMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.compress")) + for (mode in CompressMode.entries) { + compressMenu.add(mode.extension).addActionListener(object : AnAction() { + override fun actionPerformed(evt: AnActionEvent) { + compress(evt, fileSystem, mode, files) + } + }) + } + + return compressMenu + } + + + private fun compress( + event: AnActionEvent, + fileSystem: SftpFileSystem, + mode: CompressMode, + files: List>, + ) { + val transferManager = event.getData(TransportViewer.MyTransferManager) ?: return + val file = files.first().first + val workdir = file.parent ?: file.fileSystem.getPath(file.fileSystem.separator) + val name = StringUtils.defaultIfBlank(if (files.size > 1) workdir.name else file.name, "compress") + val target = workdir.resolve(name + ".${mode.extension}") + val myTransfer = CompressTransfer(fileSystem, mode, files, workdir, target) + if (transferManager.addTransfer(myTransfer).not()) return + + val panel = event.getData(TransportPanel.MyTransportPanel) ?: return + transferManager.addTransferListener(object : TransferListener { + override fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) { + if (transfer.id() != myTransfer.id()) return + if (state == TransferTreeTableNode.State.Done || state == TransferTreeTableNode.State.Failed) { + transferManager.removeTransferListener(this) + if (state == TransferTreeTableNode.State.Done) { + panel.registerSelectRow(target.name) + } + } + } + }) + } + + + private class CompressTransfer( + private val fileSystem: SftpFileSystem, + private val mode: CompressMode, + private val files: List>, + private val workdir: Path, + private val target: Path, + ) : Transfer, TransferIndeterminate { + private val myID = randomUUID() + private var end = false + private val mySource = if (files.size == 1) files.first().first + else MockPath(files.joinToString(",") { it.first.pathString }) + + @Suppress("CascadeIf") + override suspend fun transfer(bufferSize: Int): Long { + if (end) return 0 + + val paths = files.joinToString(StringUtils.SPACE) { "'${it.second.name}'" } + val command = StringBuilder() + command.append("cd '${workdir.absolutePathString()}'") + command.append(" && ") + if (mode == CompressMode.TarGz) { + command.append("tar -czf") + } else if (mode == CompressMode.Tar) { + command.append("tar -cf") + } else if (mode == CompressMode.Zip) { + command.append("zip -r") + } else if (mode == CompressMode.SevenZ) { + command.append("7z a") + } + command.append(" '${target.name}' ") + command.append(paths) + + fileSystem.clientSession.executeRemoteCommand(command.toString(), System.out, Charsets.UTF_8) + + end = true + + return size() + } + + override fun source(): Path { + return mySource + } + + override fun target(): Path { + return target + } + + override fun size(): Long { + return files.size.toLong() + } + + override fun isDirectory(): Boolean { + return false + } + + override fun priority(): Transfer.Priority { + return Transfer.Priority.High + } + + override fun scanning(): Boolean { + return false + } + + override fun id(): String { + return myID + } + + override fun parentId(): String { + return StringUtils.EMPTY + } + + } + + override fun ordered(): Long { + return 1 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/internal/sftp/ExtractTransportContextMenuExtension.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/ExtractTransportContextMenuExtension.kt new file mode 100644 index 0000000..01f7f3f --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/ExtractTransportContextMenuExtension.kt @@ -0,0 +1,189 @@ +package app.termora.transfer.internal.sftp + +import app.termora.I18n +import app.termora.WindowScope +import app.termora.actions.AnAction +import app.termora.actions.AnActionEvent +import app.termora.randomUUID +import app.termora.transfer.* +import org.apache.commons.lang3.StringUtils +import org.apache.sshd.sftp.client.fs.SftpFileSystem +import java.nio.file.FileSystem +import java.nio.file.Path +import javax.swing.JMenu +import javax.swing.JMenuItem +import kotlin.io.path.absolutePathString +import kotlin.io.path.name + +internal class ExtractTransportContextMenuExtension private constructor() : TransportContextMenuExtension { + companion object { + val instance = ExtractTransportContextMenuExtension() + } + + override fun createJMenuItem( + windowScope: WindowScope, + fileSystem: FileSystem?, + popupMenu: TransportPopupMenu, + files: List> + ): JMenuItem { + if (files.isEmpty() || fileSystem !is SftpFileSystem) throw UnsupportedOperationException() + val hasParent = files.any { it.second.isParent || it.second.isDirectory } + if (hasParent) throw UnsupportedOperationException() + + val map = mutableMapOf() + for ((path, attr) in files) { + val mode = CompressMode.entries.firstOrNull { attr.name.endsWith(".${it.extension}", true) } + if (mode == null) { + throw UnsupportedOperationException() + } + map[path] = mode + } + + val extractMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.extract")) + extractMenu.add(I18n.getString("termora.transport.table.contextmenu.extract.here")) + .addActionListener(object : AnAction() { + override fun actionPerformed(evt: AnActionEvent) { + extract(evt, fileSystem, ExtractLocation.Here, files.map { it.first to map.getValue(it.first) }) + } + }) + if (files.size == 1) { + val first = files.first() + val name = StringUtils.removeEndIgnoreCase(first.second.name, ".${map.getValue(first.first).extension}") + val text = I18n.getString("termora.transport.table.contextmenu.extract.single", name) + extractMenu.add(text).addActionListener(object : AnAction() { + override fun actionPerformed(evt: AnActionEvent) { + extract(evt, fileSystem, ExtractLocation.Self, files.map { it.first to map.getValue(it.first) }) + } + }) + } else { + extractMenu.add(I18n.getString("termora.transport.table.contextmenu.extract.multi")) + .addActionListener(object : AnAction() { + override fun actionPerformed(evt: AnActionEvent) { + extract(evt, fileSystem, ExtractLocation.Self, files.map { it.first to map.getValue(it.first) }) + } + }) + } + + + return extractMenu + } + + private fun extract( + event: AnActionEvent, + fileSystem: SftpFileSystem, + location: ExtractLocation, + files: List>, + ) { + for (pair in files) { + extract(event, fileSystem, location, pair.second, pair.first) + } + } + + + private fun extract( + event: AnActionEvent, + fileSystem: SftpFileSystem, + location: ExtractLocation, + mode: CompressMode, + file: Path, + ) { + val transferManager = event.getData(TransportViewer.MyTransferManager) ?: return + val workdir = file.parent ?: file.fileSystem.getPath(file.fileSystem.separator) + val target = if (location == ExtractLocation.Self) + workdir.resolve(StringUtils.removeEndIgnoreCase(file.name, ".${mode.extension}")) + else workdir + transferManager.addTransfer(ExtractTransfer(fileSystem, mode, file, workdir, target)) + } + + private class ExtractTransfer( + private val fileSystem: SftpFileSystem, + private val mode: CompressMode, + private val file: Path, + private val workdir: Path, + private val target: Path, + ) : Transfer, TransferIndeterminate { + private val myID = randomUUID() + private var end = false + + @Suppress("CascadeIf") + override suspend fun transfer(bufferSize: Int): Long { + if (end) return 0 + + val command = StringBuilder() + command.append("cd '${workdir.absolutePathString()}'") + command.append(" && ") + + if (mode == CompressMode.TarGz || mode == CompressMode.Tar) { + command.append(" mkdir -p '${target.absolutePathString()}' ") + command.append(" && ") + + command.append(" tar ") + if (mode == CompressMode.Tar) { + command.append(" -xf ") + } else { + command.append(" -zxf ") + } + + command.append(" '${source().absolutePathString()}' ") + command.append(" -C '${target.absolutePathString()}' ") + } else if (mode == CompressMode.Zip) { + command.append(" unzip -o -q ") + command.append(" '${source().absolutePathString()}' ") + command.append(" -d '${target.absolutePathString()}' ") + } else if (mode == CompressMode.SevenZ) { + command.append(" 7z x ") + command.append(" '${source().absolutePathString()}' ") + command.append(" -o'${target.absolutePathString()}' -y > /dev/null") + } + + fileSystem.clientSession.executeRemoteCommand(command.toString(), System.out, Charsets.UTF_8) + + end = true + + return size() + } + + override fun source(): Path { + return file + } + + override fun target(): Path { + return target + } + + override fun size(): Long { + return 1 + } + + override fun isDirectory(): Boolean { + return false + } + + override fun priority(): Transfer.Priority { + return Transfer.Priority.High + } + + override fun scanning(): Boolean { + return false + } + + override fun id(): String { + return myID + } + + override fun parentId(): String { + return StringUtils.EMPTY + } + + } + + + private enum class ExtractLocation { + Self, + Here, + } + + override fun ordered(): Long { + return 2 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/internal/sftp/RmrfTransportContextMenuExtension.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/RmrfTransportContextMenuExtension.kt new file mode 100644 index 0000000..be36165 --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/RmrfTransportContextMenuExtension.kt @@ -0,0 +1,48 @@ +package app.termora.transfer.internal.sftp + +import app.termora.I18n +import app.termora.Icons +import app.termora.OptionPane +import app.termora.WindowScope +import app.termora.transfer.TransportContextMenuExtension +import app.termora.transfer.TransportPopupMenu +import app.termora.transfer.TransportTableModel +import org.apache.sshd.sftp.client.fs.SftpFileSystem +import java.nio.file.FileSystem +import java.nio.file.Path +import javax.swing.JMenuItem +import javax.swing.JOptionPane + +internal class RmrfTransportContextMenuExtension private constructor() : TransportContextMenuExtension { + companion object { + val instance = RmrfTransportContextMenuExtension() + } + + override fun createJMenuItem( + windowScope: WindowScope, + fileSystem: FileSystem?, + popupMenu: TransportPopupMenu, + files: List> + ): JMenuItem { + if (files.isEmpty() || fileSystem !is SftpFileSystem) throw UnsupportedOperationException() + val hasParent = files.any { it.second.isParent } + if (hasParent) throw UnsupportedOperationException() + + val rmrfMenu = JMenuItem("rm -rf", Icons.warningIntroduction) + rmrfMenu.addActionListener { + if (OptionPane.showConfirmDialog( + windowScope.window, + I18n.getString("termora.transport.table.contextmenu.rm-warning"), + messageType = JOptionPane.ERROR_MESSAGE + ) == JOptionPane.YES_OPTION + ) { + popupMenu.fireActionPerformed(it, TransportPopupMenu.ActionCommand.Rmrf) + } + } + return rmrfMenu + } + + override fun ordered(): Long { + return 0 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPPlugin.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPPlugin.kt index 4be8bd0..ce0815e 100644 --- a/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPPlugin.kt +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPPlugin.kt @@ -4,11 +4,15 @@ import app.termora.FrameExtension import app.termora.plugin.Extension import app.termora.plugin.InternalPlugin import app.termora.protocol.ProtocolProviderExtension +import app.termora.transfer.TransportContextMenuExtension internal class SFTPPlugin : InternalPlugin() { init { support.addExtension(ProtocolProviderExtension::class.java) { SFTPProtocolProviderExtension.instance } support.addExtension(FrameExtension::class.java) { SFTPFrameExtension.instance } + support.addExtension(TransportContextMenuExtension::class.java) { RmrfTransportContextMenuExtension.instance } + support.addExtension(TransportContextMenuExtension::class.java) { CompressTransportContextMenuExtension.instance } + support.addExtension(TransportContextMenuExtension::class.java) { ExtractTransportContextMenuExtension.instance } } override fun getName(): String { diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 9373a55..31edc69 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -337,6 +337,11 @@ termora.transport.table.contextmenu.delete-warning=If the folder is too large, d termora.transport.table.contextmenu.rm-warning=Using the rm -rf command to delete a file is very dangerous termora.transport.table.contextmenu.change-permissions=Change Permissions... termora.transport.table.contextmenu.refresh=Refresh +termora.transport.table.contextmenu.compress=Compress +termora.transport.table.contextmenu.extract=Extract +termora.transport.table.contextmenu.extract.here=Extract here +termora.transport.table.contextmenu.extract.single=Extract to {0}\\ +termora.transport.table.contextmenu.extract.multi=Extract to *\\* termora.transport.table.contextmenu.new=${termora.welcome.contextmenu.new} termora.transport.table.contextmenu.new.folder=${termora.welcome.contextmenu.new.folder.name} termora.transport.table.contextmenu.new.file=New File diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 03b8f55..3d07a67 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -328,6 +328,11 @@ termora.transport.table.contextmenu.edit-command=你必须在 “设置 - SFTP termora.transport.table.contextmenu.open-in-folder=在${termora.finder}中打开 termora.transport.table.contextmenu.change-permissions=更改权限... termora.transport.table.contextmenu.refresh=刷新 +termora.transport.table.contextmenu.compress=压缩 +termora.transport.table.contextmenu.extract=解压 +termora.transport.table.contextmenu.extract.here=解压到当前目录 +termora.transport.table.contextmenu.extract.single=解压到 {0}\\ +termora.transport.table.contextmenu.extract.multi=解压到 *\\* termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件 termora.transport.table.contextmenu.delete-warning=如果文件夹太大,删除可能需要耗费一定时间 termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令删除文件存在很大风险 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index f4cfa84..7006783 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -323,6 +323,11 @@ termora.transport.table.contextmenu.edit-command=你必須在 “設定 - SFTP termora.transport.table.contextmenu.open-in-folder=在${termora.finder}中打開 termora.transport.table.contextmenu.change-permissions=更改權限... termora.transport.table.contextmenu.refresh=刷新 +termora.transport.table.contextmenu.compress=壓縮 +termora.transport.table.contextmenu.extract=解壓縮 +termora.transport.table.contextmenu.extract.here=解壓縮到目前目錄 +termora.transport.table.contextmenu.extract.single=解壓縮到 {0}\\ +termora.transport.table.contextmenu.extract.multi=解壓縮到 *\\* termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件 termora.transport.table.contextmenu.delete-warning=如果資料夾太大,刪除可能需要耗費一定時間 termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令刪除資料存在很大風險 diff --git a/src/test/resources/sshd/Dockerfile b/src/test/resources/sshd/Dockerfile index b5bbce4..23b16c2 100644 --- a/src/test/resources/sshd/Dockerfile +++ b/src/test/resources/sshd/Dockerfile @@ -1,6 +1,6 @@ FROM linuxserver/openssh-server RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk update && apk add wget tmux gcc g++ git make zsh htop stress-ng inetutils-telnet xclock xcalc xorg-server xinit && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \ + && apk update && apk add wget tmux gcc zip p7zip g++ git make zsh htop stress-ng inetutils-telnet xclock xcalc xorg-server xinit && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \ && tar -xf lrzsz-0.12.20.tar.gz && cd lrzsz-0.12.20 && ./configure && make && make install \ && ln -s /usr/local/bin/lrz /usr/local/bin/rz && ln -s /usr/local/bin/lsz /usr/local/bin/sz RUN sed -i 's/#AllowAgentForwarding yes/AllowAgentForwarding yes/g' /etc/ssh/sshd_config