From 187d5be6580b7e69bcb053ef97d88bfca5d2bcdd Mon Sep 17 00:00:00 2001 From: hstyi Date: Sun, 22 Jun 2025 12:42:18 +0800 Subject: [PATCH] feat: support transfer virtual window --- src/main/kotlin/app/termora/Icons.kt | 2 + .../plugin/internal/ssh/SSHTerminalTab.kt | 3 + .../terminal/panel/FloatingToolbarPanel.kt | 32 ++ .../termora/terminal/panel/TerminalPanel.kt | 8 + .../panel/vw/NvidiaSMIVisualWindow.kt | 4 +- .../terminal/panel/vw/TransferVisualWindow.kt | 444 +++++++++++++++++ .../terminal/panel/vw/VisualWindowPanel.kt | 21 +- .../kotlin/app/termora/transfer/BadgeIcon.kt | 39 ++ .../DefaultInternalTransferManager.kt | 409 ++++++++++++++++ .../app/termora/transfer/TransferListener.kt | 4 +- .../app/termora/transfer/TransferTable.kt | 9 +- .../termora/transfer/TransferTableModel.kt | 24 +- .../app/termora/transfer/TransportPanel.kt | 24 +- .../app/termora/transfer/TransportTabbed.kt | 2 +- .../app/termora/transfer/TransportViewer.kt | 450 ++---------------- .../sftp/SFTPTransferProtocolProvider.kt | 2 - src/main/resources/icons/questionMark.svg | 6 + .../resources/icons/questionMark_dark.svg | 6 + .../resources/icons/transferToolWindow.svg | 4 + .../icons/transferToolWindow_dark.svg | 4 + 20 files changed, 1063 insertions(+), 434 deletions(-) create mode 100644 src/main/kotlin/app/termora/terminal/panel/vw/TransferVisualWindow.kt create mode 100644 src/main/kotlin/app/termora/transfer/BadgeIcon.kt create mode 100644 src/main/kotlin/app/termora/transfer/DefaultInternalTransferManager.kt create mode 100644 src/main/resources/icons/questionMark.svg create mode 100644 src/main/resources/icons/questionMark_dark.svg create mode 100644 src/main/resources/icons/transferToolWindow.svg create mode 100644 src/main/resources/icons/transferToolWindow_dark.svg diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt index 4f3a9da..99da77b 100644 --- a/src/main/kotlin/app/termora/Icons.kt +++ b/src/main/kotlin/app/termora/Icons.kt @@ -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( diff --git a/src/main/kotlin/app/termora/plugin/internal/ssh/SSHTerminalTab.kt b/src/main/kotlin/app/termora/plugin/internal/ssh/SSHTerminalTab.kt index 2873556..cb45836 100644 --- a/src/main/kotlin/app/termora/plugin/internal/ssh/SSHTerminalTab.kt +++ b/src/main/kotlin/app/termora/plugin/internal/ssh/SSHTerminalTab.kt @@ -146,6 +146,9 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : } } } + + // stop + stop() } } }) diff --git a/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt b/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt index 9c08850..0fbcd8c 100644 --- a/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt +++ b/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt @@ -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") diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt index 0976730..0697aa0 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt @@ -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 + ) + ) } } } diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/NvidiaSMIVisualWindow.kt b/src/main/kotlin/app/termora/terminal/panel/vw/NvidiaSMIVisualWindow.kt index 275c9b9..0f3946d 100644 --- a/src/main/kotlin/app/termora/terminal/panel/vw/NvidiaSMIVisualWindow.kt +++ b/src/main/kotlin/app/termora/terminal/panel/vw/NvidiaSMIVisualWindow.kt @@ -57,8 +57,8 @@ class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWind initVisualWindowPanel() } - override fun toolbarButtons(): List { - return listOf(percentageBtn) + override fun toolbarButtons(): List> { + return listOf(percentageBtn to Position.Right) } private fun initViews() { diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/TransferVisualWindow.kt b/src/main/kotlin/app/termora/terminal/panel/vw/TransferVisualWindow.kt new file mode 100644 index 0000000..5be1e4b --- /dev/null +++ b/src/main/kotlin/app/termora/terminal/panel/vw/TransferVisualWindow.kt @@ -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> { + 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 = 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): Boolean { + return paths.isNotEmpty() + } + + override fun addTransfer( + paths: List>, + mode: InternalTransferManager.TransferMode + ): CompletableFuture { + + if (mode == InternalTransferManager.TransferMode.Transfer) { + val future = CompletableFuture() + 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>, + targetWorkdir: Path, + mode: InternalTransferManager.TransferMode + ): CompletableFuture { + 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) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowPanel.kt b/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowPanel.kt index 3916991..e7a41f8 100644 --- a/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowPanel.kt +++ b/src/main/kotlin/app/termora/terminal/panel/vw/VisualWindowPanel.kt @@ -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 { + protected open fun toolbarButtons(): List> { 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()) { diff --git a/src/main/kotlin/app/termora/transfer/BadgeIcon.kt b/src/main/kotlin/app/termora/transfer/BadgeIcon.kt new file mode 100644 index 0000000..921d78e --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/BadgeIcon.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/DefaultInternalTransferManager.kt b/src/main/kotlin/app/termora/transfer/DefaultInternalTransferManager.kt new file mode 100644 index 0000000..1bae3aa --- /dev/null +++ b/src/main/kotlin/app/termora/transfer/DefaultInternalTransferManager.kt @@ -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, + 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): Boolean { + return paths.isNotEmpty() && target.getWorkdir() != null + } + + override fun addTransfer( + paths: List>, + mode: TransferMode + ): CompletableFuture { + 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>, + targetWorkdir: Path, + mode: TransferMode + ): CompletableFuture { + assertEventDispatchThread() + + if (paths.isEmpty()) return CompletableFuture.completedFuture(Unit) + + val future = CompletableFuture() + 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() + 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, + mode: TransferMode, + action: TransferAction, + future: CompletableFuture + ): FileVisitResult { + + val isDirectory = pair.second.isDirectory + val path = pair.first + if (isDirectory.not()) { + val transfer = createTransfer(path, workdir.resolve(path.name), false, StringUtils.EMPTY, mode, action) + return if (transferManager.addTransfer(transfer)) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE + } + + val continued = AtomicBoolean(true) + val queue = ArrayDeque() + val isCancelled = + { (future.isCancelled || future.isCompletedExceptionally).apply { continued.set(this.not()) } } + val basedir = if (isDirectory) workdir.resolve(path.name) else workdir + val visitor = object : FileVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + val parentId = queue.lastOrNull()?.id() ?: StringUtils.EMPTY + // @formatter:off + val transfer = when (mode) { + TransferMode.Delete -> createTransfer(dir, dir, true, parentId, mode, action) + TransferMode.ChangePermission -> createTransfer(path, dir, true, parentId, mode, action, pair.second.permissions) + else -> createTransfer(dir, basedir.resolve(path.relativize(dir).pathString), true, parentId, mode, action) + } + // @formatter:on + + queue.addLast(transfer) + + if (transferManager.addTransfer(transfer).not()) { + continued.set(false) + return FileVisitResult.TERMINATE + } + + return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE + } + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + + val parentId = queue.last().id() + // @formatter:off + val transfer = when (mode) { + TransferMode.Delete -> createTransfer(file, file, false, parentId, mode, action) + TransferMode.ChangePermission -> createTransfer(file, file, false, parentId, mode, action, pair.second.permissions) + else -> createTransfer(file, basedir.resolve(path.relativize(file).pathString), false, parentId, mode, action) + } + + if (transferManager.addTransfer(transfer).not()) { + continued.set(false) + return FileVisitResult.TERMINATE + } + + return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path?, + exc: IOException + ): FileVisitResult { + if (log.isErrorEnabled) log.error(exc.message, exc) + future.completeExceptionally(exc) + return FileVisitResult.TERMINATE + } + + override fun postVisitDirectory( + dir: Path?, + exc: IOException? + ): FileVisitResult { + val c = queue.removeLast() + if (c is TransferScanner) c.scanned() + return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE + } + + } + + PathWalker.walkFileTree(path, visitor) + + // 已经添加的则继续传输 + while (queue.isNotEmpty()) { + val c = queue.removeLast() + if (c is TransferScanner) c.scanned() + } + + return if (continued.get()) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE + } + + + private fun createTransfer( + source: Path, + target: Path, + isDirectory: Boolean, + parentId: String, + mode: TransferMode, + action:TransferAction, + permissions: Set? = null + ): Transfer { + if (mode == TransferMode.Delete) { + return DeleteTransfer( + parentId, + source, + isDirectory, + if (isDirectory) 1 else Files.size(source) + ) + } else if (mode == TransferMode.ChangePermission) { + if (permissions == null) throw IllegalStateException() + return ChangePermissionTransfer( + parentId, + target, + isDirectory = isDirectory, + permissions = permissions, + size = if (isDirectory) 1 else Files.size(target) + ) + } + + if (isDirectory) { + return DirectoryTransfer( + parentId = parentId, + source = source, + target = target, + ) + } + + return FileTransfer( + parentId = parentId, + source = source, + target = target, + action = action, + size = Files.size(source) + ) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransferListener.kt b/src/main/kotlin/app/termora/transfer/TransferListener.kt index f154ecf..00c1e07 100644 --- a/src/main/kotlin/app/termora/transfer/TransferListener.kt +++ b/src/main/kotlin/app/termora/transfer/TransferListener.kt @@ -6,5 +6,7 @@ interface TransferListener : EventListener { /** * 状态变化 */ - fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) + fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) {} + + fun onTransferCountChanged() {} } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/TransferTable.kt b/src/main/kotlin/app/termora/transfer/TransferTable.kt index dfa1104..815a8b9 100644 --- a/src/main/kotlin/app/termora/transfer/TransferTable.kt +++ b/src/main/kotlin/app/termora/transfer/TransferTable.kt @@ -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 diff --git a/src/main/kotlin/app/termora/transfer/TransferTableModel.kt b/src/main/kotlin/app/termora/transfer/TransferTableModel.kt index e918c42..05a035e 100644 --- a/src/main/kotlin/app/termora/transfer/TransferTableModel.kt +++ b/src/main/kotlin/app/termora/transfer/TransferTableModel.kt @@ -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) } diff --git a/src/main/kotlin/app/termora/transfer/TransportPanel.kt b/src/main/kotlin/app/termora/transfer/TransportPanel.kt index 2d8dc20..df54a4e 100644 --- a/src/main/kotlin/app/termora/transfer/TransportPanel.kt +++ b/src/main/kotlin/app/termora/transfer/TransportPanel.kt @@ -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>() + private val futures = Collections.synchronizedSet(mutableSetOf>()) 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( - listOf(path to files.first().second.copy(permissions = c.permissions)), - InternalTransferManager.TransferMode.ChangePermission - ) + 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() diff --git a/src/main/kotlin/app/termora/transfer/TransportTabbed.kt b/src/main/kotlin/app/termora/transfer/TransportTabbed.kt index 0a741b9..d9bf58a 100644 --- a/src/main/kotlin/app/termora/transfer/TransportTabbed.kt +++ b/src/main/kotlin/app/termora/transfer/TransportTabbed.kt @@ -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() diff --git a/src/main/kotlin/app/termora/transfer/TransportViewer.kt b/src/main/kotlin/app/termora/transfer/TransportViewer.kt index 81c9fa1..44cdadf 100644 --- a/src/main/kotlin/app/termora/transfer/TransportViewer.kt +++ b/src/main/kotlin/app/termora/transfer/TransportViewer.kt @@ -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): Boolean { - return target.getSelectedTransportPanel()?.workdir != null - } - - override fun addTransfer( - paths: List>, - mode: TransferMode - ): CompletableFuture { - val workdir = (if (mode == TransferMode.Delete || mode == TransferMode.ChangePermission) - source.getSelectedTransportPanel()?.workdir else target.getSelectedTransportPanel()?.workdir) - ?: throw IllegalStateException() - return addTransfer(paths, workdir, mode) - } - - override fun addTransfer( - paths: List>, - targetWorkdir: Path, - mode: TransferMode - ): CompletableFuture { - assertEventDispatchThread() - - if (paths.isEmpty()) return CompletableFuture.completedFuture(Unit) - - val future = CompletableFuture() - val panel = getTransportPanel(targetWorkdir.fileSystem, leftTabbed) - ?: getTransportPanel(targetWorkdir.fileSystem, rightTabbed) - - coroutineScope.launch(Dispatchers.IO) { - try { - val context = AskTransferContext(TransferAction.Overwrite, false) - for (pair in paths) { - if (mode == TransferMode.Transfer && panel != null) { - val action = withContext(Dispatchers.Swing) { - getTransferAction(context, panel, pair.second) - } - if (action == null) { - break - } else if (context.applyAll) { - if (action == TransferAction.Skip) { - break - } - } else if (action == TransferAction.Skip) { - continue - } - } - val flag = doAddTransfer(targetWorkdir, pair, mode, context.action, future) - if (flag != FileVisitResult.CONTINUE) break - } - future.complete(Unit) - } catch (e: Exception) { - if (log.isErrorEnabled) log.error(e.message, e) - future.completeExceptionally(e) - } - } - return future - } - - override fun addHighTransfer(source: Path, target: Path): String { - val transfer = FileTransfer( - parentId = StringUtils.EMPTY, - source = source, - target = target, - size = Files.size(source), - action = TransferAction.Overwrite, - priority = Transfer.Priority.High - ) - if (transferManager.addTransfer(transfer)) { - return transfer.id() - } else { - throw IllegalStateException("Cannot add high transfer.") - } - } - - override fun addTransferListener(listener: TransferListener): Disposable { - return transferManager.addTransferListener(listener) - } - - private fun getTransferAction( - context: AskTransferContext, - panel: TransportPanel, - source: TransportTableModel.Attributes - ): TransferAction? { - if (context.applyAll) return context.action - - val model = panel.getTableModel() - for (i in 0 until model.rowCount) { - val c = model.getAttributes(i) - if (c.name != source.name) continue - val transfer = askTransfer(source, c) - context.action = transfer.action - context.applyAll = transfer.applyAll - if (transfer.option != JOptionPane.OK_OPTION) return null - } - - return context.action - } - - - fun getTransportPanel(fileSystem: FileSystem, tabbed: TransportTabbed): TransportPanel? { - for (i in 0 until tabbed.tabCount) { - val c = tabbed.getComponentAt(i) - if (c is TransportPanel) { - if (c.loader.isLoaded) { - if (c.loader.get().fileSystem == fileSystem) { - return c - } - } - } - } - return null - } - - private fun askTransfer( - source: TransportTableModel.Attributes, - target: TransportTableModel.Attributes - ): AskTransfer { - val formMargin = "7dlu" - val layout = FormLayout( - "left:pref, $formMargin, default:grow, 2dlu, left:pref", - "pref, 12dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" - ) - - val iconSize = 36 - // @formatter:off - val targetIcon = ScaleIcon(if(target.isDirectory) NativeIcons.folderIcon else NativeIcons.fileIcon, iconSize) - val sourceIcon = ScaleIcon(if(source.isDirectory) NativeIcons.folderIcon else NativeIcons.fileIcon, iconSize) - val sourceModified= DateFormatUtils.format(Date(source.lastModifiedTime), I18n.getString("termora.date-format")) - val targetModified= DateFormatUtils.format(Date(target.lastModifiedTime), I18n.getString("termora.date-format")) - // @formatter:on - - - val actionsComBoBox = JComboBox() - actionsComBoBox.addItem(TransferAction.Overwrite) - actionsComBoBox.addItem(TransferAction.Append) - actionsComBoBox.addItem(TransferAction.Skip) - actionsComBoBox.renderer = object : DefaultListCellRenderer() { - override fun getListCellRendererComponent( - list: JList<*>?, - value: Any?, - index: Int, - isSelected: Boolean, - cellHasFocus: Boolean - ): Component { - var text = value?.toString() ?: StringUtils.EMPTY - if (value == TransferAction.Overwrite) { - text = I18n.getString("termora.transport.sftp.already-exists.overwrite") - } else if (value == TransferAction.Skip) { - text = I18n.getString("termora.transport.sftp.already-exists.skip") - } else if (value == TransferAction.Append) { - text = I18n.getString("termora.transport.sftp.already-exists.append") - } - return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus) - } - } - val applyAllCheckbox = JCheckBox(I18n.getString("termora.transport.sftp.already-exists.apply-all")) - val box = Box.createHorizontalBox() - box.add(actionsComBoBox) - box.add(Box.createHorizontalStrut(8)) - box.add(applyAllCheckbox) - box.add(Box.createHorizontalGlue()) - - val ttBox = Box.createVerticalBox() - ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message1"))) - ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message2"))) - - val warningIcon = ScaleIcon(Icons.warningIntroduction, iconSize) - - var rows = 1 - val step = 2 - val panel = FormBuilder.create().layout(layout) - // tip - .add(JLabel(warningIcon)).xy(1, rows) - .add(ttBox).xyw(3, rows, 3).apply { rows += step } - // name - .add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.name")}:")).xy(1, rows) - .add(source.name).xyw(3, rows, 3).apply { rows += step } - // separator - .addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step } - // Destination - .add("${I18n.getString("termora.transport.sftp.already-exists.destination")}:").xy(1, rows) - .apply { rows += step } - // Folder - .add(JLabel(targetIcon)).xy(1, rows, "center, fill") - .add(targetModified).xyw(3, rows, 3).apply { rows += step } - // Source - .add("${I18n.getString("termora.transport.sftp.already-exists.source")}:").xy(1, rows) - .apply { rows += step } - // Folder - .add(JLabel(sourceIcon)).xy(1, rows, "center, fill") - .add(sourceModified).xyw(3, rows, 3).apply { rows += step } - // separator - .addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step } - // name - .add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.actions")}:")).xy(1, rows) - .add(box).xyw(3, rows, 3).apply { rows += step } - .build() - panel.putClientProperty("SKIP_requestFocusInWindow", true) - - return AskTransfer( - option = OptionPane.showConfirmDialog( - owner, panel, - messageType = JOptionPane.PLAIN_MESSAGE, - optionType = JOptionPane.OK_CANCEL_OPTION, - title = source.name, - initialValue = JOptionPane.OK_OPTION, - ) { - it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height) - it.setLocationRelativeTo(it.owner) - }, - action = actionsComBoBox.selectedItem as TransferAction, - applyAll = applyAllCheckbox.isSelected - ) - - } - - private fun doAddTransfer( - workdir: Path, - pair: Pair, - mode: TransferMode, - action: TransferAction, - future: CompletableFuture - ): FileVisitResult { - - val isDirectory = pair.second.isDirectory - val path = pair.first - if (isDirectory.not()) { - val transfer = createTransfer(path, workdir.resolve(path.name), false, StringUtils.EMPTY, mode, action) - return if (transferManager.addTransfer(transfer)) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE - } - - val continued = AtomicBoolean(true) - val queue = ArrayDeque() - val isCancelled = - { (future.isCancelled || future.isCompletedExceptionally).apply { continued.set(this.not()) } } - val basedir = if (isDirectory) workdir.resolve(path.name) else workdir - val visitor = object : FileVisitor { - override fun preVisitDirectory( - dir: Path, - attrs: BasicFileAttributes - ): FileVisitResult { - val parentId = queue.lastOrNull()?.id() ?: StringUtils.EMPTY - // @formatter:off - val transfer = when (mode) { - TransferMode.Delete -> createTransfer(dir, dir, true, parentId, mode, action) - TransferMode.ChangePermission -> createTransfer(path, dir, true, parentId, mode, action, pair.second.permissions) - else -> createTransfer(dir, basedir.resolve(path.relativize(dir).pathString), true, parentId, mode, action) - } - // @formatter:on - - queue.addLast(transfer) - - if (transferManager.addTransfer(transfer).not()) { - continued.set(false) - return FileVisitResult.TERMINATE - } - - return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE - } - - override fun visitFile( - file: Path, - attrs: BasicFileAttributes - ): FileVisitResult { - - val parentId = queue.last().id() - // @formatter:off - val transfer = when (mode) { - TransferMode.Delete -> createTransfer(file, file, false, parentId, mode, action) - TransferMode.ChangePermission -> createTransfer(file, file, false, parentId, mode, action, pair.second.permissions) - else -> createTransfer(file, basedir.resolve(path.relativize(file).pathString), false, parentId, mode, action) - } - - if (transferManager.addTransfer(transfer).not()) { - continued.set(false) - return FileVisitResult.TERMINATE - } - - return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE - } - - override fun visitFileFailed( - file: Path?, - exc: IOException - ): FileVisitResult { - if (log.isErrorEnabled) log.error(exc.message, exc) - future.completeExceptionally(exc) - return FileVisitResult.TERMINATE - } - - override fun postVisitDirectory( - dir: Path?, - exc: IOException? - ): FileVisitResult { - val c = queue.removeLast() - if (c is TransferScanner) c.scanned() - return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE - } - - } - - PathWalker.walkFileTree(path, visitor) - - // 已经添加的则继续传输 - while (queue.isNotEmpty()) { - val c = queue.removeLast() - if (c is TransferScanner) c.scanned() - } - - return if (continued.get()) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE - } - - - private fun createTransfer( - source: Path, - target: Path, - isDirectory: Boolean, - parentId: String, - mode: TransferMode, - action:TransferAction, - permissions: Set? = null - ): Transfer { - if (mode == TransferMode.Delete) { - return DeleteTransfer( - parentId, - source, - isDirectory, - if (isDirectory) 1 else Files.size(source) - ) - } else if (mode == TransferMode.ChangePermission) { - if (permissions == null) throw IllegalStateException() - return ChangePermissionTransfer( - parentId, - target, - isDirectory = isDirectory, - permissions = permissions, - size = if (isDirectory) 1 else Files.size(target) - ) - } - - if (isDirectory) { - return DirectoryTransfer( - parentId = parentId, - source = source, - target = target, - ) - } - - return FileTransfer( - parentId = parentId, - source = source, - target = target, - action = action, - size = Files.size(source) - ) - } - - - } - } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt index 58648bd..aebbdc6 100644 --- a/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt @@ -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" } diff --git a/src/main/resources/icons/questionMark.svg b/src/main/resources/icons/questionMark.svg new file mode 100644 index 0000000..3b3a73e --- /dev/null +++ b/src/main/resources/icons/questionMark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/icons/questionMark_dark.svg b/src/main/resources/icons/questionMark_dark.svg new file mode 100644 index 0000000..bcf3f43 --- /dev/null +++ b/src/main/resources/icons/questionMark_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/icons/transferToolWindow.svg b/src/main/resources/icons/transferToolWindow.svg new file mode 100644 index 0000000..9f48d8b --- /dev/null +++ b/src/main/resources/icons/transferToolWindow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/transferToolWindow_dark.svg b/src/main/resources/icons/transferToolWindow_dark.svg new file mode 100644 index 0000000..aecd837 --- /dev/null +++ b/src/main/resources/icons/transferToolWindow_dark.svg @@ -0,0 +1,4 @@ + + + +