feat: transfer support compress and extract

This commit is contained in:
hstyi
2025-07-07 15:38:45 +08:00
committed by hstyi
parent 574c816ebb
commit 66a81a5da3
22 changed files with 603 additions and 50 deletions

View File

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

View File

@@ -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<Window>,
private val coroutineScope: CoroutineScope,
private val transferManager: TransferManager,

View File

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

View File

@@ -0,0 +1,3 @@
package app.termora.transfer
interface TransferIndeterminate

View File

@@ -30,4 +30,8 @@ interface TransferManager {
*/
fun addTransferListener(listener: TransferListener): Disposable
/**
* 移除传输监听器
*/
fun removeTransferListener(listener: TransferListener)
}

View File

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

View File

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

View File

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

View File

@@ -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<Pair<Path, TransportTableModel.Attributes>>
): JMenuItem
}

View File

@@ -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<Future<*>>())
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<Int>, 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 <T : Any> getData(dataKey: DataKey<T>): 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)
}
}

View File

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

View File

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

View File

@@ -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 <T : Any> getData(dataKey: DataKey<T>): T? {
return support.getData(dataKey)
}
internal class MyIcon(private val color: Color) : Icon {
private val size = 10

View File

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

View File

@@ -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<Pair<Path, TransportTableModel.Attributes>>
): 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<Pair<Path, TransportTableModel.Attributes>>,
) {
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<Pair<Path, TransportTableModel.Attributes>>,
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
}
}

View File

@@ -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<Pair<Path, TransportTableModel.Attributes>>
): 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<Path, CompressMode>()
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<Pair<Path, CompressMode>>,
) {
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
}
}

View File

@@ -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<Pair<Path, TransportTableModel.Attributes>>
): 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
}
}

View File

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

View File

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

View File

@@ -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 命令删除文件存在很大风险

View File

@@ -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 命令刪除資料存在很大風險

View File

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