feat: support transfer virtual window

This commit is contained in:
hstyi
2025-06-22 12:42:18 +08:00
committed by hstyi
parent 2bf4d277be
commit 187d5be658
20 changed files with 1063 additions and 434 deletions

View File

@@ -20,6 +20,7 @@ object Icons {
val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") }
val questionMark by lazy { DynamicIcon("icons/questionMark.svg", "icons/questionMark_dark.svg") }
val regex by lazy { DynamicIcon("icons/regex.svg", "icons/regex_dark.svg") }
val vcs by lazy { DynamicIcon("icons/vcs.svg", "icons/vcs_dark.svg") }
val dumpThreads by lazy { DynamicIcon("icons/dumpThreads.svg", "icons/dumpThreads_dark.svg") }
@@ -119,6 +120,7 @@ object Icons {
val toolWindowJsonPath by lazy { DynamicIcon("icons/toolWindowJsonPath.svg", "icons/toolWindowJsonPath_dark.svg") }
val codeSpan by lazy { DynamicIcon("icons/codeSpan.svg", "icons/codeSpan_dark.svg") }
val fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
val transferToolWindow by lazy { DynamicIcon("icons/transferToolWindow.svg", "icons/transferToolWindow_dark.svg") }
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") }
val applyNotConflictsLeft by lazy {
DynamicIcon(

View File

@@ -146,6 +146,9 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
}
}
}
// stop
stop()
}
}
})

View File

@@ -12,6 +12,7 @@ import app.termora.snippet.SnippetTreeDialog
import app.termora.terminal.DataKey
import app.termora.terminal.panel.vw.NvidiaSMIVisualWindow
import app.termora.terminal.panel.vw.SystemInformationVisualWindow
import app.termora.terminal.panel.vw.TransferVisualWindow
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.ui.FlatRoundBorder
import org.apache.commons.lang3.StringUtils
@@ -118,6 +119,9 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
// 服务器信息
add(initServerInfoActionButton())
// Transfer
add(initTransferActionButton())
// Snippet
add(initSnippetActionButton())
@@ -185,6 +189,34 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
return btn
}
private fun initTransferActionButton(): JButton {
val btn = JButton(Icons.folder)
btn.toolTipText = I18n.getString("termora.transport.sftp")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = anEvent.getData(DataProviders.TerminalTab) ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
if (tab !is SSHTerminalTab) {
terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported"))
return
}
for (window in terminalPanel.getVisualWindows()) {
if (window is TransferVisualWindow) {
terminalPanel.moveToFront(window)
return
}
}
val visualWindowPanel = TransferVisualWindow(tab, terminalPanel)
terminalPanel.addVisualWindow(visualWindowPanel)
}
})
return btn
}
private fun initSnippetActionButton(): JButton {
val btn = JButton(Icons.codeSpan)
btn.toolTipText = I18n.getString("termora.snippet.title")

View File

@@ -588,6 +588,7 @@ class TerminalPanel(val terminal: Terminal, private val writer: TerminalWriter)
requestFocusInWindow()
}
@Suppress("CascadeIf")
override fun resumeVisualWindows(id: String, dataProvider: DataProvider) {
val windows = properties.getString("VisualWindow.${id}.store") ?: return
for (name in windows.split(",")) {
@@ -605,6 +606,13 @@ class TerminalPanel(val terminal: Terminal, private val writer: TerminalWriter)
this
)
)
} else if (name == "Transfer") {
addVisualWindow(
TransferVisualWindow(
dataProvider.getData(DataProviders.TerminalTab) as SSHTerminalTab,
this
)
)
}
}
}

View File

@@ -57,8 +57,8 @@ class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWind
initVisualWindowPanel()
}
override fun toolbarButtons(): List<JButton> {
return listOf(percentageBtn)
override fun toolbarButtons(): List<Pair<JButton, Position>> {
return listOf(percentageBtn to Position.Right)
}
private fun initViews() {

View File

@@ -0,0 +1,444 @@
package app.termora.terminal.panel.vw
import app.termora.*
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.plugin.internal.ssh.SSHTerminalTab
import app.termora.plugin.internal.ssh.SSHTerminalTab.Companion.SSHSession
import app.termora.terminal.DataKey
import app.termora.terminal.DataListener
import app.termora.transfer.*
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.sftp.client.SftpClientFactory
import org.jdesktop.swingx.JXBusyLabel
import org.jdesktop.swingx.JXHyperlink
import org.slf4j.LoggerFactory
import java.awt.*
import java.awt.event.ActionEvent
import java.awt.event.KeyEvent
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import java.nio.file.Path
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import javax.swing.*
import kotlin.io.path.absolutePathString
import kotlin.reflect.cast
import kotlin.time.Duration.Companion.milliseconds
class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "Transfer", visualWindowManager) {
companion object {
private val log = LoggerFactory.getLogger(TransferVisualWindow::class.java)
}
private enum class State {
Connecting,
Transfer,
Failed,
}
private val executorService = Executors.newVirtualThreadPerTaskExecutor()
private val coroutineDispatcher = executorService.asCoroutineDispatcher()
private val coroutineScope = CoroutineScope(coroutineDispatcher)
private val cardLayout = CardLayout()
private val panel = JPanel(cardLayout)
private val connectingPanel = ConnectingPanel()
private val connectFailedPanel = ConnectFailedPanel()
private val transferManager = TransferTableModel(coroutineScope)
private val disposable = Disposer.newDisposable()
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val questionBtn = JButton(Icons.questionMark)
private val badgeIcon = BadgeIcon(Icons.download)
private val downloadBtn = JButton(badgeIcon)
init {
initViews()
initEvents()
initVisualWindowPanel()
}
private fun initViews() {
title = "SFTP"
panel.add(connectingPanel, State.Connecting.name)
panel.add(connectFailedPanel, State.Failed.name)
add(panel, BorderLayout.CENTER)
}
private fun initEvents() {
Disposer.register(tab, this)
Disposer.register(this, disposable)
connectingPanel.busyLabel.isBusy = true
val terminal = tab.getData(DataProviders.TerminalPanel)?.getData(DataProviders.Terminal)
terminal?.getTerminalModel()?.addDataListener(object : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
// https://github.com/TermoraDev/termora/pull/244
if (key == DataKey.CurrentDir) {
val dir = DataKey.CurrentDir.clazz.cast(data)
val navigator = getTransportNavigator() ?: return
val path = navigator.getFileSystem().getPath(dir)
if (path == navigator.workdir) return
navigator.navigateTo(path)
}
}
})
downloadBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = DownloadDialog()
dialog.setLocationRelativeTo(downloadBtn)
dialog.setLocation(dialog.x, downloadBtn.locationOnScreen.y + downloadBtn.height + 1)
dialog.isVisible = true
}
})
transferManager.addTransferListener(object : TransferListener {
override fun onTransferCountChanged() {
val oldVisible = badgeIcon.visible
val newVisible = transferManager.getTransferCount() > 0
if (oldVisible != newVisible) {
badgeIcon.visible = newVisible
downloadBtn.repaint()
}
}
})
// 立即连接
connect()
}
private fun connect() {
connectingPanel.busyLabel.isBusy = true
cardLayout.show(panel, State.Connecting.name)
coroutineScope.launch {
try {
val session = getSession()
val fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
val support = TransportSupport(fileSystem, fileSystem.defaultDir.absolutePathString())
withContext(Dispatchers.Swing) {
val internalTransferManager = MyInternalTransferManager()
val transportPanel = TransportPanel(
internalTransferManager, tab.host,
TransportSupportLoader { support })
internalTransferManager.setTransferPanel(transportPanel)
Disposer.register(transportPanel, object : Disposable {
override fun dispose() {
panel.remove(transportPanel)
IOUtils.closeQuietly(fileSystem)
swingCoroutineScope.launch {
connectFailedPanel.errorLabel.text = I18n.getString("termora.transport.sftp.closed")
cardLayout.show(panel, State.Failed.name)
}
}
})
Disposer.register(disposable, transportPanel)
// 如果 session 关闭,立即销毁 Transfer
session.addCloseFutureListener { Disposer.dispose(transportPanel) }
panel.add(transportPanel, State.Transfer.name)
cardLayout.show(panel, State.Transfer.name)
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
withContext(Dispatchers.Swing) {
connectFailedPanel.errorLabel.text = ExceptionUtils.getRootCauseMessage(e)
cardLayout.show(panel, State.Failed.name)
}
} finally {
swingCoroutineScope.launch { connectingPanel.busyLabel.isBusy = false }
}
}
}
private suspend fun getSession(): ClientSession {
while (coroutineScope.isActive) {
val session = tab.getData(SSHSession)
if (session == null) {
delay(250.milliseconds)
continue
}
return session
}
throw IllegalStateException("Session is null")
}
private fun getTransportNavigator(): TransportPanel? {
for (i in 0 until panel.componentCount) {
val c = panel.getComponent(i)
if (c is TransportPanel) {
return c
}
}
return null
}
override fun beforeClose(): Boolean {
if (transferManager.getTransferCount() > 0) {
return OptionPane.showConfirmDialog(
owner,
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}
return super.beforeClose()
}
override fun dispose() {
coroutineScope.cancel()
coroutineDispatcher.close()
executorService.shutdownNow()
connectingPanel.busyLabel.isBusy = false
super.dispose()
}
override fun toolbarButtons(): List<Pair<JButton, Position>> {
return listOf(downloadBtn to Position.Left, questionBtn to Position.Right)
}
private inner class DownloadDialog() : JDialog() {
init {
size = Dimension(UIManager.getInt("Dialog.width") - 150, UIManager.getInt("Dialog.height") - 100)
isModal = false
title = I18n.getString("termora.transport.sftp")
isUndecorated = true
layout = BorderLayout()
add(createCenterPanel(), BorderLayout.CENTER)
val window = this
addWindowFocusListener(object : WindowAdapter() {
override fun windowLostFocus(e: WindowEvent?) {
window.dispose()
}
})
val inputMap = rootPane.getInputMap(WHEN_IN_FOCUSED_WINDOW)
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "close")
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_W, toolkit.menuShortcutKeyMaskEx), "close")
rootPane.actionMap.put("close", object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
if (hasPopupMenus()) return
SwingUtilities.invokeLater { window.dispose() }
}
})
}
private fun hasPopupMenus(): Boolean {
val c = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
if (c != null) {
val popups: List<JPopupMenu> = SwingUtils.getDescendantsOfType(
JPopupMenu::class.java,
c as Container, true
)
var openPopup = false
for (p in popups) {
p.isVisible = false
openPopup = true
}
val window = c as? Window ?: SwingUtilities.windowForComponent(c)
if (window != null) {
val windows = window.ownedWindows
for (w in windows) {
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
openPopup = true
w.dispose()
}
}
}
if (openPopup) {
return true
}
}
return false
}
private fun createCenterPanel(): JComponent {
val table = TransferTable(coroutineScope, transferManager)
val scrollPane = JScrollPane(table)
scrollPane.border = BorderFactory.createEmptyBorder()
addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
removeWindowListener(this)
Disposer.dispose(table)
}
})
return scrollPane
}
}
private inner class MyInternalTransferManager() : InternalTransferManager {
private lateinit var internalTransferManager: InternalTransferManager
fun setTransferPanel(transportPanel: TransportPanel) {
internalTransferManager = createInternalTransferManager(transportPanel)
}
override fun canTransfer(paths: List<Path>): Boolean {
return paths.isNotEmpty()
}
override fun addTransfer(
paths: List<Pair<Path, TransportTableModel.Attributes>>,
mode: InternalTransferManager.TransferMode
): CompletableFuture<Unit> {
if (mode == InternalTransferManager.TransferMode.Transfer) {
val future = CompletableFuture<Unit>()
val chooser = FileChooser()
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.allowsMultiSelection = false
chooser.showOpenDialog(owner).whenComplete { files, e ->
if (e != null) {
future.completeExceptionally(e)
} else if (files.isEmpty()) {
future.complete(Unit)
} else {
coroutineScope.launch(Dispatchers.Swing) {
try {
addTransfer(paths, files.first().toPath(), mode)
.thenApply { future.complete(it) }
.exceptionally { future.completeExceptionally(it) }
} catch (e: Exception) {
future.completeExceptionally(e)
}
}
}
}
return future
}
return internalTransferManager.addTransfer(paths, mode)
}
override fun addTransfer(
paths: List<Pair<Path, TransportTableModel.Attributes>>,
targetWorkdir: Path,
mode: InternalTransferManager.TransferMode
): CompletableFuture<Unit> {
return internalTransferManager.addTransfer(paths, targetWorkdir, mode)
}
override fun addHighTransfer(source: Path, target: Path): String {
return internalTransferManager.addHighTransfer(source, target)
}
override fun addTransferListener(listener: TransferListener): Disposable {
return transferManager.addTransferListener(listener)
}
private fun createWorkdirProvider(transportPanel: TransportPanel): DefaultInternalTransferManager.WorkdirProvider {
return object : DefaultInternalTransferManager.WorkdirProvider {
override fun getWorkdir(): Path? {
return transportPanel.workdir
}
override fun getTableModel(): TransportTableModel? {
return transportPanel.getTableModel()
}
}
}
private fun createInternalTransferManager(transportPanel: TransportPanel): InternalTransferManager {
return DefaultInternalTransferManager(
{ owner },
coroutineScope,
transferManager,
object : DefaultInternalTransferManager.WorkdirProvider {
override fun getWorkdir() = null
override fun getTableModel() = null
},
createWorkdirProvider(transportPanel)
)
}
}
private class ConnectingPanel : JPanel(BorderLayout()) {
val busyLabel = JXBusyLabel()
init {
initView()
}
private fun initView() {
val formMargin = "7dlu"
val layout = FormLayout(
"default:grow, pref, default:grow",
"40dlu, pref, $formMargin, pref"
)
val label = JLabel(I18n.getString("termora.transport.sftp.connecting"))
label.horizontalAlignment = SwingConstants.CENTER
busyLabel.horizontalAlignment = SwingConstants.CENTER
busyLabel.verticalAlignment = SwingConstants.CENTER
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(busyLabel).xy(2, 2, "fill, center")
builder.add(label).xy(2, 4)
add(builder.build(), BorderLayout.CENTER)
}
}
private inner class ConnectFailedPanel : JPanel(BorderLayout()) {
val errorLabel = JLabel()
init {
initView()
}
private fun initView() {
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow, pref, default:grow",
"40dlu, pref, $formMargin, pref, $formMargin, pref"
)
errorLabel.horizontalAlignment = SwingConstants.CENTER
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(FlatOptionPaneErrorIcon()).xy(2, 2)
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()
}
}).apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
isFocusable = false
}).xy(2, 6)
add(builder.build(), BorderLayout.CENTER)
}
}
}

View File

@@ -17,6 +17,10 @@ import kotlin.math.min
open class VisualWindowPanel(protected val id: String, protected val visualWindowManager: VisualWindowManager) :
JPanel(BorderLayout()), VisualWindow {
protected enum class Position {
Left, Right
}
private val stickPx = 2
protected val properties get() = DatabaseManager.getInstance().properties
private val titleLabel = JLabel()
@@ -90,7 +94,7 @@ open class VisualWindowPanel(protected val id: String, protected val visualWindo
alwaysTopBtn.isVisible = false
}
protected open fun toolbarButtons(): List<JButton> {
protected open fun toolbarButtons(): List<Pair<JButton, Position>> {
return emptyList()
}
@@ -164,19 +168,19 @@ open class VisualWindowPanel(protected val id: String, protected val visualWindo
}
private fun initToolBar() {
val btns = toolbarButtons()
val count = 2 + btns.size
val buttons = toolbarButtons()
val count = 2 + buttons.size - (buttons.count { it.second == Position.Left } * 2)
toolbar.add(alwaysTopBtn)
toolbar.add(Box.createHorizontalStrut(count * 26))
toolbar.add(JLabel(Icons.empty))
buttons.filter { it.second == Position.Left }.forEach { toolbar.add(it.first) }
toolbar.add(Box.createHorizontalStrut(max(count * 26, 0)))
toolbar.add(Box.createHorizontalGlue())
toolbar.add(titleLabel)
toolbar.add(Box.createHorizontalGlue())
btns.forEach { toolbar.add(it) }
buttons.filter { it.second == Position.Right }.forEach { toolbar.add(it.first) }
toolbar.add(toggleWindowBtn)
toolbar.add(JButton(Icons.close).apply { addActionListener { Disposer.dispose(visualWindow) } })
toolbar.add(JButton(Icons.close).apply { addActionListener { if (beforeClose()) Disposer.dispose(visualWindow) } })
toolbar.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
add(toolbar, BorderLayout.NORTH)
}
@@ -318,6 +322,9 @@ open class VisualWindowPanel(protected val id: String, protected val visualWindo
SwingUtilities.invokeLater { SwingUtilities.updateComponentTreeUI(this) }
}
protected open fun beforeClose(): Boolean {
return true
}
protected open fun close() {
if (isWindow()) {

View File

@@ -0,0 +1,39 @@
package app.termora.transfer
import app.termora.restore
import app.termora.save
import app.termora.setupAntialiasing
import java.awt.Component
import java.awt.Graphics
import java.awt.Graphics2D
import javax.swing.Icon
import javax.swing.UIManager
class BadgeIcon(
private val icon: Icon,
var visible: Boolean = false
) : Icon {
override fun paintIcon(c: Component, g: Graphics, x: Int, y: Int) {
icon.paintIcon(c, g, x, y)
if (g is Graphics2D) {
if (visible) {
g.save()
setupAntialiasing(g)
val size = 6
g.color = UIManager.getColor("Component.error.focusedBorderColor")
g.fillRoundRect(c.width - size - 4, 4, size, size, size, size)
g.restore()
}
}
}
override fun getIconWidth(): Int {
return icon.iconWidth
}
override fun getIconHeight(): Int {
return icon.iconHeight
}
}

View File

@@ -0,0 +1,409 @@
package app.termora.transfer
import app.termora.*
import app.termora.transfer.InternalTransferManager.TransferMode
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
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.time.DateFormatUtils
import org.slf4j.LoggerFactory
import java.awt.Component
import java.awt.Dimension
import java.awt.Window
import java.io.IOException
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.PosixFilePermission
import java.util.Date
import java.util.concurrent.CompletableFuture
import java.util.concurrent.atomic.AtomicBoolean
import java.util.function.Supplier
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 DefaultInternalTransferManager(
private val owner: Supplier<Window>,
private val coroutineScope: CoroutineScope,
private val transferManager: TransferManager,
private val source: WorkdirProvider,
private val target: WorkdirProvider
) : InternalTransferManager {
companion object {
private val log = LoggerFactory.getLogger(DefaultInternalTransferManager::class.java)
}
interface WorkdirProvider {
fun getWorkdir(): Path?
fun getTableModel(): TransportTableModel?
}
private data class AskTransfer(
val option: Int,
val action: TransferAction,
val applyAll: Boolean
)
private data class AskTransferContext(var action: TransferAction, var applyAll: Boolean)
override fun canTransfer(paths: List<Path>): Boolean {
return paths.isNotEmpty() && target.getWorkdir() != null
}
override fun addTransfer(
paths: List<Pair<Path, TransportTableModel.Attributes>>,
mode: TransferMode
): CompletableFuture<Unit> {
val workdir = (if (mode == TransferMode.Delete || mode == TransferMode.ChangePermission)
source.getWorkdir() ?: target.getWorkdir() else target.getWorkdir()) ?: throw IllegalStateException()
return addTransfer(paths, workdir, mode)
}
override fun addTransfer(
paths: List<Pair<Path, TransportTableModel.Attributes>>,
targetWorkdir: Path,
mode: TransferMode
): CompletableFuture<Unit> {
assertEventDispatchThread()
if (paths.isEmpty()) return CompletableFuture.completedFuture(Unit)
val future = CompletableFuture<Unit>()
val tableModel = when (targetWorkdir.fileSystem) {
source.getWorkdir()?.fileSystem -> source.getTableModel()
target.getWorkdir()?.fileSystem -> target.getTableModel()
else -> null
}
coroutineScope.launch(Dispatchers.IO) {
try {
val context = AskTransferContext(TransferAction.Overwrite, false)
for (pair in paths) {
if (mode == TransferMode.Transfer && tableModel != null && context.applyAll.not()) {
val action = withContext(Dispatchers.Swing) {
getTransferAction(context, tableModel, 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,
model: TransportTableModel,
source: TransportTableModel.Attributes
): TransferAction? {
if (context.applyAll) return context.action
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
}
private fun askTransfer(
source: TransportTableModel.Attributes,
target: TransportTableModel.Attributes
): AskTransfer {
val formMargin = "7dlu"
val layout = FormLayout(
"left:pref, $formMargin, default:grow, 2dlu, left:pref",
"pref, 12dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val iconSize = 36
// @formatter:off
val targetIcon = ScaleIcon(if(target.isDirectory) NativeIcons.folderIcon else NativeIcons.fileIcon, iconSize)
val sourceIcon = ScaleIcon(if(source.isDirectory) NativeIcons.folderIcon else NativeIcons.fileIcon, iconSize)
val sourceModified= DateFormatUtils.format(Date(source.lastModifiedTime), I18n.getString("termora.date-format"))
val targetModified= DateFormatUtils.format(Date(target.lastModifiedTime), I18n.getString("termora.date-format"))
// @formatter:on
val actionsComBoBox = JComboBox<TransferAction>()
actionsComBoBox.addItem(TransferAction.Overwrite)
actionsComBoBox.addItem(TransferAction.Append)
actionsComBoBox.addItem(TransferAction.Skip)
actionsComBoBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value?.toString() ?: StringUtils.EMPTY
if (value == TransferAction.Overwrite) {
text = I18n.getString("termora.transport.sftp.already-exists.overwrite")
} else if (value == TransferAction.Skip) {
text = I18n.getString("termora.transport.sftp.already-exists.skip")
} else if (value == TransferAction.Append) {
text = I18n.getString("termora.transport.sftp.already-exists.append")
}
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
}
}
val applyAllCheckbox = JCheckBox(I18n.getString("termora.transport.sftp.already-exists.apply-all"))
val box = Box.createHorizontalBox()
box.add(actionsComBoBox)
box.add(Box.createHorizontalStrut(8))
box.add(applyAllCheckbox)
box.add(Box.createHorizontalGlue())
val ttBox = Box.createVerticalBox()
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message1")))
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message2")))
val warningIcon = ScaleIcon(Icons.warningIntroduction, iconSize)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
// tip
.add(JLabel(warningIcon)).xy(1, rows)
.add(ttBox).xyw(3, rows, 3).apply { rows += step }
// name
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.name")}:")).xy(1, rows)
.add(source.name).xyw(3, rows, 3).apply { rows += step }
// separator
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
// Destination
.add("${I18n.getString("termora.transport.sftp.already-exists.destination")}:").xy(1, rows)
.apply { rows += step }
// Folder
.add(JLabel(targetIcon)).xy(1, rows, "center, fill")
.add(targetModified).xyw(3, rows, 3).apply { rows += step }
// Source
.add("${I18n.getString("termora.transport.sftp.already-exists.source")}:").xy(1, rows)
.apply { rows += step }
// Folder
.add(JLabel(sourceIcon)).xy(1, rows, "center, fill")
.add(sourceModified).xyw(3, rows, 3).apply { rows += step }
// separator
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
// name
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.actions")}:")).xy(1, rows)
.add(box).xyw(3, rows, 3).apply { rows += step }
.build()
panel.putClientProperty("SKIP_requestFocusInWindow", true)
return AskTransfer(
option = OptionPane.showConfirmDialog(
owner.get(), panel,
messageType = JOptionPane.PLAIN_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION,
title = source.name,
initialValue = JOptionPane.OK_OPTION,
) {
it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height)
it.setLocationRelativeTo(it.owner)
},
action = actionsComBoBox.selectedItem as TransferAction,
applyAll = applyAllCheckbox.isSelected
)
}
private fun doAddTransfer(
workdir: Path,
pair: Pair<Path, TransportTableModel.Attributes>,
mode: TransferMode,
action: TransferAction,
future: CompletableFuture<Unit>
): FileVisitResult {
val isDirectory = pair.second.isDirectory
val path = pair.first
if (isDirectory.not()) {
val transfer = createTransfer(path, workdir.resolve(path.name), false, StringUtils.EMPTY, mode, action)
return if (transferManager.addTransfer(transfer)) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
}
val continued = AtomicBoolean(true)
val queue = ArrayDeque<Transfer>()
val isCancelled =
{ (future.isCancelled || future.isCompletedExceptionally).apply { continued.set(this.not()) } }
val basedir = if (isDirectory) workdir.resolve(path.name) else workdir
val visitor = object : FileVisitor<Path> {
override fun preVisitDirectory(
dir: Path,
attrs: BasicFileAttributes
): FileVisitResult {
val parentId = queue.lastOrNull()?.id() ?: StringUtils.EMPTY
// @formatter:off
val transfer = when (mode) {
TransferMode.Delete -> createTransfer(dir, dir, true, parentId, mode, action)
TransferMode.ChangePermission -> createTransfer(path, dir, true, parentId, mode, action, pair.second.permissions)
else -> createTransfer(dir, basedir.resolve(path.relativize(dir).pathString), true, parentId, mode, action)
}
// @formatter:on
queue.addLast(transfer)
if (transferManager.addTransfer(transfer).not()) {
continued.set(false)
return FileVisitResult.TERMINATE
}
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
}
override fun visitFile(
file: Path,
attrs: BasicFileAttributes
): FileVisitResult {
val parentId = queue.last().id()
// @formatter:off
val transfer = when (mode) {
TransferMode.Delete -> createTransfer(file, file, false, parentId, mode, action)
TransferMode.ChangePermission -> createTransfer(file, file, false, parentId, mode, action, pair.second.permissions)
else -> createTransfer(file, basedir.resolve(path.relativize(file).pathString), false, parentId, mode, action)
}
if (transferManager.addTransfer(transfer).not()) {
continued.set(false)
return FileVisitResult.TERMINATE
}
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
}
override fun visitFileFailed(
file: Path?,
exc: IOException
): FileVisitResult {
if (log.isErrorEnabled) log.error(exc.message, exc)
future.completeExceptionally(exc)
return FileVisitResult.TERMINATE
}
override fun postVisitDirectory(
dir: Path?,
exc: IOException?
): FileVisitResult {
val c = queue.removeLast()
if (c is TransferScanner) c.scanned()
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
}
}
PathWalker.walkFileTree(path, visitor)
// 已经添加的则继续传输
while (queue.isNotEmpty()) {
val c = queue.removeLast()
if (c is TransferScanner) c.scanned()
}
return if (continued.get()) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
}
private fun createTransfer(
source: Path,
target: Path,
isDirectory: Boolean,
parentId: String,
mode: TransferMode,
action:TransferAction,
permissions: Set<PosixFilePermission>? = null
): Transfer {
if (mode == TransferMode.Delete) {
return DeleteTransfer(
parentId,
source,
isDirectory,
if (isDirectory) 1 else Files.size(source)
)
} else if (mode == TransferMode.ChangePermission) {
if (permissions == null) throw IllegalStateException()
return ChangePermissionTransfer(
parentId,
target,
isDirectory = isDirectory,
permissions = permissions,
size = if (isDirectory) 1 else Files.size(target)
)
}
if (isDirectory) {
return DirectoryTransfer(
parentId = parentId,
source = source,
target = target,
)
}
return FileTransfer(
parentId = parentId,
source = source,
target = target,
action = action,
size = Files.size(source)
)
}
}

View File

@@ -6,5 +6,7 @@ interface TransferListener : EventListener {
/**
* 状态变化
*/
fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State)
fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) {}
fun onTransferCountChanged() {}
}

View File

@@ -17,6 +17,7 @@ import java.awt.Insets
import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.tree.DefaultTreeCellRenderer
@@ -30,7 +31,7 @@ class TransferTable(private val coroutineScope: CoroutineScope, private val tabl
private val table get() = this
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val disposed = AtomicBoolean(false)
init {
initView()
@@ -153,7 +154,7 @@ class TransferTable(private val coroutineScope: CoroutineScope, private val tabl
private fun refreshView() {
coroutineScope.launch(Dispatchers.Swing) {
val timeout = 500
while (coroutineScope.isActive) {
while (coroutineScope.isActive && disposed.get().not()) {
for (row in 0 until rowCount) {
val treePath = getPathForRow(row) ?: continue
val node = treePath.lastPathComponent as? DefaultMutableTreeTableNode ?: continue
@@ -164,6 +165,10 @@ class TransferTable(private val coroutineScope: CoroutineScope, private val tabl
}
}
override fun dispose() {
disposed.set(true)
}
private inner class ProgressTableCellRenderer : DefaultTableCellRenderer() {
private var progress = 0.0
private var progressInt = 0

View File

@@ -12,6 +12,7 @@ import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import org.jdesktop.swingx.treetable.DefaultTreeTableModel
import org.slf4j.LoggerFactory
import java.io.Closeable
import java.io.InterruptedIOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
@@ -114,7 +115,7 @@ class TransferTableModel(private val coroutineScope: CoroutineScope) :
val result = AtomicBoolean(false)
if (SwingUtilities.isEventDispatchThread()) {
if (validGrandfather(node.transfer.parentId())) {
map[node.transfer.id()] = node
putNodeToMap(node.transfer.id(), node)
insertNodeInto(node, parent, parent.childCount)
result.set(true)
}
@@ -124,6 +125,21 @@ class TransferTableModel(private val coroutineScope: CoroutineScope) :
return result.get()
}
private fun removeNodeFromMap(id: String) {
if (map.remove(id) != null) {
for (listener in eventListener.getListeners(TransferListener::class.java)) {
listener.onTransferCountChanged()
}
}
}
private fun putNodeToMap(id: String, node: TransferTreeTableNode) {
map[id] = node
for (listener in eventListener.getListeners(TransferListener::class.java)) {
listener.onTransferCountChanged()
}
}
/**
* 获取祖先的状态,如果祖先状态不正常,那么子直接定义为失败
*
@@ -179,7 +195,7 @@ class TransferTableModel(private val coroutineScope: CoroutineScope) :
// 定义为失败
node.tryChangeState(State.Failed)
// 移除
map.remove(node.transfer.id())
removeNodeFromMap(node.transfer.id())
removeNodeFromParent(node)
// 如果删除时还在传输,那么需要减去大小
@@ -388,6 +404,10 @@ class TransferTableModel(private val coroutineScope: CoroutineScope) :
lock.withLock { condition.signalAll() }
} catch (_: CancellationException) {
break
} catch (_: InterruptedException) {
break
} catch (_: InterruptedIOException) {
break
} catch (e: Exception) {
if (log.isErrorEnabled) log.error(e.message, e)
}

View File

@@ -13,7 +13,6 @@ 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
@@ -46,7 +45,6 @@ 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
@@ -120,7 +118,7 @@ class TransportPanel(
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val disposed = AtomicBoolean(false)
private val futures = CopyOnWriteArraySet<Future<*>>()
private val futures = Collections.synchronizedSet(mutableSetOf<Future<*>>())
private val _fileSystem by lazy { getSupport().fileSystem }
private val defaultPath by lazy { getSupport().path }
@@ -456,6 +454,9 @@ class TransportPanel(
val rows = table.selectedRows.map { sorter.convertRowIndexToModel(it) }.toTypedArray()
showContextmenu(rows, e)
} else if (SwingUtilities.isLeftMouseButton(e)) {
val row = table.rowAtPoint(e.point)
if (row < 0) table.clearSelection()
}
}
})
@@ -627,7 +628,7 @@ class TransportPanel(
}
}
private fun reload(oldPath: Path? = workdir, newPath: Path? = workdir, requestFocus: Boolean = true): Boolean {
private fun reload(oldPath: Path? = workdir, newPath: Path? = workdir, requestFocus: Boolean = false): Boolean {
assertEventDispatchThread()
if (loading) return false
@@ -663,7 +664,7 @@ class TransportPanel(
return true
}
private suspend fun doReload(oldPath: Path? = null, newPath: Path? = null, requestFocus: Boolean = true): Path {
private suspend fun doReload(oldPath: Path? = null, newPath: Path? = null, requestFocus: Boolean = false): Path {
val workdir = newPath ?: oldPath
@@ -854,7 +855,6 @@ class TransportPanel(
futures.forEach { it.cancel(true) }
futures.clear()
coroutineScope.cancel()
if (loader.isLoaded && _fileSystem.isOpen) IOUtils.closeQuietly(_fileSystem)
loadingPanel.busyLabel.isBusy = false
}
}
@@ -1038,10 +1038,12 @@ class TransportPanel(
val path = files.first().first
processPath(path.name) {
if (c.includeSubFolder) {
val future = transferManager.addTransfer(
val future = withContext(Dispatchers.Swing) {
transferManager.addTransfer(
listOf(path to files.first().second.copy(permissions = c.permissions)),
InternalTransferManager.TransferMode.ChangePermission
)
}
mountFuture(future)
future.get()
} else {
@@ -1064,7 +1066,7 @@ class TransportPanel(
}
}
private fun processPath(name: String, action: () -> Unit) {
private fun processPath(name: String, action: suspend () -> Unit) {
coroutineScope.launch {
try {
action.invoke()

View File

@@ -18,10 +18,10 @@ 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
lateinit var internalTransferManager: InternalTransferManager
init {
initViews()

View File

@@ -1,35 +1,18 @@
package app.termora.transfer
import app.termora.*
import app.termora.Disposable
import app.termora.Disposer
import app.termora.DynamicColor
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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 java.nio.file.Path
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 {
@@ -41,10 +24,10 @@ class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
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 leftTabbed = TransportTabbed(transferManager)
private val rightTabbed = TransportTabbed(transferManager)
private val leftTransferManager = createInternalTransferManager(leftTabbed, rightTabbed)
private val rightTransferManager = createInternalTransferManager(rightTabbed, leftTabbed)
private val rootSplitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT)
private val owner get() = SwingUtilities.getWindowAncestor(this)
@@ -56,16 +39,12 @@ class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
private fun initView() {
isFocusable = false
leftTabbed.internalTransferManager = leftTransferManager
rightTabbed.internalTransferManager = rightTransferManager
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)
@@ -103,6 +82,36 @@ class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
Disposer.register(this, rightTabbed)
}
private fun createInternalTransferManager(
source: TransportTabbed,
target: TransportTabbed
): InternalTransferManager {
return DefaultInternalTransferManager(
{ owner },
coroutineScope,
transferManager,
object : DefaultInternalTransferManager.WorkdirProvider {
override fun getWorkdir(): Path? {
return source.getSelectedTransportPanel()?.workdir
}
override fun getTableModel(): TransportTableModel? {
return source.getSelectedTransportPanel()?.getTableModel()
}
},
object : DefaultInternalTransferManager.WorkdirProvider {
override fun getWorkdir(): Path? {
return target.getSelectedTransportPanel()?.workdir
}
override fun getTableModel(): TransportTableModel? {
return target.getSelectedTransportPanel()?.getTableModel()
}
})
}
fun getTransferManager(): TransferManager {
return transferManager
}
@@ -115,375 +124,4 @@ class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
return rightTabbed
}
private data class AskTransfer(
val option: Int,
val action: TransferAction,
val applyAll: Boolean
)
private data class AskTransferContext(var action: TransferAction, var applyAll: Boolean)
private inner class MyInternalTransferManager() : InternalTransferManager {
lateinit var source: TransportTabbed
lateinit var target: TransportTabbed
override fun canTransfer(paths: List<Path>): Boolean {
return target.getSelectedTransportPanel()?.workdir != null
}
override fun addTransfer(
paths: List<Pair<Path, TransportTableModel.Attributes>>,
mode: TransferMode
): CompletableFuture<Unit> {
val workdir = (if (mode == TransferMode.Delete || mode == TransferMode.ChangePermission)
source.getSelectedTransportPanel()?.workdir else target.getSelectedTransportPanel()?.workdir)
?: throw IllegalStateException()
return addTransfer(paths, workdir, mode)
}
override fun addTransfer(
paths: List<Pair<Path, TransportTableModel.Attributes>>,
targetWorkdir: Path,
mode: TransferMode
): CompletableFuture<Unit> {
assertEventDispatchThread()
if (paths.isEmpty()) return CompletableFuture.completedFuture(Unit)
val future = CompletableFuture<Unit>()
val panel = getTransportPanel(targetWorkdir.fileSystem, leftTabbed)
?: getTransportPanel(targetWorkdir.fileSystem, rightTabbed)
coroutineScope.launch(Dispatchers.IO) {
try {
val context = AskTransferContext(TransferAction.Overwrite, false)
for (pair in paths) {
if (mode == TransferMode.Transfer && panel != null) {
val action = withContext(Dispatchers.Swing) {
getTransferAction(context, panel, pair.second)
}
if (action == null) {
break
} else if (context.applyAll) {
if (action == TransferAction.Skip) {
break
}
} else if (action == TransferAction.Skip) {
continue
}
}
val flag = doAddTransfer(targetWorkdir, pair, mode, context.action, future)
if (flag != FileVisitResult.CONTINUE) break
}
future.complete(Unit)
} catch (e: Exception) {
if (log.isErrorEnabled) log.error(e.message, e)
future.completeExceptionally(e)
}
}
return future
}
override fun addHighTransfer(source: Path, target: Path): String {
val transfer = FileTransfer(
parentId = StringUtils.EMPTY,
source = source,
target = target,
size = Files.size(source),
action = TransferAction.Overwrite,
priority = Transfer.Priority.High
)
if (transferManager.addTransfer(transfer)) {
return transfer.id()
} else {
throw IllegalStateException("Cannot add high transfer.")
}
}
override fun addTransferListener(listener: TransferListener): Disposable {
return transferManager.addTransferListener(listener)
}
private fun getTransferAction(
context: AskTransferContext,
panel: TransportPanel,
source: TransportTableModel.Attributes
): TransferAction? {
if (context.applyAll) return context.action
val model = panel.getTableModel()
for (i in 0 until model.rowCount) {
val c = model.getAttributes(i)
if (c.name != source.name) continue
val transfer = askTransfer(source, c)
context.action = transfer.action
context.applyAll = transfer.applyAll
if (transfer.option != JOptionPane.OK_OPTION) return null
}
return context.action
}
fun getTransportPanel(fileSystem: FileSystem, tabbed: TransportTabbed): TransportPanel? {
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getComponentAt(i)
if (c is TransportPanel) {
if (c.loader.isLoaded) {
if (c.loader.get().fileSystem == fileSystem) {
return c
}
}
}
}
return null
}
private fun askTransfer(
source: TransportTableModel.Attributes,
target: TransportTableModel.Attributes
): AskTransfer {
val formMargin = "7dlu"
val layout = FormLayout(
"left:pref, $formMargin, default:grow, 2dlu, left:pref",
"pref, 12dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val iconSize = 36
// @formatter:off
val targetIcon = ScaleIcon(if(target.isDirectory) NativeIcons.folderIcon else NativeIcons.fileIcon, iconSize)
val sourceIcon = ScaleIcon(if(source.isDirectory) NativeIcons.folderIcon else NativeIcons.fileIcon, iconSize)
val sourceModified= DateFormatUtils.format(Date(source.lastModifiedTime), I18n.getString("termora.date-format"))
val targetModified= DateFormatUtils.format(Date(target.lastModifiedTime), I18n.getString("termora.date-format"))
// @formatter:on
val actionsComBoBox = JComboBox<TransferAction>()
actionsComBoBox.addItem(TransferAction.Overwrite)
actionsComBoBox.addItem(TransferAction.Append)
actionsComBoBox.addItem(TransferAction.Skip)
actionsComBoBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value?.toString() ?: StringUtils.EMPTY
if (value == TransferAction.Overwrite) {
text = I18n.getString("termora.transport.sftp.already-exists.overwrite")
} else if (value == TransferAction.Skip) {
text = I18n.getString("termora.transport.sftp.already-exists.skip")
} else if (value == TransferAction.Append) {
text = I18n.getString("termora.transport.sftp.already-exists.append")
}
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
}
}
val applyAllCheckbox = JCheckBox(I18n.getString("termora.transport.sftp.already-exists.apply-all"))
val box = Box.createHorizontalBox()
box.add(actionsComBoBox)
box.add(Box.createHorizontalStrut(8))
box.add(applyAllCheckbox)
box.add(Box.createHorizontalGlue())
val ttBox = Box.createVerticalBox()
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message1")))
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message2")))
val warningIcon = ScaleIcon(Icons.warningIntroduction, iconSize)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
// tip
.add(JLabel(warningIcon)).xy(1, rows)
.add(ttBox).xyw(3, rows, 3).apply { rows += step }
// name
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.name")}:")).xy(1, rows)
.add(source.name).xyw(3, rows, 3).apply { rows += step }
// separator
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
// Destination
.add("${I18n.getString("termora.transport.sftp.already-exists.destination")}:").xy(1, rows)
.apply { rows += step }
// Folder
.add(JLabel(targetIcon)).xy(1, rows, "center, fill")
.add(targetModified).xyw(3, rows, 3).apply { rows += step }
// Source
.add("${I18n.getString("termora.transport.sftp.already-exists.source")}:").xy(1, rows)
.apply { rows += step }
// Folder
.add(JLabel(sourceIcon)).xy(1, rows, "center, fill")
.add(sourceModified).xyw(3, rows, 3).apply { rows += step }
// separator
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
// name
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.actions")}:")).xy(1, rows)
.add(box).xyw(3, rows, 3).apply { rows += step }
.build()
panel.putClientProperty("SKIP_requestFocusInWindow", true)
return AskTransfer(
option = OptionPane.showConfirmDialog(
owner, panel,
messageType = JOptionPane.PLAIN_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION,
title = source.name,
initialValue = JOptionPane.OK_OPTION,
) {
it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height)
it.setLocationRelativeTo(it.owner)
},
action = actionsComBoBox.selectedItem as TransferAction,
applyAll = applyAllCheckbox.isSelected
)
}
private fun doAddTransfer(
workdir: Path,
pair: Pair<Path, TransportTableModel.Attributes>,
mode: TransferMode,
action: TransferAction,
future: CompletableFuture<Unit>
): FileVisitResult {
val isDirectory = pair.second.isDirectory
val path = pair.first
if (isDirectory.not()) {
val transfer = createTransfer(path, workdir.resolve(path.name), false, StringUtils.EMPTY, mode, action)
return if (transferManager.addTransfer(transfer)) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
}
val continued = AtomicBoolean(true)
val queue = ArrayDeque<Transfer>()
val isCancelled =
{ (future.isCancelled || future.isCompletedExceptionally).apply { continued.set(this.not()) } }
val basedir = if (isDirectory) workdir.resolve(path.name) else workdir
val visitor = object : FileVisitor<Path> {
override fun preVisitDirectory(
dir: Path,
attrs: BasicFileAttributes
): FileVisitResult {
val parentId = queue.lastOrNull()?.id() ?: StringUtils.EMPTY
// @formatter:off
val transfer = when (mode) {
TransferMode.Delete -> createTransfer(dir, dir, true, parentId, mode, action)
TransferMode.ChangePermission -> createTransfer(path, dir, true, parentId, mode, action, pair.second.permissions)
else -> createTransfer(dir, basedir.resolve(path.relativize(dir).pathString), true, parentId, mode, action)
}
// @formatter:on
queue.addLast(transfer)
if (transferManager.addTransfer(transfer).not()) {
continued.set(false)
return FileVisitResult.TERMINATE
}
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
}
override fun visitFile(
file: Path,
attrs: BasicFileAttributes
): FileVisitResult {
val parentId = queue.last().id()
// @formatter:off
val transfer = when (mode) {
TransferMode.Delete -> createTransfer(file, file, false, parentId, mode, action)
TransferMode.ChangePermission -> createTransfer(file, file, false, parentId, mode, action, pair.second.permissions)
else -> createTransfer(file, basedir.resolve(path.relativize(file).pathString), false, parentId, mode, action)
}
if (transferManager.addTransfer(transfer).not()) {
continued.set(false)
return FileVisitResult.TERMINATE
}
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
}
override fun visitFileFailed(
file: Path?,
exc: IOException
): FileVisitResult {
if (log.isErrorEnabled) log.error(exc.message, exc)
future.completeExceptionally(exc)
return FileVisitResult.TERMINATE
}
override fun postVisitDirectory(
dir: Path?,
exc: IOException?
): FileVisitResult {
val c = queue.removeLast()
if (c is TransferScanner) c.scanned()
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
}
}
PathWalker.walkFileTree(path, visitor)
// 已经添加的则继续传输
while (queue.isNotEmpty()) {
val c = queue.removeLast()
if (c is TransferScanner) c.scanned()
}
return if (continued.get()) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
}
private fun createTransfer(
source: Path,
target: Path,
isDirectory: Boolean,
parentId: String,
mode: TransferMode,
action:TransferAction,
permissions: Set<PosixFilePermission>? = null
): Transfer {
if (mode == TransferMode.Delete) {
return DeleteTransfer(
parentId,
source,
isDirectory,
if (isDirectory) 1 else Files.size(source)
)
} else if (mode == TransferMode.ChangePermission) {
if (permissions == null) throw IllegalStateException()
return ChangePermissionTransfer(
parentId,
target,
isDirectory = isDirectory,
permissions = permissions,
size = if (isDirectory) 1 else Files.size(target)
)
}
if (isDirectory) {
return DirectoryTransfer(
parentId = parentId,
source = source,
target = target,
)
}
return FileTransfer(
parentId = parentId,
source = source,
target = target,
action = action,
size = Files.size(source)
)
}
}
}

View File

@@ -1,7 +1,6 @@
package app.termora.transfer.internal.sftp
import app.termora.SshClients
import app.termora.database.DatabaseManager
import app.termora.protocol.PathHandler
import app.termora.protocol.PathHandlerRequest
import app.termora.protocol.TransferProtocolProvider
@@ -14,7 +13,6 @@ import org.apache.sshd.sftp.client.SftpClientFactory
internal class SFTPTransferProtocolProvider : TransferProtocolProvider {
companion object {
val instance by lazy { SFTPTransferProtocolProvider() }
private val sftp get() = DatabaseManager.getInstance().sftp
const val PROTOCOL = "sftp"
}

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6.5" stroke="#6C707E"/>
<path d="M7.59245 8.521C7.67312 8.33767 7.77212 8.18 7.88945 8.048C8.00679 7.916 8.16445 7.75833 8.36245 7.575C8.55312 7.40267 8.70345 7.25417 8.81345 7.1295C8.92712 7.00483 9.02245 6.86 9.09945 6.695C9.17645 6.52633 9.21495 6.33933 9.21495 6.134C9.21495 5.903 9.16545 5.69767 9.06645 5.518C8.97112 5.33833 8.83545 5.199 8.65945 5.1C8.48345 5.001 8.28179 4.9515 8.05445 4.9515C7.80145 4.9515 7.57779 5.0065 7.38345 5.1165C7.18912 5.2265 7.03879 5.3805 6.93245 5.5785C6.90638 5.62705 6.88351 5.67735 6.86383 5.72943C6.75806 6.00931 6.5304 6.2605 6.2312 6.2605V6.2605C5.932 6.2605 5.68157 6.01527 5.74167 5.72217C5.78989 5.48707 5.87148 5.27051 5.98645 5.0725C6.18445 4.7315 6.46312 4.4675 6.82245 4.2805C7.18179 4.0935 7.59612 4 8.06545 4C8.51279 4 8.90695 4.08617 9.24795 4.2585C9.58895 4.43083 9.85295 4.67467 10.04 4.99C10.227 5.30167 10.3186 5.661 10.315 6.068C10.315 6.38333 10.2655 6.662 10.1665 6.904C10.0675 7.14233 9.94645 7.34217 9.80345 7.5035C9.66045 7.66483 9.47529 7.84633 9.24795 8.048C9.07929 8.19833 8.94729 8.32483 8.85195 8.4275C8.75662 8.53017 8.67595 8.64567 8.60995 8.774C8.59197 8.80996 8.57743 8.84636 8.56569 8.88312C8.47784 9.15799 8.28252 9.401 7.99395 9.401V9.401C7.70538 9.401 7.45381 9.16525 7.4934 8.87941C7.51026 8.75764 7.54001 8.6378 7.59245 8.521Z" fill="#6C707E"/>
<circle cx="8" cy="10.75" r="0.75" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6.5" stroke="#CED0D6"/>
<path d="M7.59245 8.521C7.67312 8.33767 7.77212 8.18 7.88945 8.048C8.00679 7.916 8.16445 7.75833 8.36245 7.575C8.55312 7.40267 8.70345 7.25417 8.81345 7.1295C8.92712 7.00483 9.02245 6.86 9.09945 6.695C9.17645 6.52633 9.21495 6.33933 9.21495 6.134C9.21495 5.903 9.16545 5.69767 9.06645 5.518C8.97112 5.33833 8.83545 5.199 8.65945 5.1C8.48345 5.001 8.28179 4.9515 8.05445 4.9515C7.80145 4.9515 7.57779 5.0065 7.38345 5.1165C7.18912 5.2265 7.03879 5.3805 6.93245 5.5785C6.90638 5.62705 6.88351 5.67735 6.86383 5.72943C6.75806 6.00931 6.5304 6.2605 6.2312 6.2605V6.2605C5.932 6.2605 5.68157 6.01527 5.74167 5.72217C5.78989 5.48707 5.87148 5.27051 5.98645 5.0725C6.18445 4.7315 6.46312 4.4675 6.82245 4.2805C7.18179 4.0935 7.59612 4 8.06545 4C8.51279 4 8.90695 4.08617 9.24795 4.2585C9.58895 4.43083 9.85295 4.67467 10.04 4.99C10.227 5.30167 10.3186 5.661 10.315 6.068C10.315 6.38333 10.2655 6.662 10.1665 6.904C10.0675 7.14233 9.94645 7.34217 9.80345 7.5035C9.66045 7.66483 9.47529 7.84633 9.24795 8.048C9.07929 8.19833 8.94729 8.32483 8.85195 8.4275C8.75662 8.53017 8.67595 8.64567 8.60995 8.774C8.59197 8.80996 8.57743 8.84636 8.56569 8.88312C8.47784 9.15799 8.28252 9.401 7.99395 9.401V9.401C7.70538 9.401 7.45381 9.16525 7.4934 8.87941C7.51026 8.75764 7.54001 8.6378 7.59245 8.521Z" fill="#CED0D6"/>
<circle cx="8" cy="10.75" r="0.75" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.8536 3.14645C11.6583 2.95118 11.3417 2.95118 11.1464 3.14645L8.14645 6.14645C7.95118 6.34171 7.95118 6.65829 8.14645 6.85355C8.34171 7.04882 8.65829 7.04882 8.85355 6.85355L11 4.70711V12.5C11 12.7761 11.2239 13 11.5 13C11.7761 13 12 12.7761 12 12.5V4.70711L14.1464 6.85355C14.3417 7.04882 14.6583 7.04882 14.8536 6.85355C15.0488 6.65829 15.0488 6.34171 14.8536 6.14645L11.8536 3.14645Z" fill="#6C707E"/>
<path d="M5 3.5C5 3.22386 4.77614 3 4.5 3C4.22386 3 4 3.22386 4 3.5V11.2929L1.85355 9.14645C1.65829 8.95118 1.34171 8.95118 1.14645 9.14645C0.951184 9.34171 0.951184 9.65829 1.14645 9.85355L4.14645 12.8536C4.34171 13.0488 4.65829 13.0488 4.85355 12.8536L7.85355 9.85355C8.04882 9.65829 8.04882 9.34171 7.85355 9.14645C7.65829 8.95118 7.34171 8.95118 7.14645 9.14645L5 11.2929V3.5Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 919 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.8536 3.14645C11.6583 2.95118 11.3417 2.95118 11.1464 3.14645L8.14645 6.14645C7.95118 6.34171 7.95118 6.65829 8.14645 6.85355C8.34171 7.04882 8.65829 7.04882 8.85355 6.85355L11 4.70711V12.5C11 12.7761 11.2239 13 11.5 13C11.7761 13 12 12.7761 12 12.5V4.70711L14.1464 6.85355C14.3417 7.04882 14.6583 7.04882 14.8536 6.85355C15.0488 6.65829 15.0488 6.34171 14.8536 6.14645L11.8536 3.14645Z" fill="#CED0D6"/>
<path d="M5 3.5C5 3.22386 4.77614 3 4.5 3C4.22386 3 4 3.22386 4 3.5V11.2929L1.85355 9.14645C1.65829 8.95118 1.34171 8.95118 1.14645 9.14645C0.951184 9.34171 0.951184 9.65829 1.14645 9.85355L4.14645 12.8536C4.34171 13.0488 4.65829 13.0488 4.85355 12.8536L7.85355 9.85355C8.04882 9.65829 8.04882 9.34171 7.85355 9.14645C7.65829 8.95118 7.34171 8.95118 7.14645 9.14645L5 11.2929V3.5Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 919 B