From 79d0a9a348ba86b21f0f210509b7f84561134f4b Mon Sep 17 00:00:00 2001 From: hstyi Date: Thu, 13 Mar 2025 16:33:57 +0800 Subject: [PATCH] refactor: SFTP (#351) --- .github/workflows/windows-x86-64.yml | 1 - src/main/java/app/termora/Kernel32.java | 38 + src/main/kotlin/app/termora/Application.kt | 42 +- .../kotlin/app/termora/ApplicationRunner.kt | 24 +- src/main/kotlin/app/termora/Database.kt | 10 + .../kotlin/app/termora/EditHostOptionsPane.kt | 2 + src/main/kotlin/app/termora/Host.kt | 5 + .../kotlin/app/termora/HostOptionsPane.kt | 53 +- src/main/kotlin/app/termora/Icons.kt | 3 + .../app/termora/NativeStringComparator.kt | 41 + src/main/kotlin/app/termora/NewHostTree.kt | 8 +- .../kotlin/app/termora/NewHostTreeDialog.kt | 18 +- .../kotlin/app/termora/PtyConnectorFactory.kt | 13 +- .../app/termora/RememberFocusTerminalTab.kt | 16 + .../kotlin/app/termora/SFTPPtyTerminalTab.kt | 19 +- .../kotlin/app/termora/SFTPTerminalTab.kt | 73 -- .../kotlin/app/termora/SettingsOptionsPane.kt | 85 +- src/main/kotlin/app/termora/SshClients.kt | 36 +- src/main/kotlin/app/termora/TerminalTab.kt | 2 + src/main/kotlin/app/termora/TerminalTabbed.kt | 12 +- .../app/termora/TerminalTabbedManager.kt | 1 + src/main/kotlin/app/termora/TermoraFrame.kt | 3 +- src/main/kotlin/app/termora/WelcomePanel.kt | 1 + .../app/termora/actions/ActionManager.kt | 2 +- .../app/termora/actions/AppUpdateAction.kt | 8 +- .../findeverywhere/FindEverywhereProvider.kt | 3 + .../app/termora/macro/MacroPtyConnector.kt | 4 +- .../kotlin/app/termora/native/FileChooser.kt | 5 + .../{transport => sftp}/BookmarkButton.kt | 40 +- .../{transport => sftp}/BookmarksDialog.kt | 2 +- .../app/termora/sftp/FileSystemViewNav.kt | 259 ++++ .../app/termora/sftp/FileSystemViewPanel.kt | 468 ++++++++ .../app/termora/sftp/FileSystemViewTable.kt | 844 +++++++++++++ .../termora/sftp/FileSystemViewTableModel.kt | 265 ++++ .../app/termora/sftp/NativeFileIcons.kt | 81 ++ .../PosixFilePermissionDialog.kt | 2 +- .../kotlin/app/termora/sftp/SFTPAction.kt | 62 + .../app/termora/sftp/SFTPActionEvent.kt | 11 + .../app/termora/sftp/SFTPDataProviders.kt | 12 + .../SFTPFileSystemViewPanel.kt} | 148 ++- src/main/kotlin/app/termora/sftp/SFTPPanel.kt | 215 ++++ src/main/kotlin/app/termora/sftp/SFTPTab.kt | 82 ++ .../kotlin/app/termora/sftp/SFTPTabbed.kt | 116 ++ .../kotlin/app/termora/sftp/SpeedReporter.kt | 56 + src/main/kotlin/app/termora/sftp/Transport.kt | 267 +++++ .../app/termora/sftp/TransportListener.kt | 10 + .../app/termora/sftp/TransportManager.kt | 11 + .../termora/sftp/TransportStatusException.kt | 3 + .../kotlin/app/termora/sftp/TransportTable.kt | 261 ++++ .../app/termora/sftp/TransportTableModel.kt | 443 +++++++ .../termora/sftp/TransportTreeTableNode.kt | 72 ++ .../app/termora/terminal/TerminalReader.kt | 8 +- .../app/termora/transport/FileSystemPanel.kt | 1065 ----------------- .../app/termora/transport/FileSystemTabbed.kt | 186 --- .../termora/transport/FileSystemTableModel.kt | 255 ---- .../termora/transport/FileTransportPanel.kt | 174 --- .../transport/FileTransportTableModel.kt | 126 -- .../app/termora/transport/SFTPAction.kt | 80 -- .../kotlin/app/termora/transport/Transport.kt | 287 ----- .../transport/TransportDataProviders.kt | 17 - .../app/termora/transport/TransportJob.kt | 27 - .../termora/transport/TransportListener.kt | 35 - .../app/termora/transport/TransportManager.kt | 130 -- .../app/termora/transport/TransportPanel.kt | 174 --- src/main/resources/i18n/messages.properties | 11 +- .../resources/i18n/messages_zh_CN.properties | 10 +- .../resources/i18n/messages_zh_TW.properties | 9 +- src/main/resources/icons/chevronRight.svg | 4 + .../resources/icons/chevronRight_dark.svg | 4 + src/main/resources/icons/eye.svg | 8 +- src/main/resources/icons/eyeClose.svg | 2 +- src/main/resources/icons/eyeClose_dark.svg | 2 +- src/main/resources/icons/eye_dark.svg | 8 +- src/main/resources/icons/inspectionsEye.svg | 5 + .../resources/icons/inspectionsEye_dark.svg | 5 + .../resources/icons/warningIntroduction.svg | 6 + .../icons/warningIntroduction_dark.svg | 6 + 77 files changed, 4083 insertions(+), 2819 deletions(-) create mode 100644 src/main/java/app/termora/Kernel32.java create mode 100644 src/main/kotlin/app/termora/NativeStringComparator.kt create mode 100644 src/main/kotlin/app/termora/RememberFocusTerminalTab.kt delete mode 100644 src/main/kotlin/app/termora/SFTPTerminalTab.kt rename src/main/kotlin/app/termora/{transport => sftp}/BookmarkButton.kt (85%) rename src/main/kotlin/app/termora/{transport => sftp}/BookmarksDialog.kt (99%) create mode 100644 src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt create mode 100644 src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt create mode 100644 src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt create mode 100644 src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt create mode 100644 src/main/kotlin/app/termora/sftp/NativeFileIcons.kt rename src/main/kotlin/app/termora/{transport => sftp}/PosixFilePermissionDialog.kt (99%) create mode 100644 src/main/kotlin/app/termora/sftp/SFTPAction.kt create mode 100644 src/main/kotlin/app/termora/sftp/SFTPActionEvent.kt create mode 100644 src/main/kotlin/app/termora/sftp/SFTPDataProviders.kt rename src/main/kotlin/app/termora/{transport/SftpFileSystemPanel.kt => sftp/SFTPFileSystemViewPanel.kt} (66%) create mode 100644 src/main/kotlin/app/termora/sftp/SFTPPanel.kt create mode 100644 src/main/kotlin/app/termora/sftp/SFTPTab.kt create mode 100644 src/main/kotlin/app/termora/sftp/SFTPTabbed.kt create mode 100644 src/main/kotlin/app/termora/sftp/SpeedReporter.kt create mode 100644 src/main/kotlin/app/termora/sftp/Transport.kt create mode 100644 src/main/kotlin/app/termora/sftp/TransportListener.kt create mode 100644 src/main/kotlin/app/termora/sftp/TransportManager.kt create mode 100644 src/main/kotlin/app/termora/sftp/TransportStatusException.kt create mode 100644 src/main/kotlin/app/termora/sftp/TransportTable.kt create mode 100644 src/main/kotlin/app/termora/sftp/TransportTableModel.kt create mode 100644 src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt delete mode 100644 src/main/kotlin/app/termora/transport/FileSystemPanel.kt delete mode 100644 src/main/kotlin/app/termora/transport/FileSystemTabbed.kt delete mode 100644 src/main/kotlin/app/termora/transport/FileSystemTableModel.kt delete mode 100644 src/main/kotlin/app/termora/transport/FileTransportPanel.kt delete mode 100644 src/main/kotlin/app/termora/transport/FileTransportTableModel.kt delete mode 100644 src/main/kotlin/app/termora/transport/SFTPAction.kt delete mode 100644 src/main/kotlin/app/termora/transport/Transport.kt delete mode 100644 src/main/kotlin/app/termora/transport/TransportDataProviders.kt delete mode 100644 src/main/kotlin/app/termora/transport/TransportJob.kt delete mode 100644 src/main/kotlin/app/termora/transport/TransportListener.kt delete mode 100644 src/main/kotlin/app/termora/transport/TransportManager.kt delete mode 100644 src/main/kotlin/app/termora/transport/TransportPanel.kt create mode 100644 src/main/resources/icons/chevronRight.svg create mode 100644 src/main/resources/icons/chevronRight_dark.svg create mode 100644 src/main/resources/icons/inspectionsEye.svg create mode 100644 src/main/resources/icons/inspectionsEye_dark.svg create mode 100644 src/main/resources/icons/warningIntroduction.svg create mode 100644 src/main/resources/icons/warningIntroduction_dark.svg diff --git a/.github/workflows/windows-x86-64.yml b/.github/workflows/windows-x86-64.yml index 2a221fb..bc0502e 100644 --- a/.github/workflows/windows-x86-64.yml +++ b/.github/workflows/windows-x86-64.yml @@ -45,5 +45,4 @@ jobs: name: termora-windows-x86-64 path: | build/distributions/*.zip - build/distributions/*.msi build/distributions/*.exe diff --git a/src/main/java/app/termora/Kernel32.java b/src/main/java/app/termora/Kernel32.java new file mode 100644 index 0000000..ab7e68e --- /dev/null +++ b/src/main/java/app/termora/Kernel32.java @@ -0,0 +1,38 @@ +package app.termora; + +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.WString; +import com.sun.jna.win32.StdCallLibrary; + +interface Kernel32 extends StdCallLibrary { + + Kernel32 INSTANCE = Native.load("Kernel32", Kernel32.class); + WString INVARIANT_LOCALE = new WString(""); + + int CompareStringEx(WString lpLocaleName, + int dwCmpFlags, + WString lpString1, + int cchCount1, + WString lpString2, + int cchCount2, + Pointer lpVersionInformation, + Pointer lpReserved, + int lParam); + + default int CompareStringEx(int dwCmpFlags, + String str1, + String str2) { + return Kernel32.INSTANCE + .CompareStringEx( + INVARIANT_LOCALE, + dwCmpFlags, + new WString(str1), + str1.length(), + new WString(str2), + str2.length(), + Pointer.NULL, + Pointer.NULL, + 0); + } + } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Application.kt b/src/main/kotlin/app/termora/Application.kt index 116b650..df0bf68 100644 --- a/src/main/kotlin/app/termora/Application.kt +++ b/src/main/kotlin/app/termora/Application.kt @@ -150,6 +150,16 @@ object Application { ProcessBuilder("xdg-open", uri.toString()).start() } } + + fun browseInFolder(file: File) { + if (SystemInfo.isWindows) { + ProcessBuilder("explorer", "/select," + file.absolutePath).start() + } else if (SystemInfo.isMacOS) { + ProcessBuilder("open", "-R", file.absolutePath).start() + } else if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) { + Desktop.getDesktop().browseFileDirectory(file) + } + } } fun formatBytes(bytes: Long): String { @@ -159,7 +169,7 @@ fun formatBytes(bytes: Long): String { val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt() val value = bytes / 1024.0.pow(exp.toDouble()) - return String.format("%.2f %s", value, units[exp]) + return String.format("%.2f%s", value, units[exp]) } fun formatSeconds(seconds: Long): String { @@ -168,11 +178,33 @@ fun formatSeconds(seconds: Long): String { val minutes = (seconds % 3600) / 60 val remainingSeconds = seconds % 60 + return when { - days > 0 -> "${days}天${hours}小时${minutes}分${remainingSeconds}秒" - hours > 0 -> "${hours}小时${minutes}分${remainingSeconds}秒" - minutes > 0 -> "${minutes}分${remainingSeconds}秒" - else -> "${remainingSeconds}秒" + days > 0 -> I18n.getString( + "termora.transport.jobs.table.estimated-time-days-format", + days, + hours, + minutes, + remainingSeconds + ) + + hours > 0 -> I18n.getString( + "termora.transport.jobs.table.estimated-time-hours-format", + hours, + minutes, + remainingSeconds + ) + + minutes > 0 -> I18n.getString( + "termora.transport.jobs.table.estimated-time-minutes-format", + minutes, + remainingSeconds + ) + + else -> I18n.getString( + "termora.transport.jobs.table.estimated-time-seconds-format", + remainingSeconds + ) } } diff --git a/src/main/kotlin/app/termora/ApplicationRunner.kt b/src/main/kotlin/app/termora/ApplicationRunner.kt index a8dcff0..affe49d 100644 --- a/src/main/kotlin/app/termora/ApplicationRunner.kt +++ b/src/main/kotlin/app/termora/ApplicationRunner.kt @@ -7,6 +7,7 @@ import com.formdev.flatlaf.FlatSystemProperties import com.formdev.flatlaf.extras.FlatDesktop import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse import com.formdev.flatlaf.extras.FlatInspector +import com.formdev.flatlaf.ui.FlatTableCellBorder import com.formdev.flatlaf.util.SystemInfo import com.jthemedetecor.OsThemeDetector import com.mixpanel.mixpanelapi.ClientDelivery @@ -29,7 +30,6 @@ import java.nio.channels.FileLock import java.nio.file.Paths import java.nio.file.StandardOpenOption import java.util.* -import java.util.function.Consumer import javax.swing.* import kotlin.system.exitProcess import kotlin.system.measureTimeMillis @@ -120,15 +120,12 @@ class ApplicationRunner { private fun startMainFrame() { + TermoraFrameManager.getInstance().createWindow().isVisible = true if (SystemUtils.IS_OS_MAC_OSX) { SwingUtilities.invokeLater { - FlatDesktop.setQuitHandler(object : Consumer { - override fun accept(response: QuitResponse) { - quitHandler(response) - } - }) + FlatDesktop.setQuitHandler { response -> quitHandler(response) } } } } @@ -172,6 +169,15 @@ class ApplicationRunner { JDialog.setDefaultLookAndFeelDecorated(true) } + UIManager.put( + "FileChooser.${if (SystemInfo.isWindows) "win32" else "other"}.newFolder", + I18n.getString("termora.welcome.contextmenu.new.folder.name") + ) + UIManager.put( + "FileChooser.${if (SystemInfo.isWindows) "win32" else "other"}.newFolder.subsequent", + "${I18n.getString("termora.welcome.contextmenu.new.folder.name")}.{0}" + ) + val themeManager = ThemeManager.getInstance() val appearance = Database.getDatabase().appearance var theme = appearance.theme @@ -186,6 +192,7 @@ class ApplicationRunner { themeManager.change(theme, true) + if (Application.isUnknownVersion()) FlatInspector.install("ctrl shift alt X") @@ -218,9 +225,8 @@ class ApplicationRunner { } UIManager.put("Table.rowHeight", 24) - UIManager.put("Table.cellNoFocusBorder", BorderFactory.createEmptyBorder()) - UIManager.put("Table.focusCellHighlightBorder", BorderFactory.createEmptyBorder()) - UIManager.put("Table.focusSelectedCellHighlightBorder", BorderFactory.createEmptyBorder()) + UIManager.put("Table.focusCellHighlightBorder", FlatTableCellBorder.Default()) + UIManager.put("Table.focusSelectedCellHighlightBorder", FlatTableCellBorder.Default()) UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc")) UIManager.put("Tree.rowHeight", 24) diff --git a/src/main/kotlin/app/termora/Database.kt b/src/main/kotlin/app/termora/Database.kt index 6b47748..50a7a42 100644 --- a/src/main/kotlin/app/termora/Database.kt +++ b/src/main/kotlin/app/termora/Database.kt @@ -606,12 +606,22 @@ class Database private constructor(private val env: Environment) : Disposable { */ var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY) + /** + * defaultDirectory + */ + var defaultDirectory by StringPropertyDelegate(StringUtils.EMPTY) + /** * 是否固定在标签栏 */ var pinTab by BooleanPropertyDelegate(false) + /** + * 是否保留原始文件时间 + */ + var preserveModificationTime by BooleanPropertyDelegate(false) + } /** diff --git a/src/main/kotlin/app/termora/EditHostOptionsPane.kt b/src/main/kotlin/app/termora/EditHostOptionsPane.kt index ea83175..762b7e7 100644 --- a/src/main/kotlin/app/termora/EditHostOptionsPane.kt +++ b/src/main/kotlin/app/termora/EditHostOptionsPane.kt @@ -47,6 +47,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() { serialCommOption.parityComboBox.selectedItem = serialComm.parity serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl + + sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory } override fun getHost(): Host { diff --git a/src/main/kotlin/app/termora/Host.kt b/src/main/kotlin/app/termora/Host.kt index 94add47..a6f5fb1 100644 --- a/src/main/kotlin/app/termora/Host.kt +++ b/src/main/kotlin/app/termora/Host.kt @@ -132,6 +132,11 @@ data class Options( * 串口配置 */ val serialComm: SerialComm = SerialComm(), + + /** + * SFTP 默认目录 + */ + val sftpDefaultDirectory: String = StringUtils.EMPTY, ) { companion object { val Default = Options() diff --git a/src/main/kotlin/app/termora/HostOptionsPane.kt b/src/main/kotlin/app/termora/HostOptionsPane.kt index ef16e66..3b61056 100644 --- a/src/main/kotlin/app/termora/HostOptionsPane.kt +++ b/src/main/kotlin/app/termora/HostOptionsPane.kt @@ -29,6 +29,7 @@ open class HostOptionsPane : OptionsPane() { protected val terminalOption = TerminalOption() protected val jumpHostsOption = JumpHostsOption() protected val serialCommOption = SerialCommOption() + protected val sftpOption = SFTPOption() protected val owner: Window get() = SwingUtilities.getWindowAncestor(this) init { @@ -38,6 +39,7 @@ open class HostOptionsPane : OptionsPane() { addOption(jumpHostsOption) addOption(terminalOption) addOption(serialCommOption) + addOption(sftpOption) setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)) } @@ -91,7 +93,8 @@ open class HostOptionsPane : OptionsPane() { startupCommand = terminalOption.startupCommandTextField.text, heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int, jumpHosts = jumpHostsOption.jumpHosts.map { it.id }, - serialComm = serialComm + serialComm = serialComm, + sftpDefaultDirectory = sftpOption.defaultDirectoryField.text ) return Host( @@ -669,6 +672,54 @@ open class HostOptionsPane : OptionsPane() { } } + protected inner class SFTPOption : JPanel(BorderLayout()), Option { + val defaultDirectoryField = OutlineTextField(255) + + + init { + initView() + initEvents() + } + + private fun initView() { + add(getCenterComponent(), BorderLayout.CENTER) + } + + private fun initEvents() { + + } + + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.folder + } + + override fun getTitle(): String { + return "SFTP" + } + + override fun getJComponent(): JComponent { + return this + } + + private fun getCenterComponent(): JComponent { + val layout = FormLayout( + "left:pref, $formMargin, default:grow, $formMargin", + "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" + ) + + var rows = 1 + val step = 2 + val panel = FormBuilder.create().layout(layout) + .add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows) + .add(defaultDirectoryField).xy(3, rows).apply { rows += step } + .build() + + + return panel + } + } + protected inner class TunnelingOption : JPanel(BorderLayout()), Option { val tunnelings = mutableListOf() diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt index 34b7beb..5b47546 100644 --- a/src/main/kotlin/app/termora/Icons.kt +++ b/src/main/kotlin/app/termora/Icons.kt @@ -13,6 +13,7 @@ object Icons { val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") } val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") } val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") } + val inspectionsEye by lazy { DynamicIcon("icons/inspectionsEye.svg", "icons/inspectionsEye_dark.svg") } 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") } @@ -32,6 +33,7 @@ object Icons { val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") } val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") } val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") } + val warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_dark.svg") } val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") } val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") } val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") } @@ -50,6 +52,7 @@ object Icons { val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") } val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") } val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") } + val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_dark.svg") } val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") } val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") } val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") } diff --git a/src/main/kotlin/app/termora/NativeStringComparator.kt b/src/main/kotlin/app/termora/NativeStringComparator.kt new file mode 100644 index 0000000..e2e201b --- /dev/null +++ b/src/main/kotlin/app/termora/NativeStringComparator.kt @@ -0,0 +1,41 @@ +package app.termora + +import com.formdev.flatlaf.util.SystemInfo +import de.jangassen.jfa.foundation.Foundation +import de.jangassen.jfa.foundation.Foundation.NSAutoreleasePool +import java.text.Collator +import java.util.* + +class NativeStringComparator private constructor() : Comparator { + private val collator by lazy { Collator.getInstance(Locale.getDefault()).apply { strength = Collator.PRIMARY } } + + companion object { + fun getInstance(): NativeStringComparator { + return ApplicationScope.forApplicationScope() + .getOrCreate(NativeStringComparator::class) { NativeStringComparator() } + } + + private const val SORT_DIGITSASNUMBERS: Int = 0x00000008 + + } + + override fun compare(o1: String, o2: String): Int { + if (SystemInfo.isWindows) { + // CompareStringEx returns 1, 2, 3 respectively instead of -1, 0, 1 + return Kernel32.INSTANCE.CompareStringEx(SORT_DIGITSASNUMBERS, o1, o2) - 2 + } else if (SystemInfo.isMacOS) { + val pool = NSAutoreleasePool() + try { + val a = Foundation.nsString(o1) + val b = Foundation.nsString(o2) + return Foundation.invoke(a, "localizedStandardCompare:", b).toInt() + } finally { + pool.drain() + } + } + + return collator.compare(o1, o2) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/NewHostTree.kt b/src/main/kotlin/app/termora/NewHostTree.kt index a59b7ea..46610ab 100644 --- a/src/main/kotlin/app/termora/NewHostTree.kt +++ b/src/main/kotlin/app/termora/NewHostTree.kt @@ -1,9 +1,8 @@ package app.termora import app.termora.Application.ohMyJson -import app.termora.actions.AnActionEvent import app.termora.actions.OpenHostAction -import app.termora.transport.SFTPAction +import app.termora.sftp.SFTPActionEvent import com.formdev.flatlaf.extras.components.FlatPopupMenu import kotlinx.serialization.Serializable import kotlinx.serialization.json.* @@ -46,6 +45,7 @@ class NewHostTree : SimpleTree() { private val properties get() = Database.getDatabase().properties private val owner get() = SwingUtilities.getWindowAncestor(this) private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) + private val sftpAction get() = ActionManager.getInstance().getAction(app.termora.Actions.SFTP) private var isShowMoreInfo get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean() set(value) = properties.putString("HostTree.showMoreInfo", value.toString()) @@ -396,10 +396,8 @@ class NewHostTree : SimpleTree() { val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } if (nodes.isEmpty()) return - val sftpAction = ActionManager.getInstance().getAction(app.termora.Actions.SFTP) as SFTPAction? ?: return - val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return for (node in nodes) { - sftpAction.connectHost(node, tab) + sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt)) } } diff --git a/src/main/kotlin/app/termora/NewHostTreeDialog.kt b/src/main/kotlin/app/termora/NewHostTreeDialog.kt index 31acb68..4f3d863 100644 --- a/src/main/kotlin/app/termora/NewHostTreeDialog.kt +++ b/src/main/kotlin/app/termora/NewHostTreeDialog.kt @@ -3,11 +3,10 @@ package app.termora import org.apache.commons.lang3.StringUtils import java.awt.Dimension import java.awt.Window +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import java.util.function.Function -import javax.swing.BorderFactory -import javax.swing.JComponent -import javax.swing.JScrollPane -import javax.swing.UIManager +import javax.swing.* class NewHostTreeDialog( owner: Window, @@ -19,7 +18,7 @@ class NewHostTreeDialog( private val tree = NewHostTree() init { - size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150) + size = Dimension(UIManager.getInt("Dialog.width") - 250, UIManager.getInt("Dialog.height") - 150) isModal = true isResizable = false controlsVisible = false @@ -29,6 +28,15 @@ class NewHostTreeDialog( tree.doubleClickConnection = false tree.dragEnabled = false + tree.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { + val node = tree.getLastSelectedPathNode() ?: return + if (node.isFolder) return + doOKAction() + } + } + }) init() diff --git a/src/main/kotlin/app/termora/PtyConnectorFactory.kt b/src/main/kotlin/app/termora/PtyConnectorFactory.kt index 24caab9..43b39a3 100644 --- a/src/main/kotlin/app/termora/PtyConnectorFactory.kt +++ b/src/main/kotlin/app/termora/PtyConnectorFactory.kt @@ -33,14 +33,21 @@ class PtyConnectorFactory : Disposable { if (SystemUtils.IS_OS_UNIX) { commands.add("-l") } - return createPtyConnector(commands.toTypedArray(), rows, cols, env, charset) + return createPtyConnector( + commands = commands.toTypedArray(), + rows = rows, + cols = cols, + env = env, + charset = charset + ) } fun createPtyConnector( commands: Array, rows: Int = 24, cols: Int = 80, env: Map = emptyMap(), - charset: Charset = StandardCharsets.UTF_8 + directory: String = SystemUtils.USER_HOME, + charset: Charset = StandardCharsets.UTF_8, ): PtyConnector { val envs = mutableMapOf() envs.putAll(System.getenv()) @@ -67,7 +74,7 @@ class PtyConnectorFactory : Disposable { .setInitialRows(rows) .setInitialColumns(cols) .setConsole(false) - .setDirectory(SystemUtils.USER_HOME) + .setDirectory(StringUtils.defaultIfBlank(directory, SystemUtils.USER_HOME)) .setCygwin(false) .setUseWinConPty(SystemUtils.IS_OS_WINDOWS) .setRedirectErrorStream(false) diff --git a/src/main/kotlin/app/termora/RememberFocusTerminalTab.kt b/src/main/kotlin/app/termora/RememberFocusTerminalTab.kt new file mode 100644 index 0000000..726ca9b --- /dev/null +++ b/src/main/kotlin/app/termora/RememberFocusTerminalTab.kt @@ -0,0 +1,16 @@ +package app.termora + +import java.awt.Component +import java.awt.KeyboardFocusManager + +abstract class RememberFocusTerminalTab : TerminalTab { + private var lastFocusedComponent: Component? = null + + override fun onLostFocus() { + lastFocusedComponent = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner + } + + override fun onGrabFocus() { + lastFocusedComponent?.requestFocusInWindow() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt b/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt index 7c9289e..1215b3f 100644 --- a/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt +++ b/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt @@ -8,6 +8,7 @@ import org.apache.commons.io.Charsets import org.apache.commons.io.FileUtils import org.apache.commons.io.IOUtils import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.SystemUtils import org.apache.sshd.client.ClientBuilder import org.apache.sshd.client.SshClient import org.apache.sshd.client.session.ClientSession @@ -28,6 +29,11 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal private var sshSession: ClientSession? = null private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand + private val defaultDirectory get() = Database.getDatabase().sftp.defaultDirectory + + init { + terminalPanel.dropFiles = true + } companion object { val canSupports by lazy { @@ -115,16 +121,21 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal if (envs.containsKey("CurrentDir")) { val currentDir = envs.getValue("CurrentDir") commands.add("${host.username}@${host.host}:${currentDir}") + } else if (host.options.sftpDefaultDirectory.isNotBlank()) { + commands.add("${host.username}@${host.host}:${host.options.sftpDefaultDirectory.trim()}") } else { commands.add("${host.username}@${host.host}") } + val directory = FileUtils.getFile(StringUtils.defaultIfBlank(defaultDirectory, SystemUtils.USER_HOME)) + val winSize = terminalPanel.winSize() val ptyConnector = ptyConnectorFactory.createPtyConnector( - commands.toTypedArray(), - winSize.rows, winSize.cols, - host.options.envs(), - Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8), + commands = commands.toTypedArray(), + rows = winSize.rows, cols = winSize.cols, + env = host.options.envs(), + charset = Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8), + directory = if (directory.exists()) directory.absolutePath else SystemUtils.USER_HOME ) return ptyConnector diff --git a/src/main/kotlin/app/termora/SFTPTerminalTab.kt b/src/main/kotlin/app/termora/SFTPTerminalTab.kt deleted file mode 100644 index 65b24d2..0000000 --- a/src/main/kotlin/app/termora/SFTPTerminalTab.kt +++ /dev/null @@ -1,73 +0,0 @@ -package app.termora - -import app.termora.actions.DataProvider -import app.termora.terminal.DataKey -import app.termora.transport.TransportDataProviders -import app.termora.transport.TransportPanel -import java.beans.PropertyChangeListener -import javax.swing.Icon -import javax.swing.JComponent -import javax.swing.JOptionPane -import javax.swing.SwingUtilities - -class SFTPTerminalTab : Disposable, TerminalTab, DataProvider { - - private val sftp get() = Database.getDatabase().sftp - private val transportPanel = TransportPanel() - - init { - Disposer.register(this, transportPanel) - } - - override fun getTitle(): String { - return "SFTP" - } - - override fun getIcon(): Icon { - return Icons.folder - } - - override fun addPropertyChangeListener(listener: PropertyChangeListener) { - - } - - override fun removePropertyChangeListener(listener: PropertyChangeListener) { - } - - override fun getJComponent(): JComponent { - return transportPanel - } - - override fun canClone(): Boolean { - return false - } - - override fun canClose(): Boolean { - assertEventDispatchThread() - - if (sftp.pinTab) { - return false - } - - val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true - if (transportManager.getTransports().isEmpty()) { - return true - } - - return OptionPane.showConfirmDialog( - SwingUtilities.getWindowAncestor(getJComponent()), - I18n.getString("termora.transport.sftp.close-tab"), - messageType = JOptionPane.QUESTION_MESSAGE, - optionType = JOptionPane.OK_CANCEL_OPTION - ) == JOptionPane.OK_OPTION - } - - @Suppress("UNCHECKED_CAST") - override fun getData(dataKey: DataKey): T? { - if (dataKey == TransportDataProviders.TransportPanel) { - return transportPanel as T - } - return null - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SettingsOptionsPane.kt b/src/main/kotlin/app/termora/SettingsOptionsPane.kt index a92216c..6476b3c 100644 --- a/src/main/kotlin/app/termora/SettingsOptionsPane.kt +++ b/src/main/kotlin/app/termora/SettingsOptionsPane.kt @@ -15,6 +15,7 @@ import app.termora.keymgr.OhKeyPair import app.termora.macro.Macro import app.termora.macro.MacroManager import app.termora.native.FileChooser +import app.termora.sftp.SFTPTab import app.termora.snippet.Snippet import app.termora.snippet.SnippetManager import app.termora.sync.SyncConfig @@ -25,7 +26,6 @@ import app.termora.terminal.CursorStyle import app.termora.terminal.DataKey import app.termora.terminal.panel.FloatingToolbarPanel import app.termora.terminal.panel.TerminalPanel -import app.termora.transport.SFTPAction import cash.z.ecc.android.bip39.Mnemonics import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.FlatSVGIcon @@ -46,14 +46,15 @@ import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.time.DateFormatUtils import org.jdesktop.swingx.JXEditorPane -import org.jdesktop.swingx.action.ActionManager import org.slf4j.LoggerFactory import java.awt.BorderLayout import java.awt.Component import java.awt.Dimension import java.awt.Toolkit import java.awt.datatransfer.StringSelection +import java.awt.event.ActionEvent import java.awt.event.ItemEvent +import java.awt.event.ItemListener import java.io.File import java.net.URI import java.nio.charset.StandardCharsets @@ -72,7 +73,6 @@ class SettingsOptionsPane : OptionsPane() { private val snippetManager get() = SnippetManager.getInstance() private val keymapManager get() = KeymapManager.getInstance() private val macroManager get() = MacroManager.getInstance() - private val actionManager get() = ActionManager.getInstance() private val keywordHighlightManager get() = KeywordHighlightManager.getInstance() private val keyManager get() = KeyManager.getInstance() @@ -1334,9 +1334,11 @@ class SettingsOptionsPane : OptionsPane() { private val editCommandField = OutlineTextField(255) private val sftpCommandField = OutlineTextField(255) + private val defaultDirectoryField = OutlineTextField(255) + private val browseDirectoryBtn = JButton(Icons.folder) private val pinTabComboBox = YesOrNoComboBox() + private val preserveModificationTimeComboBox = YesOrNoComboBox() private val sftp get() = database.sftp - private val sftpAction get() = actionManager.getAction(Actions.SFTP) as SFTPAction init { initView() @@ -1358,25 +1360,53 @@ class SettingsOptionsPane : OptionsPane() { } }) - pinTabComboBox.addItemListener { - if (it.stateChange == ItemEvent.SELECTED) { + defaultDirectoryField.document.addDocumentListener(object : DocumentAdaptor() { + override fun changedUpdate(e: DocumentEvent) { + sftp.defaultDirectory = defaultDirectoryField.text + } + }) + + pinTabComboBox.addItemListener(object : ItemListener { + override fun itemStateChanged(e: ItemEvent) { + if (e.stateChange != ItemEvent.SELECTED) return sftp.pinTab = pinTabComboBox.selectedItem as Boolean for (window in TermoraFrameManager.getInstance().getWindows()) { val evt = AnActionEvent(window, StringUtils.EMPTY, EventObject(window)) - if (pinTabComboBox.selectedItem == true) { - sftpAction.openOrCreateSFTPTerminalTab(evt) - } - val tabbed = evt.getData(DataProviders.TabbedPane) ?: continue val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue - for ((index, tab) in manager.getTerminalTabs().withIndex()) { - if (tab is SFTPTerminalTab) { - tabbed.setTabClosable(index, pinTabComboBox.selectedItem != true) - break + + if (sftp.pinTab) { + if (manager.getTerminalTabs().none { it is SFTPTab }) { + manager.addTerminalTab(1, SFTPTab(), false) } } + + // 刷新状态 + manager.refreshTerminalTabs() } } + + }) + + preserveModificationTimeComboBox.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + sftp.preserveModificationTime = preserveModificationTimeComboBox.selectedItem as Boolean + } } + + browseDirectoryBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val chooser = FileChooser() + chooser.allowsMultiSelection = false + chooser.defaultDirectory = StringUtils.defaultIfBlank( + defaultDirectoryField.text, + SystemUtils.USER_HOME + ) + chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + chooser.showOpenDialog(owner).thenAccept { files -> + if (files.isNotEmpty()) defaultDirectoryField.text = files.first().absolutePath + } + } + }) } @@ -1393,9 +1423,14 @@ class SettingsOptionsPane : OptionsPane() { sftpCommandField.placeholderText = "sftp" } + defaultDirectoryField.placeholderText = SystemUtils.USER_HOME + defaultDirectoryField.trailingComponent = browseDirectoryBtn + + defaultDirectoryField.text = sftp.defaultDirectory editCommandField.text = sftp.editCommand sftpCommandField.text = sftp.sftpCommand pinTabComboBox.selectedItem = sftp.pinTab + preserveModificationTimeComboBox.selectedItem = sftp.preserveModificationTime } override fun getIcon(isSelected: Boolean): Icon { @@ -1416,13 +1451,23 @@ class SettingsOptionsPane : OptionsPane() { "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" ) + val box = Box.createHorizontalBox() + box.add(JLabel("${I18n.getString("termora.settings.sftp.preserve-time")}:")) + box.add(Box.createHorizontalStrut(8)) + box.add(preserveModificationTimeComboBox) + + var rows = 1 val builder = FormBuilder.create().layout(layout).debug(false) - builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, 1) - builder.add(pinTabComboBox).xy(3, 1) - builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, 3) - builder.add(editCommandField).xy(3, 3) - builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, 5) - builder.add(sftpCommandField).xy(3, 5) + builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, rows) + builder.add(pinTabComboBox).xy(3, rows).apply { rows += 2 } + builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, rows) + builder.add(editCommandField).xy(3, rows).apply { rows += 2 } + builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, rows) + builder.add(sftpCommandField).xy(3, rows).apply { rows += 2 } + builder.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows) + builder.add(defaultDirectoryField).xy(3, rows).apply { rows += 2 } + builder.add(box).xyw(1, rows, 3).apply { rows += 2 } + return builder.build() diff --git a/src/main/kotlin/app/termora/SshClients.kt b/src/main/kotlin/app/termora/SshClients.kt index 5413ac6..02c219f 100644 --- a/src/main/kotlin/app/termora/SshClients.kt +++ b/src/main/kotlin/app/termora/SshClients.kt @@ -1,7 +1,11 @@ package app.termora +import app.termora.keyboardinteractive.TerminalUserInteraction import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.terminal.TerminalSize +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.swing.Swing +import kotlinx.coroutines.withContext import org.apache.commons.io.IOUtils import org.apache.commons.lang3.StringUtils import org.apache.sshd.client.ClientBuilder @@ -16,6 +20,7 @@ import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor import org.apache.sshd.client.keyverifier.ServerKeyVerifier import org.apache.sshd.client.session.ClientSession +import org.apache.sshd.common.AttributeRepository import org.apache.sshd.common.SshException import org.apache.sshd.common.channel.PtyChannelConfiguration import org.apache.sshd.common.config.keys.KeyUtils @@ -48,6 +53,9 @@ import javax.swing.SwingUtilities import kotlin.math.max object SshClients { + + val HOST_KEY = AttributeRepository.AttributeKey() + private val timeout = Duration.ofSeconds(30) private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) } @@ -151,7 +159,8 @@ object SshClients { log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}") } // 映射完毕之后修改Host和端口 - jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis()) + jumpHosts[i + 1] = + nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis()) } } @@ -191,6 +200,8 @@ object SshClients { throw SshException("Authentication failed") } + session.setAttribute(HOST_KEY, host) + return session } @@ -230,6 +241,29 @@ object SshClients { return sshdSocketAddress } + suspend fun openClient(host: Host, owner: Window): Pair { + val client = openClient(host) + var myHost = host + withContext(Dispatchers.Swing) { + client.userInteraction = TerminalUserInteraction(owner) + client.serverKeyVerifier = DialogServerKeyVerifier(owner) + // 弹出授权框 + if (host.authentication.type == AuthenticationType.No) { + val dialog = RequestAuthenticationDialog(owner, host) + val authentication = dialog.getAuthentication() + myHost = myHost.copy( + authentication = authentication, + username = dialog.getUsername(), updateDate = System.currentTimeMillis(), + ) + // save + if (dialog.isRemembered()) { + HostManager.getInstance().addHost(myHost) + } + } + } + return client to myHost + } + /** * 打开一个客户端 */ diff --git a/src/main/kotlin/app/termora/TerminalTab.kt b/src/main/kotlin/app/termora/TerminalTab.kt index fcf86ab..6847817 100644 --- a/src/main/kotlin/app/termora/TerminalTab.kt +++ b/src/main/kotlin/app/termora/TerminalTab.kt @@ -43,6 +43,8 @@ interface TerminalTab : Disposable, DataProvider { */ fun canClose(): Boolean = true + fun willBeClose(): Boolean = true + /** * 是否可以克隆 */ diff --git a/src/main/kotlin/app/termora/TerminalTabbed.kt b/src/main/kotlin/app/termora/TerminalTabbed.kt index 14d56a3..e313705 100644 --- a/src/main/kotlin/app/termora/TerminalTabbed.kt +++ b/src/main/kotlin/app/termora/TerminalTabbed.kt @@ -6,7 +6,6 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider import app.termora.findeverywhere.FindEverywhereProvider import app.termora.findeverywhere.FindEverywhereResult import app.termora.terminal.DataKey -import app.termora.transport.TransportPanel import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatTabbedPane @@ -121,7 +120,7 @@ class TerminalTabbed( val results = mutableListOf() for (i in 0 until tabbedPane.tabCount) { val c = tabbedPane.getComponentAt(i) - if (c is WelcomePanel || c is TransportPanel) { + if (c is JComponent && c.getClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE) != null) { continue } results.add( @@ -155,7 +154,7 @@ class TerminalTabbed( val tab = tabs[index] if (disposable) { - if (!tab.canClose()) { + if (!tab.willBeClose()) { return } } @@ -327,6 +326,13 @@ class TerminalTabbed( Disposer.register(this, tab) } + override fun refreshTerminalTabs() { + for (i in 0 until tabbedPane.tabCount) { + tabbedPane.setTabClosable(i, tabs[i].canClose()) + } + } + + private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) { if (!SFTPPtyTerminalTab.canSupports) { OptionPane.showMessageDialog( diff --git a/src/main/kotlin/app/termora/TerminalTabbedManager.kt b/src/main/kotlin/app/termora/TerminalTabbedManager.kt index 07be15a..57c22e1 100644 --- a/src/main/kotlin/app/termora/TerminalTabbedManager.kt +++ b/src/main/kotlin/app/termora/TerminalTabbedManager.kt @@ -7,4 +7,5 @@ interface TerminalTabbedManager { fun getTerminalTabs(): List fun setSelectedTerminalTab(tab: TerminalTab) fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true) + fun refreshTerminalTabs() } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/TermoraFrame.kt b/src/main/kotlin/app/termora/TermoraFrame.kt index f99cd97..dc28cfd 100644 --- a/src/main/kotlin/app/termora/TermoraFrame.kt +++ b/src/main/kotlin/app/termora/TermoraFrame.kt @@ -4,6 +4,7 @@ package app.termora import app.termora.actions.DataProvider import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviders +import app.termora.sftp.SFTPTab import app.termora.terminal.DataKey import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatLaf @@ -103,7 +104,7 @@ class TermoraFrame : JFrame(), DataProvider { // 下一次事件循环检测是否固定 SFTP SwingUtilities.invokeLater { if (sftp.pinTab) { - terminalTabbed.addTerminalTab(SFTPTerminalTab(), false) + terminalTabbed.addTerminalTab(SFTPTab(), false) } } diff --git a/src/main/kotlin/app/termora/WelcomePanel.kt b/src/main/kotlin/app/termora/WelcomePanel.kt index 57034ed..b7702f3 100644 --- a/src/main/kotlin/app/termora/WelcomePanel.kt +++ b/src/main/kotlin/app/termora/WelcomePanel.kt @@ -47,6 +47,7 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout() private fun initView() { putClientProperty(FlatClientProperties.TABBED_PANE_TAB_CLOSABLE, false) + putClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE, true) val panel = JPanel(BorderLayout()) panel.add(createSearchPanel(), BorderLayout.NORTH) diff --git a/src/main/kotlin/app/termora/actions/ActionManager.kt b/src/main/kotlin/app/termora/actions/ActionManager.kt index 53b93f0..ad168dc 100644 --- a/src/main/kotlin/app/termora/actions/ActionManager.kt +++ b/src/main/kotlin/app/termora/actions/ActionManager.kt @@ -6,9 +6,9 @@ import app.termora.findeverywhere.FindEverywhereAction import app.termora.highlight.KeywordHighlightAction import app.termora.keymgr.KeyManagerAction import app.termora.macro.MacroAction +import app.termora.sftp.SFTPAction import app.termora.snippet.SnippetAction import app.termora.tlog.TerminalLoggerAction -import app.termora.transport.SFTPAction import javax.swing.Action class ActionManager : org.jdesktop.swingx.action.ActionManager() { diff --git a/src/main/kotlin/app/termora/actions/AppUpdateAction.kt b/src/main/kotlin/app/termora/actions/AppUpdateAction.kt index e2a1572..86de39e 100644 --- a/src/main/kotlin/app/termora/actions/AppUpdateAction.kt +++ b/src/main/kotlin/app/termora/actions/AppUpdateAction.kt @@ -73,6 +73,9 @@ class AppUpdateAction private constructor() : AnAction( } private suspend fun checkUpdate() { + if (Application.isUnknownVersion()) { + return + } val latestVersion = updaterManager.fetchLatestVersion() if (latestVersion.isSelf) { @@ -220,7 +223,10 @@ class AppUpdateAction private constructor() : AnAction( // 没有安装过 则打开安装向导 else listOf(file.absolutePath) - println(commands) + if (log.isInfoEnabled) { + log.info("restart {}", commands.joinToString(StringUtils.SPACE)) + } + TermoraRestarter.getInstance().scheduleRestart(owner, commands) } diff --git a/src/main/kotlin/app/termora/findeverywhere/FindEverywhereProvider.kt b/src/main/kotlin/app/termora/findeverywhere/FindEverywhereProvider.kt index 2105484..3c45c12 100644 --- a/src/main/kotlin/app/termora/findeverywhere/FindEverywhereProvider.kt +++ b/src/main/kotlin/app/termora/findeverywhere/FindEverywhereProvider.kt @@ -5,6 +5,9 @@ import app.termora.Scope interface FindEverywhereProvider { companion object { + + const val SKIP_FIND_EVERYWHERE = "SKIP_FIND_EVERYWHERE" + @Suppress("UNCHECKED_CAST") fun getFindEverywhereProviders(scope: Scope): MutableList { var list = scope.getAnyOrNull("FindEverywhereProviders") diff --git a/src/main/kotlin/app/termora/macro/MacroPtyConnector.kt b/src/main/kotlin/app/termora/macro/MacroPtyConnector.kt index 59b3b21..9f796e9 100644 --- a/src/main/kotlin/app/termora/macro/MacroPtyConnector.kt +++ b/src/main/kotlin/app/termora/macro/MacroPtyConnector.kt @@ -1,17 +1,15 @@ package app.termora.macro import app.termora.Actions - import app.termora.terminal.PtyConnector import app.termora.terminal.PtyConnectorDelegate import org.jdesktop.swingx.action.ActionManager -import java.util.* class MacroPtyConnector(private val connector: PtyConnector) : PtyConnectorDelegate(connector) { private val isRecording get() = ActionManager.getInstance().isSelected(Actions.MACRO) companion object { - private val bytes = LinkedList() + private val bytes = ArrayDeque() fun getRecodingByteArray(): ByteArray { val array = bytes.toByteArray() diff --git a/src/main/kotlin/app/termora/native/FileChooser.kt b/src/main/kotlin/app/termora/native/FileChooser.kt index 77e60ba..fa238ee 100644 --- a/src/main/kotlin/app/termora/native/FileChooser.kt +++ b/src/main/kotlin/app/termora/native/FileChooser.kt @@ -35,6 +35,11 @@ class FileChooser { } else { val fileChooser = JnaFileChooser() fileChooser.isMultiSelectionEnabled = allowsMultiSelection + when (fileSelectionMode) { + JFileChooser.DIRECTORIES_ONLY -> fileChooser.mode = JnaFileChooser.Mode.Directories + JFileChooser.FILES_ONLY -> fileChooser.mode = JnaFileChooser.Mode.Files + JFileChooser.FILES_AND_DIRECTORIES -> fileChooser.mode = JnaFileChooser.Mode.FilesAndDirectories + } fileChooser.setTitle(title) if (defaultDirectory.isNotBlank()) { diff --git a/src/main/kotlin/app/termora/transport/BookmarkButton.kt b/src/main/kotlin/app/termora/sftp/BookmarkButton.kt similarity index 85% rename from src/main/kotlin/app/termora/transport/BookmarkButton.kt rename to src/main/kotlin/app/termora/sftp/BookmarkButton.kt index 78ae5d6..cc04baa 100644 --- a/src/main/kotlin/app/termora/transport/BookmarkButton.kt +++ b/src/main/kotlin/app/termora/sftp/BookmarkButton.kt @@ -1,15 +1,10 @@ -package app.termora.transport +package app.termora.sftp +import app.termora.* import app.termora.Application.ohMyJson -import app.termora.DynamicColor -import app.termora.I18n -import app.termora.Icons -import app.termora.assertEventDispatchThread -import app.termora.Database import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.ui.FlatUIUtils -import kotlinx.serialization.encodeToString import org.apache.commons.lang3.StringUtils import java.awt.* import java.awt.event.ActionEvent @@ -23,6 +18,7 @@ class BookmarkButton : JButton(Icons.bookmarks) { private val properties by lazy { Database.getDatabase().properties } private val arrowWidth = 16 private val arrowSize = 6 + private val button = this /** * 为 true 表示在书签内 @@ -49,13 +45,15 @@ class BookmarkButton : JButton(Icons.bookmarks) { override fun mouseClicked(e: MouseEvent) { if (SwingUtilities.isLeftMouseButton(e)) { if (e.x < oldWidth) { - super@BookmarkButton.fireActionPerformed( - ActionEvent( - this@BookmarkButton, - ActionEvent.ACTION_PERFORMED, - StringUtils.EMPTY + for (listener in actionListeners) { + listener.actionPerformed( + ActionEvent( + button, + ActionEvent.ACTION_PERFORMED, + StringUtils.EMPTY + ) ) - ) + } } else { showBookmarks(e) } @@ -80,13 +78,15 @@ class BookmarkButton : JButton(Icons.bookmarks) { popupMenu.addSeparator() for (bookmark in bookmarks) { popupMenu.add(bookmark).addActionListener { - super@BookmarkButton.fireActionPerformed( - ActionEvent( - this@BookmarkButton, - ActionEvent.ACTION_PERFORMED, - bookmark + for (listener in actionListeners) { + listener.actionPerformed( + ActionEvent( + button, + ActionEvent.ACTION_PERFORMED, + bookmark + ) ) - ) + } } } } @@ -140,7 +140,7 @@ class BookmarkButton : JButton(Icons.bookmarks) { g.color = if (FlatLaf.isLafDark()) Color(206, 208, 214) else Color(108, 112, 126) - g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) FlatUIUtils.paintArrow( g2d, x, preferredSize.height / 2 - arrowSize, arrowWidth, arrowWidth, SwingConstants.SOUTH, false, arrowSize, 0f, 0f, 0f diff --git a/src/main/kotlin/app/termora/transport/BookmarksDialog.kt b/src/main/kotlin/app/termora/sftp/BookmarksDialog.kt similarity index 99% rename from src/main/kotlin/app/termora/transport/BookmarksDialog.kt rename to src/main/kotlin/app/termora/sftp/BookmarksDialog.kt index fc48629..fb029bf 100644 --- a/src/main/kotlin/app/termora/transport/BookmarksDialog.kt +++ b/src/main/kotlin/app/termora/sftp/BookmarksDialog.kt @@ -1,4 +1,4 @@ -package app.termora.transport +package app.termora.sftp import app.termora.DialogWrapper import app.termora.DynamicColor diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt new file mode 100644 index 0000000..ed5f884 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt @@ -0,0 +1,259 @@ +package app.termora.sftp + +import app.termora.Icons +import app.termora.assertEventDispatchThread +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.extras.components.FlatTextField +import org.apache.commons.lang3.StringUtils +import org.slf4j.LoggerFactory +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Graphics +import java.awt.Point +import java.awt.event.ActionEvent +import java.awt.event.ActionListener +import java.awt.event.ItemEvent +import java.awt.event.ItemListener +import java.nio.file.FileSystem +import java.nio.file.Path +import javax.swing.* +import javax.swing.event.PopupMenuEvent +import javax.swing.event.PopupMenuListener +import javax.swing.filechooser.FileSystemView +import kotlin.io.path.absolutePathString + +class FileSystemViewNav( + private val fileSystem: FileSystem, + private val homeDirectory: Path +) : JPanel(BorderLayout()) { + + companion object { + private const val PATH = "path" + private val log = LoggerFactory.getLogger(FileSystemViewNav::class.java) + } + + private val fileSystemView = FileSystemView.getFileSystemView() + private val textField = MyFlatTextField() + private var popupLastTime = 0L + private val history = linkedSetOf() + private val layeredPane = LayeredPane() + private val downBtn = JButton(Icons.chevronDown) + private val comboBox = object : JComboBox() { + override fun getLocationOnScreen(): Point { + val point = super.getLocationOnScreen() + point.y -= 1 + return point + } + } + + init { + initViews() + initEvents() + } + + private fun initViews() { + + comboBox.isEnabled = false + comboBox.putClientProperty("JComboBox.isTableCellEditor", true) + + textField.leadingIcon = NativeFileIcons.getFolderIcon() + textField.trailingComponent = downBtn + textField.text = homeDirectory.absolutePathString() + textField.putClientProperty(PATH, homeDirectory) + + downBtn.putClientProperty( + FlatClientProperties.STYLE, + mapOf( + "toolbar.hoverBackground" to UIManager.getColor("Button.background"), + "toolbar.pressedBackground" to UIManager.getColor("Button.background"), + ) + ) + + comboBox.renderer = object : DefaultListCellRenderer() { + private val indentIcon = IndentIcon() + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + val c = super.getListCellRendererComponent( + list, + value, + index, + isSelected, + cellHasFocus + ) + + indentIcon.depth = 0 + indentIcon.icon = NativeFileIcons.getFolderIcon() + + icon = indentIcon + return c + } + } + + layeredPane.add(comboBox, JLayeredPane.DEFAULT_LAYER as Any) + layeredPane.add(textField, JLayeredPane.PALETTE_LAYER as Any) + add(layeredPane, BorderLayout.CENTER) + + + if (fileSystem.isWindows()) { + try { + for (root in fileSystemView.roots) { + history.add(root.absolutePath) + } + for (rootDirectory in fileSystem.rootDirectories) { + history.add(rootDirectory.absolutePathString()) + } + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + } + } + } + + private fun initEvents() { + + val itemListener = ItemListener { e -> + if (e.stateChange == ItemEvent.SELECTED) { + val item = comboBox.selectedItem + if (item is Path) { + changeSelectedPath(item) + } + } + } + + comboBox.addPopupMenuListener(object : PopupMenuListener { + override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { + comboBox.addItemListener(itemListener) + } + + override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) { + popupLastTime = System.currentTimeMillis() + comboBox.removeItemListener(itemListener) + comboBox.isEnabled = false + textField.requestFocusInWindow() + } + + override fun popupMenuCanceled(e: PopupMenuEvent?) { + } + + }) + + // 监听 Action + addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val text = textField.text.trim() + if (text.isBlank()) return + if (history.contains(text)) return + history.add(text) + } + }) + + downBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + if (System.currentTimeMillis() - popupLastTime < 250) return + comboBox.isEnabled = true + comboBox.requestFocusInWindow() + showComboBoxPopup() + } + }) + + textField.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val name = textField.text.trim() + if (name.isBlank()) return + } + }) + } + + private fun showComboBoxPopup() { + + comboBox.removeAllItems() + + for (text in history) { + val path = fileSystem.getPath(text) + comboBox.addItem(path) + if (text == textField.text) { + comboBox.selectedItem = path + } + } + + comboBox.showPopup() + } + + fun addActionListener(l: ActionListener) { + listenerList.add(ActionListener::class.java, l) + } + + class IndentIcon : Icon { + val space = 10 + var depth: Int = 0 + var icon = NativeFileIcons.getFolderIcon() + + override fun paintIcon(c: Component, g: Graphics, x: Int, y: Int) { + if (c.componentOrientation.isLeftToRight) { + icon.paintIcon(c, g, x + depth * space, y) + } else { + icon.paintIcon(c, g, x, y) + } + } + + override fun getIconWidth(): Int { + return icon.iconWidth + depth * space + } + + override fun getIconHeight(): Int { + return icon.iconHeight + } + } + + fun getSelectedPath(): Path { + return textField.getClientProperty(PATH) as Path + } + + fun changeSelectedPath(path: Path) { + assertEventDispatchThread() + + textField.text = path.absolutePathString() + textField.putClientProperty(PATH, path) + + for (listener in listenerList.getListeners(ActionListener::class.java)) { + listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY)) + } + + } + + @Suppress("UNNECESSARY_SAFE_CALL") + override fun updateUI() { + super.updateUI() + downBtn?.putClientProperty( + FlatClientProperties.STYLE, + mapOf( + "toolbar.hoverBackground" to UIManager.getColor("Button.background"), + "toolbar.pressedBackground" to UIManager.getColor("Button.background"), + ) + ) + } + + class MyFlatTextField : FlatTextField() { + public override fun fireActionPerformed() { + super.fireActionPerformed() + } + } + + + private class LayeredPane : JLayeredPane() { + override fun doLayout() { + synchronized(treeLock) { + for (c in components) { + c.setBounds(0, 0, width, height) + } + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt new file mode 100644 index 0000000..92f0ca8 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt @@ -0,0 +1,468 @@ +package app.termora.sftp + +import app.termora.* +import app.termora.actions.DataProvider +import app.termora.terminal.DataKey +import com.formdev.flatlaf.extras.components.FlatToolBar +import kotlinx.coroutines.* +import kotlinx.coroutines.swing.Swing +import org.apache.commons.lang3.SystemUtils +import org.apache.commons.lang3.exception.ExceptionUtils +import org.apache.sshd.sftp.client.fs.SftpFileSystem +import org.jdesktop.swingx.JXBusyLabel +import java.awt.BorderLayout +import java.awt.event.* +import java.nio.file.FileSystem +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.Consumer +import javax.swing.* +import kotlin.io.path.absolutePathString +import kotlin.io.path.name + +class FileSystemViewPanel( + val host: Host, + val fileSystem: FileSystem, + private val transportManager: TransportManager, + private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) : JPanel(BorderLayout()), Disposable, DataProvider { + + private val properties get() = Database.getDatabase().properties + private val sftp get() = Database.getDatabase().sftp + private val table = FileSystemViewTable(fileSystem, transportManager, coroutineScope) + private val disposed = AtomicBoolean(false) + private var nextReloadTicks = emptyArray>() + private val isLoading = AtomicBoolean(false) + private val owner get() = SwingUtilities.getWindowAncestor(this) + private val loadingPanel = LoadingPanel() + private val layeredPane = LayeredPane() + private val homeDirectory = getHomeDirectory() + private val nav = FileSystemViewNav(fileSystem, homeDirectory) + private var workdir = homeDirectory + private val model get() = table.model as FileSystemViewTableModel + private val showHiddenFilesKey = "termora.transport.host.${host.id}.show-hidden-files" + private var useFileHiding: Boolean + get() = properties.getString(showHiddenFilesKey, "true").toBoolean() + set(value) = properties.putString(showHiddenFilesKey, value.toString()) + + val isDisposed get() = disposed.get() + + init { + initViews() + initEvents() + } + + private fun initViews() { + + val toolbar = FlatToolBar() + toolbar.add(createHomeFolderButton()) + toolbar.add(Box.createHorizontalStrut(2)) + toolbar.add(nav) + toolbar.add(createBookmarkButton()) + toolbar.add(createParentFolderButton()) + toolbar.add(createHiddenFilesButton()) + toolbar.add(createRefreshButton()) + + add(toolbar, BorderLayout.NORTH) + add(layeredPane, BorderLayout.CENTER) + + val scrollPane = JScrollPane(table) + scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor) + layeredPane.add(scrollPane, JLayeredPane.DEFAULT_LAYER as Any) + layeredPane.add(loadingPanel, JLayeredPane.PALETTE_LAYER as Any) + } + + private fun initEvents() { + + Disposer.register(this, table) + + nav.addActionListener { reload() } + + table.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { + enterTableSelectionFolder() + } + } + }) + + table.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER) { + enterTableSelectionFolder() + } + } + }) + + val listener = object : TransportListener, Disposable { + override fun onTransportChanged(transport: Transport) { + val path = transport.target.parent ?: return + if (path.fileSystem != fileSystem) return + if (path.absolutePathString() != workdir.absolutePathString()) return + // 立即刷新 + reload(true) + } + + override fun dispose() { + transportManager.removeTransportListener(this) + } + } + transportManager.addTransportListener(listener) + Disposer.register(this, listener) + + // 变更工作目录 + if (SwingUtilities.isEventDispatchThread()) { + changeWorkdir(homeDirectory) + } else { + SwingUtilities.invokeLater { changeWorkdir(homeDirectory) } + } + + } + + private fun enterTableSelectionFolder(row: Int = table.selectedRow) { + if (row < 0 || isLoading.get()) return + val attr = model.getAttr(row) + if (attr.isFile) return + + // 当前工作目录 + val workdir = getWorkdir() + + // 返回上级之后,选中上级目录 + if (attr.name == "..") { + val workdirName = workdir.name + nextReloadTickSelection(workdirName) + } + + changeWorkdir(attr.path) + + } + + private fun createRefreshButton(): JButton { + val button = JButton(Icons.refresh) + button.addActionListener { reload(true) } + return button + } + + private fun createHiddenFilesButton(): JButton { + val button = JButton(if (useFileHiding) Icons.eyeClose else Icons.eye) + button.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + useFileHiding = !useFileHiding + button.icon = if (useFileHiding) Icons.eyeClose else Icons.eye + reload(true) + } + }) + return button + } + + private fun createHomeFolderButton(): JButton { + val button = JButton(Icons.homeFolder) + button.addActionListener { nav.changeSelectedPath(homeDirectory) } + return button + } + + private fun createBookmarkButton(): JButton { + val bookmarkBtn = BookmarkButton() + bookmarkBtn.name = "Host.${host.id}.Bookmarks" + bookmarkBtn.addActionListener { e -> + if (e.actionCommand.isNullOrBlank()) { + if (bookmarkBtn.isBookmark) { + bookmarkBtn.deleteBookmark(workdir.toString()) + } else { + bookmarkBtn.addBookmark(workdir.toString()) + } + bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark + } else { + changeWorkdir(fileSystem.getPath(e.actionCommand)) + } + } + + nav.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(nav.getSelectedPath().absolutePathString()) + } + }) + + return bookmarkBtn + } + + + private fun createParentFolderButton(): AbstractButton { + val button = JButton(Icons.up) + button.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + if (model.rowCount < 1) return + val attr = model.getAttr(0) + if (attr !is FileSystemViewTableModel.ParentAttr) return + enterTableSelectionFolder(0) + } + }) + + addPropertyChangeListener("workdir") { + button.isEnabled = model.rowCount > 0 && model.getAttr(0) is FileSystemViewTableModel.ParentAttr + } + + return button + } + + private fun nextReloadTickSelection(name: String, consumer: Consumer = Consumer { }) { + // 创建成功之后需要修改和选中 + registerNextReloadTick { + for (i in 0 until table.rowCount) { + if (model.getAttr(i).name == name) { + table.addRowSelectionInterval(i, i) + table.scrollRectToVisible(table.getCellRect(i, 0, true)) + consumer.accept(i) + break + } + } + } + } + + private fun changeWorkdir(workdir: Path) { + assertEventDispatchThread() + nav.changeSelectedPath(workdir) + } + + fun renameTo(oldPath: Path, newPath: Path) { + + // 新建文件夹 + coroutineScope.launch { + if (requestLoading()) { + try { + Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE) + } catch (e: Exception) { + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog( + SwingUtilities.getWindowAncestor(owner), + ExceptionUtils.getMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + } finally { + stopLoading() + } + } + + // 创建成功之后需要选中 + nextReloadTickSelection(newPath.name) + + // 立即刷新 + reload() + } + } + + fun newFolderOrFile(name: String, isFile: Boolean) { + coroutineScope.launch { + if (requestLoading()) { + try { + doNewFolderOrFile(getWorkdir().resolve(name), isFile) + } finally { + stopLoading() + } + } + + // 创建成功之后需要修改和选中 + nextReloadTickSelection(name) + + // 立即刷新 + reload() + } + } + + + private suspend fun doNewFolderOrFile(path: Path, isFile: Boolean) { + + if (Files.exists(path)) { + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog( + owner, + I18n.getString("termora.transport.file-already-exists", path.name), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + return + } + + // 创建文件夹 + withContext(Dispatchers.IO) { + runCatching { if (isFile) Files.createFile(path) else Files.createDirectories(path) }.onFailure { + withContext(Dispatchers.Swing) { + if (it is Exception) { + OptionPane.showMessageDialog( + owner, + ExceptionUtils.getMessage(it), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + } + } + } + + + } + + + fun requestLoading(): Boolean { + if (isLoading.compareAndSet(false, true)) { + if (SwingUtilities.isEventDispatchThread()) { + loadingPanel.start() + } else { + SwingUtilities.invokeLater { loadingPanel.start() } + } + return true + } + return false + } + + fun stopLoading() { + if (isLoading.compareAndSet(true, false)) { + if (SwingUtilities.isEventDispatchThread()) { + loadingPanel.stop() + } else { + SwingUtilities.invokeLater { loadingPanel.stop() } + } + } + } + + fun reload(rememberSelection: Boolean = false) { + if (!requestLoading()) return + if (fileSystem.isSFTP()) loadingPanel.start() + val oldWorkdir = workdir + val path = nav.getSelectedPath() + + coroutineScope.launch { + try { + + if (rememberSelection) { + withContext(Dispatchers.Swing) { + table.selectedRows.sortedDescending().map { model.getAttr(it).name } + .forEach { nextReloadTickSelection(it) } + } + } + + runCatching { model.reload(path, useFileHiding) }.onFailure { + if (it is Exception) { + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog( + owner, ExceptionUtils.getMessage(it), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + } + }.onSuccess { + withContext(Dispatchers.Swing) { + workdir = path + // 触发工作目录变动 + firePropertyChange("workdir", oldWorkdir, workdir) + } + } + + withContext(Dispatchers.Swing) { + // 触发 + triggerNextReloadTicks() + } + + } finally { + stopLoading() + if (fileSystem.isSFTP()) { + withContext(Dispatchers.Swing) { loadingPanel.stop() } + } + } + } + } + + private fun getHomeDirectory(): Path { + if (fileSystem.isSFTP()) { + val fs = fileSystem as SftpFileSystem + val host = fs.session.getAttribute(SshClients.HOST_KEY) ?: return fs.defaultDir + val defaultDirectory = host.options.sftpDefaultDirectory + if (defaultDirectory.isNotBlank()) { + return runCatching { fs.getPath(defaultDirectory) } + .getOrElse { fs.defaultDir } + } + return fs.defaultDir + } + + if (sftp.defaultDirectory.isNotBlank()) { + return runCatching { fileSystem.getPath(sftp.defaultDirectory) } + .getOrElse { fileSystem.getPath(SystemUtils.USER_HOME) } + } + + return fileSystem.getPath(SystemUtils.USER_HOME) + } + + fun getWorkdir(): Path { + return workdir + } + + private fun registerNextReloadTick(consumer: Consumer) { + nextReloadTicks += Consumer { t -> + assertEventDispatchThread() + consumer.accept(t) + } + } + + private fun triggerNextReloadTicks() { + for (nextReloadTick in nextReloadTicks) { + nextReloadTick.accept(Unit) + } + nextReloadTicks = emptyArray() + } + + override fun dispose() { + if (disposed.compareAndSet(false, true)) { + val rootChildren = transportManager.getTransports(0L) + for (child in rootChildren) { + if (child.source.fileSystem == fileSystem || + child.target.fileSystem == fileSystem + ) { + child.changeStatus(TransportStatus.Failed) + } + } + } + } + + @Suppress("UNCHECKED_CAST") + override fun getData(dataKey: DataKey): T? { + return if (dataKey == SFTPDataProviders.FileSystemViewTable) table as T else null + } + + private class LoadingPanel : JPanel() { + private val busyLabel = JXBusyLabel() + + init { + isOpaque = false + border = BorderFactory.createEmptyBorder(50, 0, 0, 0) + + add(busyLabel, BorderLayout.CENTER) + addMouseListener(object : MouseAdapter() {}) + isVisible = false + } + + fun start() { + busyLabel.isBusy = true + isVisible = true + } + + fun stop() { + busyLabel.isBusy = false + isVisible = false + } + } + + private class LayeredPane : JLayeredPane() { + override fun doLayout() { + synchronized(treeLock) { + val w = width + val h = height + for (c in components) { + c.setBounds(0, 0, w, h) + } + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt new file mode 100644 index 0000000..1013b85 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt @@ -0,0 +1,844 @@ +package app.termora.sftp + +import app.termora.* +import app.termora.actions.AnActionEvent +import app.termora.actions.SettingsAction +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.extras.components.FlatPopupMenu +import com.formdev.flatlaf.util.SystemInfo +import kotlinx.coroutines.* +import kotlinx.coroutines.swing.Swing +import org.apache.commons.io.FileUtils +import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.exception.ExceptionUtils +import org.apache.sshd.sftp.client.SftpClient +import org.apache.sshd.sftp.client.fs.SftpFileSystem +import org.apache.sshd.sftp.client.fs.SftpPath +import org.apache.sshd.sftp.client.fs.SftpPosixFileAttributes +import org.jdesktop.swingx.action.ActionManager +import org.slf4j.LoggerFactory +import java.awt.Component +import java.awt.Insets +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException +import java.awt.event.* +import java.io.File +import java.io.IOException +import java.io.OutputStream +import java.nio.file.* +import java.nio.file.attribute.BasicFileAttributes +import java.text.MessageFormat +import java.util.* +import java.util.concurrent.atomic.AtomicBoolean +import java.util.regex.Pattern +import javax.swing.* +import javax.swing.table.DefaultTableCellRenderer +import kotlin.collections.ArrayDeque +import kotlin.io.path.* +import kotlin.time.Duration.Companion.milliseconds + + +@Suppress("DuplicatedCode") +class FileSystemViewTable( + private val fileSystem: FileSystem, + private val transportManager: TransportManager, + private val coroutineScope: CoroutineScope +) : JTable(), Disposable { + + companion object { + private val log = LoggerFactory.getLogger(FileSystemViewTable::class.java) + } + + private val sftp get() = Database.getDatabase().sftp + private val model = FileSystemViewTableModel() + private val table = this + private val owner get() = SwingUtilities.getWindowAncestor(this) + private val sftpPanel + get() = SwingUtilities.getAncestorOfClass(SFTPPanel::class.java, this) + as SFTPPanel + private val fileSystemViewPanel + get() = SwingUtilities.getAncestorOfClass(FileSystemViewPanel::class.java, this) + as FileSystemViewPanel + private val actionManager get() = ActionManager.getInstance() + private val isDisposed = AtomicBoolean(false) + + init { + initViews() + initEvents() + } + + private fun initViews() { + super.setModel(model) + + super.getTableHeader().setReorderingAllowed(false) + super.setDragEnabled(true) + super.setDropMode(DropMode.ON_OR_INSERT_ROWS) + super.setCellSelectionEnabled(false) + super.setRowSelectionAllowed(true) + super.setRowHeight(UIManager.getInt("Table.rowHeight")) + super.setAutoResizeMode(AUTO_RESIZE_OFF) + super.setFillsViewportHeight(true) + super.putClientProperty( + FlatClientProperties.STYLE, mapOf( + "showHorizontalLines" to true, + "showVerticalLines" to true, + "cellMargins" to Insets(0, 4, 0, 4), + ) + ) + + setDefaultRenderer(Any::class.java, object : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: JTable?, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int + ): Component { + foreground = null + val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getAttr(row).icon else null + foreground = if (!isSelected && model.getAttr(row).isHidden) + UIManager.getColor("textInactiveText") else foreground + return c + } + }) + + columnModel.getColumn(FileSystemViewTableModel.COLUMN_NAME).preferredWidth = 250 + columnModel.getColumn(FileSystemViewTableModel.COLUMN_LAST_MODIFIED_TIME).preferredWidth = 130 + + } + + private fun initEvents() { + // contextmenu + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (SwingUtilities.isRightMouseButton(e)) { + val r = table.rowAtPoint(e.point) + if (r >= 0 && r < table.rowCount) { + if (!table.isRowSelected(r)) { + table.setRowSelectionInterval(r, r) + } + } else { + table.clearSelection() + } + + val rows = table.selectedRows + + if (!table.hasFocus()) { + table.requestFocusInWindow() + } + + showContextMenu(rows, e) + } else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { + val row = table.selectedRow + if (row <= 0 || row >= table.rowCount) return + val attr = model.getAttr(row) + if (attr.isDirectory) return + // 传输 + transfer(arrayOf(attr)) + } + } + }) + + + // Delete key + table.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) { + val rows = selectedRows + if (rows.contains(0)) return + val attrs = rows.map { model.getAttr(it) }.toTypedArray() + val files = attrs.map { it.path }.toTypedArray() + deletePaths(files, false) + } else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) { + fileSystemViewPanel.reload(true) + } else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F2) { + renameSelection() + } + } + }) + + table.transferHandler = object : TransferHandler() { + override fun canImport(support: TransferSupport): Boolean { + val dropLocation = support.dropLocation as? JTable.DropLocation ?: return false + // 如果不是新增行,并且光标不在第一列,那么不允许 + if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false + // 如果不是新增行,如果在一个文件上,那么不允许 + if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false + + if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { + val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) + return data is FileSystemTableRowTransferable && data.source != table + } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + return !fileSystem.isLocal() + } + + return false + } + + override fun importData(support: TransferSupport): Boolean { + val dropLocation = support.dropLocation as? JTable.DropLocation ?: return false + // 如果不是新增行,并且光标不在第一列,那么不允许 + if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false + // 如果不是新增行,如果在一个文件上,那么不允许 + if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false + + var targetWorkdir: Path? = null + + // 变更工作目录 + if (!dropLocation.isInsertRow) { + targetWorkdir = model.getAttr(dropLocation.row).path + } + + if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { + val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) + if (data !is FileSystemTableRowTransferable) return false + // 委托源表开始传输 + data.source.transfer(data.attrs.toTypedArray(), false, targetWorkdir) + return true + } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*> + if (files.isEmpty()) return false + val paths = files.filterIsInstance() + .map { FileSystemViewTableModel.Attr(it.toPath()) } + .toTypedArray() + if (paths.isEmpty()) return false + val localTarget = sftpPanel.getLocalTarget() + val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false + // 委托最左侧的本地文件系统传输 + table.transfer(paths, true, targetWorkdir) + return true + } + return false + } + + override fun getSourceActions(c: JComponent?): Int { + return COPY + } + + override fun createTransferable(c: JComponent?): Transferable? { + val attrs = table.selectedRows.filter { it != 0 }.map { model.getAttr(it) } + if (attrs.isEmpty()) return null + return FileSystemTableRowTransferable(table, attrs) + } + } + + // 快速导航 + addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + val c = e.keyChar + val count = model.rowCount + val row = selectedRow + 1 + for (i in row until count) if (navigate(i, c)) return + for (i in 0 until count) if (navigate(i, c)) return + } + + private fun navigate(row: Int, c: Char): Boolean { + val name = model.getAttr(row).name + if (name.startsWith(c, true)) { + clearSelection() + addRowSelectionInterval(row, row) + table.scrollRectToVisible(table.getCellRect(row, 0, true)) + return true + } + return false + } + }) + } + + + override fun dispose() { + if (isDisposed.compareAndSet(false, true)) { + if (!fileSystem.isSFTP()) { + coroutineScope.cancel() + } + } + } + + private fun showContextMenu(rows: IntArray, e: MouseEvent) { + val attrs = rows.map { model.getAttr(it) }.toTypedArray() + val files = attrs.map { it.path }.toTypedArray() + val hasParent = rows.contains(0) + + val popupMenu = FlatPopupMenu() + val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new")) + // 创建文件夹 + val newFolder = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder")) + // 创建文件 + val newFile = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file")) + // 传输 + val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer")) + // 编辑 + val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit")) + edit.isEnabled = fileSystem.isSFTP() && attrs.all { it.isFile } + popupMenu.addSeparator() + // 复制路径 + val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path")) + + // 如果是本地,那么支持打开本地路径 + if (fileSystem.isLocal()) { + popupMenu.add( + I18n.getString( + "termora.transport.table.contextmenu.open-in-folder", + if (SystemInfo.isMacOS) I18n.getString("termora.finder") + else if (SystemInfo.isWindows) I18n.getString("termora.explorer") + else I18n.getString("termora.folder") + ) + ).addActionListener { + Application.browseInFolder(files.last().toFile()) + } + + } + popupMenu.addSeparator() + + // 重命名 + val rename = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.rename")) + + // 删除 + val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete")) + // rm -rf + val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction)) + + // 只有 SFTP 可以 + if (!fileSystem.isSFTP()) { + rmrf.isVisible = false + } + + // 修改权限 + val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions")) + permission.isEnabled = false + + // 如果是本地系统文件,那么不允许修改权限,用户应该自己修改 + if (fileSystem.isSFTP() && rows.isNotEmpty()) { + permission.isEnabled = true + } + popupMenu.addSeparator() + + // 刷新 + val refresh = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.refresh")) + popupMenu.add(refresh) + popupMenu.addSeparator() + + // 新建 + popupMenu.add(newMenu) + + // 新建文件夹 + newFolder.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + newFolderOrFile(false) + } + }) + // 新建文件 + newFile.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + newFolderOrFile(true) + } + }) + rename.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + renameSelection() + } + }) + delete.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + deletePaths(files, false) + } + }) + rmrf.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + deletePaths(files, true) + } + }) + copyPath.addActionListener { + val sb = StringBuilder() + attrs.forEach { sb.append(it.path.absolutePathString()).appendLine() } + sb.deleteCharAt(sb.length - 1) + toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null) + } + edit.addActionListener { if (files.isNotEmpty()) editFiles(files) } + permission.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val last = attrs.last() + val dialog = PosixFilePermissionDialog( + SwingUtilities.getWindowAncestor(table), + last.posixFilePermissions + ) + val permissions = dialog.open() ?: return + + if (fileSystemViewPanel.requestLoading()) { + coroutineScope.launch(Dispatchers.IO) { + val c = runCatching { Files.setPosixFilePermissions(last.path, permissions) }.onFailure { + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog( + owner, + ExceptionUtils.getMessage(it), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + } + + // stop loading + fileSystemViewPanel.stopLoading() + + // reload + if (c.isSuccess) { + fileSystemViewPanel.reload(true) + } + } + } + } + }) + refresh.addActionListener { fileSystemViewPanel.reload() } + transfer.addActionListener { transfer(attrs) } + + if (rows.isEmpty() || hasParent) { + transfer.isEnabled = false + rename.isEnabled = false + delete.isEnabled = false + edit.isEnabled = false + rmrf.isEnabled = false + copyPath.isEnabled = false + permission.isEnabled = false + } else { + transfer.isEnabled = sftpPanel.canTransfer(table) + } + + + popupMenu.show(table, e.x, e.y) + } + + private fun renameSelection() { + val index = selectedRow + if (index < 0) return + val attr = model.getAttr(index) + val dialog = InputDialog( + owner, + title = attr.name, + text = attr.name, + ) + val text = dialog.getText() ?: return + if (text.isBlank() || text == attr.name) return + if (model.getPathNames().contains(text)) { + OptionPane.showMessageDialog( + owner, + I18n.getString("termora.transport.file-already-exists", text), + messageType = JOptionPane.ERROR_MESSAGE + ) + return + } + fileSystemViewPanel.renameTo(attr.path, attr.path.parent.resolve(text)) + } + + private fun editFiles(files: Array) { + if (files.isEmpty()) return + + if (SystemInfo.isLinux) { + if (sftp.editCommand.isBlank()) { + OptionPane.showMessageDialog( + owner, + I18n.getString("termora.transport.table.contextmenu.edit-command"), + messageType = JOptionPane.INFORMATION_MESSAGE + ) + actionManager.getAction(SettingsAction.SETTING) + ?.actionPerformed(AnActionEvent(this, StringUtils.EMPTY, EventObject(this))) + return + } + } + + for (file in files) { + val dir = Application.createSubTemporaryDir() + val path = Paths.get(dir.absolutePathString(), file.name) + + val newTransport = createTransport(file, false, 0L) + .apply { target = path } + + transportManager.addTransportListener(object : TransportListener { + override fun onTransportChanged(transport: Transport) { + if (transport != newTransport) return + if (transport.status != TransportStatus.Done && transport.status != TransportStatus.Failed) return + transportManager.removeTransportListener(this) + if (transport.status != TransportStatus.Done) return + // 监听文件变动 + listenFileChange(path, file) + } + }) + + transportManager.addTransport(newTransport) + + } + } + + private fun listenFileChange(localPath: Path, remotePath: Path) { + try { + if (sftp.editCommand.isNotBlank()) { + ProcessBuilder( + parseCommand( + MessageFormat.format( + sftp.editCommand, + localPath.absolutePathString() + ) + ) + ).start() + } else if (SystemInfo.isMacOS) { + ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start() + } else if (SystemInfo.isWindows) { + ProcessBuilder("notepad", localPath.absolutePathString()).start() + } else { + return + } + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + return + } + + var lastModifiedTime = localPath.getLastModifiedTime().toMillis() + + coroutineScope.launch(Dispatchers.IO) { + while (coroutineScope.isActive) { + try { + if (isDisposed.get() || !Files.exists(localPath)) break + val nowModifiedTime = localPath.getLastModifiedTime().toMillis() + if (nowModifiedTime != lastModifiedTime) { + lastModifiedTime = nowModifiedTime + if (log.isDebugEnabled) { + log.debug("Listening to file {} changes", localPath.absolutePathString()) + } + withContext(Dispatchers.Swing) { + transportManager.addTransport( + createTransport(localPath, false, 0L) + .apply { target = remotePath }) + } + } + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + break + } + + delay(500.milliseconds) + } + + } + } + + private fun parseCommand(command: String): List { + val result = mutableListOf() + val matcher = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(command) + + while (matcher.find()) { + if (matcher.group(1) != null) { + result.add(matcher.group(1)) // 处理双引号部分 + } else { + result.add(matcher.group(2).replace("\\\\ ", " ")) + } + } + return result + } + + private fun newFolderOrFile(isFile: Boolean) { + val name = if (isFile) I18n.getString("termora.transport.table.contextmenu.new.file") + else I18n.getString("termora.welcome.contextmenu.new.folder.name") + val dialog = InputDialog( + owner, + title = name, + text = name, + ) + val text = dialog.getText() ?: return + if (text.isBlank()) return + if (model.getPathNames().contains(text)) { + OptionPane.showMessageDialog( + owner, + I18n.getString("termora.transport.file-already-exists", text), + messageType = JOptionPane.ERROR_MESSAGE + ) + return + } + fileSystemViewPanel.newFolderOrFile(text, isFile) + } + + private fun transfer( + attrs: Array, + fromLocalSystem: Boolean = false, + targetWorkdir: Path? = null + ) { + coroutineScope.launch { + try { + doTransfer(attrs, fromLocalSystem, targetWorkdir) + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + } + } + } + + private fun deletePaths(paths: Array, rm: Boolean = false) { + if (OptionPane.showConfirmDialog( + SwingUtilities.getWindowAncestor(this), + I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"), + messageType = if (rm) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE + ) != JOptionPane.YES_OPTION + ) { + return + } + + if (!fileSystemViewPanel.requestLoading()) { + return + } + + coroutineScope.launch { + + runCatching { + if (fileSystem.isSFTP()) { + deleteSftpPaths(paths, rm) + } else { + deleteRecursively(paths) + } + }.onFailure { + if (log.isErrorEnabled) { + log.error(it.message, it) + } + } + + // 停止加载 + fileSystemViewPanel.stopLoading() + + // 刷新 + fileSystemViewPanel.reload() + + } + } + + private fun deleteSftpPaths(paths: Array, rm: Boolean = false) { + val fs = this.fileSystem as SftpFileSystem + if (rm) { + for (path in paths) { + fs.session.executeRemoteCommand( + "rm -rf '${path.absolutePathString()}'", + OutputStream.nullOutputStream(), + Charsets.UTF_8 + ) + } + } else { + fs.client.use { + for (path in paths) { + deleteRecursivelySFTP(path as SftpPath, it) + } + } + } + } + + private fun deleteRecursively(paths: Array) { + for (path in paths) { + FileUtils.deleteQuietly(path.toFile()) + } + } + + /** + * 优化删除效率,采用一个连接 + */ + private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) { + val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory() + if (isDirectory) { + for (e in sftpClient.readDir(path.toString())) { + if (e.filename == ".." || e.filename == ".") { + continue + } + if (e.attributes.isDirectory) { + deleteRecursivelySFTP(path.resolve(e.filename), sftpClient) + } else { + sftpClient.remove(path.resolve(e.filename).toString()) + } + } + sftpClient.rmdir(path.toString()) + } else { + sftpClient.remove(path.toString()) + } + + } + + + /** + * 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止 + */ + private fun doTransfer( + attrs: Array, + fromLocalSystem: Boolean, + targetWorkdir: Path? + ) { + if (attrs.isEmpty()) return + val sftpPanel = this.sftpPanel + val target = sftpPanel.getTarget(table) ?: return + var isTerminate = false + val queue = ArrayDeque() + + for (attr in attrs) { + + /** + * 定义一个添加器,它可以自动的判断导入/拖拽行为 + */ + val adder = object { + fun add(transport: Transport): Boolean { + return addTransport( + sftpPanel, + if (fromLocalSystem) attr.path.parent else null, + target, + targetWorkdir, + transport + ) + } + } + + if (attr.isFile) { + if (!adder.add(createTransport(attr.path, false, 0).apply { scanned() })) { + isTerminate = true + break + } + continue + } + + queue.clear() + + try { + walk(attr.path, object : FileVisitor { + override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { + val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L) + .apply { queue.addLast(this) } + if (adder.add(transport)) return FileVisitResult.CONTINUE + return FileVisitResult.TERMINATE.apply { isTerminate = true } + } + + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS + val transport = createTransport(file, false, queue.last().id).apply { scanned() } + if (adder.add(transport)) return FileVisitResult.CONTINUE + return FileVisitResult.TERMINATE.apply { isTerminate = true } + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + // 标记为扫描完毕 + queue.removeLast().scanned() + return FileVisitResult.CONTINUE + } + + }) + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + isTerminate = true + } + + if (isTerminate) break + } + + if (isTerminate) { + // 把剩余的文件夹标记为扫描完毕 + while (queue.isNotEmpty()) queue.removeLast().scanned() + } + } + + private fun walk(dir: Path, visitor: FileVisitor) { + if (fileSystem is SftpFileSystem) { + val attr = SftpPosixFileAttributes(dir, SftpClient.Attributes()) + fileSystem.client.use { walkSFTP(dir, attr, visitor, it) } + } else { + Files.walkFileTree(dir, setOf(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, visitor) + } + } + + private fun walkSFTP( + dir: Path, + attr: SftpPosixFileAttributes, + visitor: FileVisitor, + client: SftpClient + ): FileVisitResult { + + if (visitor.preVisitDirectory(dir, attr) == FileVisitResult.TERMINATE) { + return FileVisitResult.TERMINATE + } + + val paths = client.readDir(dir.absolutePathString()) + for (e in paths) { + if (e.filename == ".." || e.filename == ".") continue + if (e.attributes.isDirectory) { + if (walkSFTP(dir.resolve(e.filename), attr, visitor, client) == FileVisitResult.TERMINATE) { + return FileVisitResult.TERMINATE + } + } else { + val result = visitor.visitFile(dir.resolve(e.filename), attr) + if (result == FileVisitResult.TERMINATE) { + return FileVisitResult.TERMINATE + } else if (result == FileVisitResult.SKIP_SUBTREE) { + break + } + } + } + + if (visitor.postVisitDirectory(dir, null) == FileVisitResult.TERMINATE) { + return FileVisitResult.TERMINATE + } + + return FileVisitResult.CONTINUE + } + + private fun addTransport( + sftpPanel: SFTPPanel, + sourceWorkdir: Path?, + target: FileSystemViewPanel, + targetWorkdir: Path?, + transport: Transport + ): Boolean { + return sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport) + } + + private fun createTransport(source: Path, isDirectory: Boolean, parentId: Long): Transport { + val transport = Transport( + source = source, + target = source, + parentId = parentId, + isDirectory = isDirectory, + ) + if (transport.isFile) { + transport.filesize.addAndGet(source.fileSize()) + } + return transport + } + + + private class FileSystemTableRowTransferable( + val source: FileSystemViewTable, + val attrs: List + ) : Transferable { + companion object { + val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable") + } + + override fun getTransferDataFlavors(): Array { + return arrayOf(dataFlavor) + } + + override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean { + return flavor == dataFlavor + } + + override fun getTransferData(flavor: DataFlavor?): Any { + if (flavor != dataFlavor) { + throw UnsupportedFlavorException(flavor) + } + return this + } + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt new file mode 100644 index 0000000..30b31e2 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt @@ -0,0 +1,265 @@ +package app.termora.sftp + +import app.termora.I18n +import app.termora.NativeStringComparator +import app.termora.formatBytes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.swing.Swing +import kotlinx.coroutines.withContext +import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.time.DateFormatUtils +import org.apache.sshd.sftp.client.fs.SftpPath +import org.slf4j.LoggerFactory +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.PosixFilePermissions +import java.util.* +import javax.swing.table.DefaultTableModel +import kotlin.io.path.* + +class FileSystemViewTableModel : DefaultTableModel() { + + + companion object { + const val COLUMN_NAME = 0 + const val COLUMN_TYPE = 1 + const val COLUMN_FILE_SIZE = 2 + const val COLUMN_LAST_MODIFIED_TIME = 3 + const val COLUMN_ATTRS = 4 + const val COLUMN_OWNER = 5 + private val log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java) + + private fun fromSftpPermissions(sftpPermissions: Int): Set { + val result = mutableSetOf() + + // 将十进制权限转换为八进制字符串 + val octalPermissions = sftpPermissions.toString(8) + + // 仅取后三位权限部分 + if (octalPermissions.length < 3) { + return result + } + + val permissionBits = octalPermissions.takeLast(3) + + // 解析每一部分的权限 + val owner = permissionBits[0].digitToInt() + val group = permissionBits[1].digitToInt() + val others = permissionBits[2].digitToInt() + + // 处理所有者权限 + if ((owner and 4) != 0) result.add(PosixFilePermission.OWNER_READ) + if ((owner and 2) != 0) result.add(PosixFilePermission.OWNER_WRITE) + if ((owner and 1) != 0) result.add(PosixFilePermission.OWNER_EXECUTE) + + // 处理组权限 + if ((group and 4) != 0) result.add(PosixFilePermission.GROUP_READ) + if ((group and 2) != 0) result.add(PosixFilePermission.GROUP_WRITE) + if ((group and 1) != 0) result.add(PosixFilePermission.GROUP_EXECUTE) + + // 处理其他用户权限 + if ((others and 4) != 0) result.add(PosixFilePermission.OTHERS_READ) + if ((others and 2) != 0) result.add(PosixFilePermission.OTHERS_WRITE) + if ((others and 1) != 0) result.add(PosixFilePermission.OTHERS_EXECUTE) + + return result + } + } + + override fun getValueAt(row: Int, column: Int): Any { + val attr = getAttr(row) + return when (column) { + COLUMN_NAME -> attr.name + COLUMN_FILE_SIZE -> if (attr.isDirectory) StringUtils.EMPTY else formatBytes(attr.size) + COLUMN_TYPE -> attr.type + COLUMN_LAST_MODIFIED_TIME -> if (attr.modified > 0) DateFormatUtils.format( + Date(attr.modified), + "yyyy/MM/dd HH:mm" + ) else StringUtils.EMPTY + + COLUMN_ATTRS -> attr.permissions + COLUMN_OWNER -> attr.owner + else -> StringUtils.EMPTY + } + } + + override fun getDataVector(): Vector> { + return super.getDataVector() + } + + override fun getColumnCount(): Int { + return 6 + } + + override fun getColumnClass(columnIndex: Int): Class<*> { + return when (columnIndex) { + COLUMN_NAME -> String::class.java + else -> super.getColumnClass(columnIndex) + } + } + + fun getAttr(row: Int): Attr { + return super.getValueAt(row, 0) as Attr + } + + fun getPathNames(): Set { + val names = linkedSetOf() + for (i in 0 until rowCount) { + names.add(getAttr(i).name) + } + return names + } + + + override fun getColumnName(column: Int): String { + return when (column) { + COLUMN_NAME -> I18n.getString("termora.transport.table.filename") + COLUMN_FILE_SIZE -> I18n.getString("termora.transport.table.size") + COLUMN_TYPE -> I18n.getString("termora.transport.table.type") + COLUMN_LAST_MODIFIED_TIME -> I18n.getString("termora.transport.table.modified-time") + COLUMN_ATTRS -> I18n.getString("termora.transport.table.permissions") + COLUMN_OWNER -> I18n.getString("termora.transport.table.owner") + else -> StringUtils.EMPTY + } + } + + override fun isCellEditable(row: Int, column: Int): Boolean { + return false + } + + suspend fun reload(dir: Path, useFileHiding: Boolean) { + + if (log.isDebugEnabled) { + log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding) + } + + val attrs = mutableListOf() + if (dir.parent != null) { + attrs.add(ParentAttr(dir.parent)) + } + + withContext(Dispatchers.IO) { + Files.list(dir).use { paths -> + for (path in paths) { + val attr = if (path is SftpPath) SftpAttr(path) else Attr(path) + if (useFileHiding && attr.isHidden) continue + attrs.add(attr) + } + } + } + + attrs.sortWith(compareBy { !it.isDirectory }.thenComparing { a, b -> + NativeStringComparator.getInstance().compare( + a.name, + b.name + ) + }) + + withContext(Dispatchers.Swing) { + while (rowCount > 0) removeRow(0) + attrs.forEach { addRow(arrayOf(it)) } + } + + } + + + open class Attr(val path: Path) { + + /** + * 名称 + */ + open val name by lazy { path.name } + + /** + * 文件类型 + */ + open val type by lazy { NativeFileIcons.getIcon(name, isFile).second } + + /** + * 大小 + */ + open val size by lazy { path.fileSize() } + + /** + * 修改时间 + */ + open val modified by lazy { path.getLastModifiedTime().toMillis() } + + /** + * 获取所有者 + */ + open val owner by lazy { StringUtils.EMPTY } + + /** + * 获取操作系统图标 + */ + open val icon by lazy { NativeFileIcons.getIcon(name, isFile).first } + + /** + * 是否是文件夹 + */ + open val isDirectory by lazy { path.isDirectory() } + + /** + * 是否是文件 + */ + open val isFile by lazy { !isDirectory } + + /** + * 是否是文件夹 + */ + open val isHidden by lazy { path.isHidden() } + + /** + * 获取权限 + */ + open val permissions: String by lazy { + posixFilePermissions.let { + if (it.isNotEmpty()) PosixFilePermissions.toString( + it + ) else StringUtils.EMPTY + } + } + open val posixFilePermissions by lazy { if (path.fileSystem.isUnix()) path.getPosixFilePermissions() else emptySet() } + + open fun toFile(): File { + if (path.fileSystem.isSFTP()) { + return File(path.absolutePathString()) + } + return path.toFile() + } + } + + class ParentAttr(path: Path) : Attr(path) { + override val name by lazy { ".." } + override val isDirectory = true + override val isFile = false + override val isHidden = false + override val permissions = StringUtils.EMPTY + override val modified = 0L + override val type = StringUtils.EMPTY + override val icon by lazy { NativeFileIcons.getFolderIcon() } + + } + + + class SftpAttr(sftpPath: SftpPath) : Attr(sftpPath) { + private val attributes = sftpPath.attributes + + override val isDirectory = attributes.isDirectory + override val isHidden = name.startsWith(".") + override val size = attributes.size + override val owner: String = StringUtils.defaultString(attributes.owner) + override val modified = attributes.modifyTime.toMillis() + override val permissions: String = PosixFilePermissions.toString(fromSftpPermissions(attributes.permissions)) + override val posixFilePermissions = fromSftpPermissions(attributes.permissions) + + override fun toFile(): File { + return File(path.absolutePathString()) + } + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/NativeFileIcons.kt b/src/main/kotlin/app/termora/sftp/NativeFileIcons.kt new file mode 100644 index 0000000..b3de0b5 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/NativeFileIcons.kt @@ -0,0 +1,81 @@ +package app.termora.sftp + +import app.termora.Application +import app.termora.I18n +import com.formdev.flatlaf.icons.FlatTreeClosedIcon +import com.formdev.flatlaf.icons.FlatTreeLeafIcon +import com.formdev.flatlaf.util.SystemInfo +import org.apache.commons.io.FileUtils +import org.apache.commons.io.FilenameUtils +import org.apache.commons.lang3.SystemUtils +import org.eclipse.jgit.util.LRUMap +import java.util.* +import javax.swing.Icon +import javax.swing.filechooser.FileSystemView.getFileSystemView + +object NativeFileIcons { + + /** + * key: filename , value: + */ + private val cache = LRUMap>(16, 512) + private val folderIcon = FlatTreeClosedIcon() + private val fileIcon = FlatTreeLeafIcon() + + init { + if (SystemUtils.IS_OS_UNIX) { + cache[SystemUtils.USER_HOME] = Pair(FlatTreeClosedIcon(), I18n.getString("termora.folder")) + } + } + + fun getFolderIcon(): Icon { + return getIcon(UUID.randomUUID().toString(), false).first + } + + fun getFileIcon(filename: String): Icon { + return getIcon(filename, true).first + } + + fun getIcon(filename: String, isFile: Boolean = true): Pair { + if (isFile) { + val extension = FilenameUtils.getExtension(filename) + if (cache.containsKey(extension)) { + return cache.getValue(extension) + } + } else { + if (cache.containsKey(SystemUtils.USER_HOME)) { + return cache.getValue(SystemUtils.USER_HOME) + } + } + + val isDirectory = !isFile + + if (SystemInfo.isWindows) { + val file = if (isDirectory) FileUtils.getFile(SystemUtils.USER_HOME) else + FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}.${filename}") + if (isFile && !file.exists()) { + file.createNewFile() + } + val icon = getFileSystemView().getSystemIcon(file, 16, 16) + val description = getFileSystemView().getSystemTypeDescription(file) + val pair = icon to description + + if (isDirectory) { + cache[SystemUtils.USER_HOME] = pair + } else { + cache[FilenameUtils.getExtension(file.name)] = pair + } + + if (isFile) FileUtils.deleteQuietly(file) + + return pair + } + + return Pair( + if (isDirectory) folderIcon else fileIcon, + if (isDirectory) I18n.getString("termora.folder") else FilenameUtils.getExtension(filename) + ) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/PosixFilePermissionDialog.kt b/src/main/kotlin/app/termora/sftp/PosixFilePermissionDialog.kt similarity index 99% rename from src/main/kotlin/app/termora/transport/PosixFilePermissionDialog.kt rename to src/main/kotlin/app/termora/sftp/PosixFilePermissionDialog.kt index f3dfce7..0f8cf30 100644 --- a/src/main/kotlin/app/termora/transport/PosixFilePermissionDialog.kt +++ b/src/main/kotlin/app/termora/sftp/PosixFilePermissionDialog.kt @@ -1,4 +1,4 @@ -package app.termora.transport +package app.termora.sftp import app.termora.DialogWrapper import app.termora.I18n diff --git a/src/main/kotlin/app/termora/sftp/SFTPAction.kt b/src/main/kotlin/app/termora/sftp/SFTPAction.kt new file mode 100644 index 0000000..8578c96 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/SFTPAction.kt @@ -0,0 +1,62 @@ +package app.termora.sftp + +import app.termora.HostManager +import app.termora.HostTerminalTab +import app.termora.Icons +import app.termora.Protocol +import app.termora.actions.AnAction +import app.termora.actions.AnActionEvent +import app.termora.actions.DataProviders +import org.apache.commons.lang3.StringUtils + +class SFTPAction : AnAction("SFTP", Icons.folder) { + private val hostManager get() = HostManager.getInstance() + + override fun actionPerformed(evt: AnActionEvent) { + + val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return + var sftpTab: SFTPTab? = null + for (tab in terminalTabbedManager.getTerminalTabs()) { + if (tab is SFTPTab) { + sftpTab = tab + break + } + } + + // 创建一个新的 + if (sftpTab == null) { + sftpTab = SFTPTab() + terminalTabbedManager.addTerminalTab(sftpTab, false) + } + + var hostId = if (evt is SFTPActionEvent) evt.hostId else StringUtils.EMPTY + + // 如果不是特定事件,那么尝试获取选中的Tab,如果是一个 Host 并且是 SSH 协议那么直接打开 + if (hostId.isBlank()) { + val tab = terminalTabbedManager.getSelectedTerminalTab() + if (tab is HostTerminalTab) { + if (tab.host.protocol == Protocol.SSH || tab.host.protocol == Protocol.SFTPPty) { + hostId = tab.host.id + } + } + } + + terminalTabbedManager.setSelectedTerminalTab(sftpTab) + + if (hostId.isBlank()) return + + val tabbed = sftpTab.getData(SFTPDataProviders.RightSFTPTabbed) ?: return + // 如果已经打开了 那么直接选中 + for (i in 0 until tabbed.tabCount) { + val fileSystemViewPanel = tabbed.getFileSystemViewPanel(i) ?: continue + if (fileSystemViewPanel.host.id == hostId) { + tabbed.selectedIndex = i + return + } + } + + val host = hostManager.getHost(hostId) ?: return + tabbed.addSFTPFileSystemViewPanelTab(host) + + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPActionEvent.kt b/src/main/kotlin/app/termora/sftp/SFTPActionEvent.kt new file mode 100644 index 0000000..ed813bf --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/SFTPActionEvent.kt @@ -0,0 +1,11 @@ +package app.termora.sftp + +import app.termora.actions.AnActionEvent +import org.apache.commons.lang3.StringUtils +import java.util.* + +class SFTPActionEvent( + source: Any, + val hostId: String, + event: EventObject +) : AnActionEvent(source, StringUtils.EMPTY, event) \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPDataProviders.kt b/src/main/kotlin/app/termora/sftp/SFTPDataProviders.kt new file mode 100644 index 0000000..218486b --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/SFTPDataProviders.kt @@ -0,0 +1,12 @@ +package app.termora.sftp + +import app.termora.terminal.DataKey + +object SFTPDataProviders { + val TransportManager = DataKey(app.termora.sftp.TransportManager::class) + val FileSystemViewPanel = DataKey(app.termora.sftp.FileSystemViewPanel::class) + val CoroutineScope = DataKey(kotlinx.coroutines.CoroutineScope::class) + val FileSystemViewTable = DataKey(app.termora.sftp.FileSystemViewTable::class) + val LeftSFTPTabbed = DataKey(SFTPTabbed::class) + val RightSFTPTabbed = DataKey(SFTPTabbed::class) +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt b/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt similarity index 66% rename from src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt rename to src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt index 229257b..527f6df 100644 --- a/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt +++ b/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt @@ -1,11 +1,9 @@ -package app.termora.transport +package app.termora.sftp import app.termora.* -import app.termora.actions.AnAction -import app.termora.actions.AnActionEvent -import app.termora.keyboardinteractive.TerminalUserInteraction +import app.termora.actions.DataProvider +import app.termora.terminal.DataKey import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon -import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout import kotlinx.coroutines.* @@ -23,15 +21,18 @@ import org.slf4j.LoggerFactory import java.awt.BorderLayout import java.awt.CardLayout import java.awt.event.ActionEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import java.util.concurrent.atomic.AtomicBoolean import javax.swing.* -class SftpFileSystemPanel( - var host: Host? = null -) : JPanel(BorderLayout()), Disposable { +class SFTPFileSystemViewPanel( + var host: Host? = null, + private val transportManager: TransportManager, +) : JPanel(BorderLayout()), Disposable, DataProvider { companion object { - private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java) + private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java) private enum class State { Initialized, @@ -50,11 +51,14 @@ class SftpFileSystemPanel( private val selectHostPanel = SelectHostPanel() private val connectFailedPanel = ConnectFailedPanel() private val isDisposed = AtomicBoolean(false) + private val that = this + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val properties get() = Database.getDatabase().properties private var client: SshClient? = null private var session: ClientSession? = null private var fileSystem: SftpFileSystem? = null - var fileSystemPanel: FileSystemPanel? = null + private var fileSystemPanel: FileSystemViewPanel? = null init { @@ -71,12 +75,11 @@ class SftpFileSystemPanel( } private fun initEvents() { - + Disposer.register(this, selectHostPanel) } - @OptIn(DelicateCoroutinesApi::class) fun connect() { - GlobalScope.launch(Dispatchers.IO) { + coroutineScope.launch { if (state != State.Connecting) { state = State.Connecting @@ -100,42 +103,17 @@ class SftpFileSystemPanel( connectingPanel.stop() } } - } } private suspend fun doConnect() { - val thisHost = this.host ?: return - var host = thisHost.copy(authentication = thisHost.authentication.copy(), updateDate = System.currentTimeMillis()) closeIO() try { - val client = SshClients.openClient(host).apply { client = this } - withContext(Dispatchers.Swing) { - val owner = SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel) - client.userInteraction = TerminalUserInteraction(owner) - client.serverKeyVerifier = DialogServerKeyVerifier(owner) - // 弹出授权框 - if (host.authentication.type == AuthenticationType.No) { - val dialog = RequestAuthenticationDialog(owner, host) - val authentication = dialog.getAuthentication() - host = host.copy( - authentication = authentication, - username = dialog.getUsername(), updateDate = System.currentTimeMillis(), - ) - // save - if (dialog.isRemembered()) { - HostManager.getInstance().addHost( - host.copy( - authentication = authentication, - username = dialog.getUsername(), updateDate = System.currentTimeMillis(), - ) - ) - } - } - } + val (client, host) = SshClients.openClient(thisHost, SwingUtilities.getWindowAncestor(that)) + this.client = client val session = SshClients.openSession(host, client).apply { session = this } fileSystem = SftpClientFactory.instance().createSftpFileSystem(session) session.addCloseFutureListener { onClose() } @@ -152,18 +130,10 @@ class SftpFileSystemPanel( withContext(Dispatchers.Swing) { state = State.Connected - - val fileSystemPanel = FileSystemPanel(fileSystem, host) - + val fileSystemPanel = FileSystemViewPanel(thisHost, fileSystem, transportManager, coroutineScope) cardPanel.add(fileSystemPanel, State.Connected.name) cardLayout.show(cardPanel, State.Connected.name) - - firePropertyChange("TabName", StringUtils.EMPTY, host.name) - - this@SftpFileSystemPanel.fileSystemPanel = fileSystemPanel - - // 立即加载 - fileSystemPanel.reload() + that.fileSystemPanel = fileSystemPanel } } @@ -199,6 +169,7 @@ class SftpFileSystemPanel( override fun dispose() { if (isDisposed.compareAndSet(false, true)) { closeIO() + coroutineScope.cancel() } } @@ -269,7 +240,7 @@ class SftpFileSystemPanel( AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) { override fun actionPerformed(e: ActionEvent) { state = State.Initialized - this@SftpFileSystemPanel.firePropertyChange("TabName", StringUtils.SPACE, StringUtils.EMPTY) + that.setTabTitle(I18n.getString("termora.transport.sftp.select-host")) cardLayout.show(cardPanel, State.Initialized.name) } }).apply { @@ -281,44 +252,65 @@ class SftpFileSystemPanel( } } - private inner class SelectHostPanel : JPanel(BorderLayout()) { + private inner class SelectHostPanel : JPanel(BorderLayout()), Disposable { + private val tree = NewHostTree() + init { initView() + initEvents() } private fun initView() { - val formMargin = "4dlu" - val layout = FormLayout( - "default:grow, pref, default:grow", - "40dlu, pref, $formMargin, pref, $formMargin, pref" - ) + tree.contextmenu = false + tree.dragEnabled = false + tree.doubleClickConnection = false + val scrollPane = JScrollPane(tree) + scrollPane.border = BorderFactory.createEmptyBorder(4, 4, 4, 4) + add(scrollPane, BorderLayout.CENTER) - val errorInfo = JLabel(I18n.getString("termora.transport.sftp.connect-a-host")) - errorInfo.horizontalAlignment = SwingConstants.CENTER + TreeUtils.loadExpansionState(tree, properties.getString("SFTPTabbed.Tree.state", StringUtils.EMPTY)) + } - val builder = FormBuilder.create().layout(layout).debug(false) - builder.add(FlatOptionPaneInformationIcon()).xy(2, 2) - builder.add(errorInfo).xyw(1, 4, 3, "fill, center") - builder.add(JXHyperlink(object : AnAction(I18n.getString("termora.transport.sftp.select-host")) { - override fun actionPerformed(evt: AnActionEvent) { - val dialog = NewHostTreeDialog(evt.window) - dialog.setFilter { it.host.protocol == Protocol.SSH } - dialog.setTreeName("SftpFileSystemPanel.SelectHostTree") - dialog.allowMulti = false - dialog.setLocationRelativeTo(this@SelectHostPanel) - dialog.isVisible = true - this@SftpFileSystemPanel.host = dialog.hosts.firstOrNull() ?: return - connect() + private fun initEvents() { + tree.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { + val node = tree.getLastSelectedPathNode() ?: return + if (node.isFolder) return + val host = node.data as Host + that.setTabTitle(host.name) + that.host = host + that.connect() + } } - }).apply { - horizontalAlignment = SwingConstants.CENTER - verticalAlignment = SwingConstants.CENTER - isFocusable = false - }).xy(2, 6) - add(builder.build(), BorderLayout.CENTER) + }) + } + + override fun dispose() { + properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree)) } } + @Suppress("UNCHECKED_CAST") + override fun getData(dataKey: DataKey): T? { + return when (dataKey) { + SFTPDataProviders.FileSystemViewPanel -> fileSystemPanel as T? + SFTPDataProviders.CoroutineScope -> coroutineScope as T? + else -> null + } + } + + private fun setTabTitle(title: String) { + val tabbed = SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, that) + if (tabbed is JTabbedPane) { + for (i in 0 until tabbed.tabCount) { + if (tabbed.getComponentAt(i) == that) { + tabbed.setTitleAt(i, title) + break + } + } + } + } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPPanel.kt b/src/main/kotlin/app/termora/sftp/SFTPPanel.kt new file mode 100644 index 0000000..ab5f38e --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/SFTPPanel.kt @@ -0,0 +1,215 @@ +package app.termora.sftp + +import app.termora.* +import app.termora.actions.DataProvider +import app.termora.actions.DataProviderSupport +import app.termora.findeverywhere.FindEverywhereProvider +import app.termora.terminal.DataKey +import okio.withLock +import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.SystemUtils +import org.apache.sshd.sftp.client.fs.SftpFileSystem +import java.awt.BorderLayout +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.nio.file.FileSystem +import java.nio.file.FileSystems +import java.nio.file.Path +import javax.swing.* +import kotlin.io.path.absolutePathString + +fun FileSystem.isSFTP() = this is SftpFileSystem +fun FileSystem.isUnix() = isLocal() && SystemUtils.IS_OS_UNIX +fun FileSystem.isWindows() = isLocal() && SystemUtils.IS_OS_WINDOWS +fun FileSystem.isLocal() = StringUtils.startsWithAny(javaClass.name, "java", "sun") + +class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable { + + private val transportTable = TransportTable() + private val transportManager get() = transportTable.model + private val dataProviderSupport = DataProviderSupport() + private val leftComponent = SFTPTabbed(transportManager) + private val rightComponent = SFTPTabbed(transportManager) + + init { + initViews() + initEvents() + FileSystems.getDefault() + } + + private fun initViews() { + + putClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE, true) + + val splitPane = JSplitPane() + splitPane.resizeWeight = 0.5 + splitPane.leftComponent = leftComponent + splitPane.rightComponent = rightComponent + splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor) + splitPane.addComponentListener(object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) { + removeComponentListener(this) + splitPane.setDividerLocation(splitPane.resizeWeight) + } + }) + + leftComponent.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor) + rightComponent.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor) + + val rootSplitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT) + val scrollPane = JScrollPane(transportTable) + scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor) + + rootSplitPane.resizeWeight = 0.7 + rootSplitPane.topComponent = splitPane + rootSplitPane.bottomComponent = scrollPane + rootSplitPane.addComponentListener(object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) { + removeComponentListener(this) + rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight) + } + }) + + add(rootSplitPane, BorderLayout.CENTER) + } + + private fun initEvents() { + Disposer.register(this, leftComponent) + Disposer.register(this, rightComponent) + Disposer.register(this, transportTable) + + dataProviderSupport.addData(SFTPDataProviders.TransportManager, transportManager) + dataProviderSupport.addData(SFTPDataProviders.LeftSFTPTabbed, leftComponent) + dataProviderSupport.addData(SFTPDataProviders.RightSFTPTabbed, rightComponent) + + + // default tab + leftComponent.addTab( + I18n.getString("termora.transport.local"), + FileSystemViewPanel( + Host( + id = "local", + name = I18n.getString("termora.transport.local"), + protocol = Protocol.Local, + ), FileSystems.getDefault(), transportManager + ) + ) + leftComponent.setTabClosable(0, false) + + + // default tab + rightComponent.addTab( + I18n.getString("termora.transport.sftp.select-host"), + SFTPFileSystemViewPanel(transportManager = transportManager) + ) + + rightComponent.addChangeListener { + if (rightComponent.tabCount == 0 && !rightComponent.isDisposed) { + rightComponent.addTab( + I18n.getString("termora.transport.sftp.select-host"), + SFTPFileSystemViewPanel(transportManager = transportManager) + ) + } + } + + leftComponent.setTabCloseCallback { _, index -> tabCloseCallback(leftComponent, index) } + rightComponent.setTabCloseCallback { _, index -> tabCloseCallback(rightComponent, index) } + } + + private fun tabCloseCallback(tabbed: SFTPTabbed, index: Int) { + assertEventDispatchThread() + + val c = tabbed.getFileSystemViewPanel(index) + if (c == null) { + tabbed.removeTabAt(index) + return + } + + val fs = c.fileSystem + val root = transportManager.root + + transportManager.lock.withLock { + val deletedIds = mutableListOf() + for (i in 0 until root.childCount) { + val child = root.getChildAt(i) as? TransportTreeTableNode ?: continue + if (child.transport.source.fileSystem == fs || + child.transport.target.fileSystem == fs + ) { + deletedIds.add(child.transport.id) + } + } + + if (deletedIds.isNotEmpty()) { + if (OptionPane.showConfirmDialog( + SwingUtilities.getWindowAncestor(this), + I18n.getString("termora.transport.sftp.close-tab"), + messageType = JOptionPane.QUESTION_MESSAGE, + optionType = JOptionPane.OK_CANCEL_OPTION + ) != JOptionPane.OK_OPTION + ) { + return + } + } + + deletedIds.forEach { transportManager.removeTransport(it) } + } + + + // 删除并销毁 + tabbed.removeTabAt(index) + + } + + /** + * 返回失败表示没有创建成功 + */ + fun addTransport( + source: JComponent, + sourceWorkdir: Path?, + target: FileSystemViewPanel, + targetWorkdir: Path?, + transport: Transport + ): Boolean { + + val sourcePanel = SwingUtilities.getAncestorOfClass(FileSystemViewPanel::class.java, source) + as? FileSystemViewPanel ?: return false + val targetPanel = target as? FileSystemViewPanel ?: return false + if (sourcePanel.isDisposed || targetPanel.isDisposed) return false + val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir()).absolutePathString() + val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir()).absolutePathString() + val targetFileSystem = targetPanel.fileSystem + val sourcePath = transport.source.absolutePathString() + + transport.target = targetFileSystem.getPath( + myTargetWorkdir, + StringUtils.removeStart(sourcePath, mySourceWorkdir) + ) + + return transportManager.addTransport(transport) + + } + + fun canTransfer(source: JComponent): Boolean { + return getTarget(source) != null + } + + fun getTarget(source: JComponent): FileSystemViewPanel? { + val sourceTabbed = SwingUtilities.getAncestorOfClass(SFTPTabbed::class.java, source) + as? SFTPTabbed ?: return null + val isLeft = sourceTabbed == leftComponent + val targetTabbed = if (isLeft) rightComponent else leftComponent + return targetTabbed.getSelectedFileSystemViewPanel() + } + + /** + * 获取本地文件系统面板 + */ + fun getLocalTarget(): FileSystemViewPanel { + return leftComponent.getFileSystemViewPanel(0) as FileSystemViewPanel + } + + override fun getData(dataKey: DataKey): T? { + return dataProviderSupport.getData(dataKey) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPTab.kt b/src/main/kotlin/app/termora/sftp/SFTPTab.kt new file mode 100644 index 0000000..b67b419 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/SFTPTab.kt @@ -0,0 +1,82 @@ +package app.termora.sftp + +import app.termora.* +import app.termora.terminal.DataKey +import java.beans.PropertyChangeListener +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JOptionPane +import javax.swing.SwingUtilities + +class SFTPTab : RememberFocusTerminalTab() { + private val sftpPanel = SFTPPanel() + private val sftp get() = Database.getDatabase().sftp + + init { + Disposer.register(this, sftpPanel) + } + + override fun getTitle(): String { + return "SFTP" + } + + override fun getIcon(): Icon { + return Icons.folder + } + + override fun addPropertyChangeListener(listener: PropertyChangeListener) { + } + + override fun removePropertyChangeListener(listener: PropertyChangeListener) { + } + + override fun canClose(): Boolean { + return !sftp.pinTab + } + + override fun willBeClose(): Boolean { + if (!canClose()) return false + + val transportManager = sftpPanel.getData(SFTPDataProviders.TransportManager) ?: return true + if (transportManager.getTransportCount() > 0) { + return OptionPane.showConfirmDialog( + SwingUtilities.getWindowAncestor(getJComponent()), + I18n.getString("termora.transport.sftp.close-tab"), + messageType = JOptionPane.QUESTION_MESSAGE, + optionType = JOptionPane.OK_CANCEL_OPTION + ) == JOptionPane.OK_OPTION + } + + val leftTabbed = sftpPanel.getData(SFTPDataProviders.LeftSFTPTabbed) ?: return true + val rightTabbed = sftpPanel.getData(SFTPDataProviders.RightSFTPTabbed) ?: return true + if (hasActiveTab(leftTabbed) || hasActiveTab(rightTabbed)) { + return OptionPane.showConfirmDialog( + SwingUtilities.getWindowAncestor(getJComponent()), + I18n.getString("termora.transport.sftp.close-tab-has-active-session"), + messageType = JOptionPane.QUESTION_MESSAGE, + optionType = JOptionPane.OK_CANCEL_OPTION + ) == JOptionPane.OK_OPTION + } + + + return true + } + + private fun hasActiveTab(tabbed: SFTPTabbed): Boolean { + for (i in 0 until tabbed.tabCount) { + val c = tabbed.getFileSystemViewPanel(i) ?: continue + if (c.host.id != "local") { + return true + } + } + return false + } + + override fun getJComponent(): JComponent { + return sftpPanel + } + + override fun getData(dataKey: DataKey): T? { + return sftpPanel.getData(dataKey) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPTabbed.kt b/src/main/kotlin/app/termora/sftp/SFTPTabbed.kt new file mode 100644 index 0000000..049824b --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/SFTPTabbed.kt @@ -0,0 +1,116 @@ +package app.termora.sftp + +import app.termora.* +import app.termora.actions.AnAction +import app.termora.actions.AnActionEvent +import com.formdev.flatlaf.extras.components.FlatTabbedPane +import java.awt.Point +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.JButton +import javax.swing.JToolBar +import javax.swing.SwingUtilities +import javax.swing.UIManager +import kotlin.math.max + +@Suppress("DuplicatedCode") +class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPane(), Disposable { + private val addBtn = JButton(Icons.add) + private val tabbed = this + private val disposed = AtomicBoolean(false) + + val isDisposed get() = disposed.get() + + init { + initViews() + initEvents() + } + + private fun initViews() { + super.setTabLayoutPolicy(SCROLL_TAB_LAYOUT) + super.setTabsClosable(true) + super.setTabType(TabType.underlined) + super.setStyleMap( + mapOf( + "focusColor" to UIManager.getColor("TabbedPane.selectedBackground"), + "hoverColor" to UIManager.getColor("TabbedPane.background"), + "tabHeight" to 30 + ) + ) + + + val toolbar = JToolBar() + toolbar.add(addBtn) + super.setTrailingComponent(toolbar) + + } + + private fun initEvents() { + addBtn.addActionListener(object : AnAction() { + override fun actionPerformed(evt: AnActionEvent) { + val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(tabbed)) + dialog.location = Point( + max(0, addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2), + addBtn.locationOnScreen.y + max(tabHeight, addBtn.height) + ) + dialog.setFilter { it.host.protocol == Protocol.SSH } + dialog.setTreeName("SFTPTabbed.Tree") + dialog.allowMulti = true + dialog.isVisible = true + + val hosts = dialog.hosts + if (hosts.isEmpty()) return + + for (host in hosts) { + addSFTPFileSystemViewPanelTab(host) + } + + } + }) + + } + + fun addSFTPFileSystemViewPanelTab(host: Host) { + val panel = SFTPFileSystemViewPanel(host, transportManager) + addTab(host.name, panel) + panel.connect() + selectedIndex = tabCount - 1 + } + + /** + * 获取当前的 FileSystemViewPanel + */ + fun getSelectedFileSystemViewPanel(): FileSystemViewPanel? { + return getFileSystemViewPanel(selectedIndex) + } + + + fun getFileSystemViewPanel(index: Int): FileSystemViewPanel? { + if (tabCount < 1 || index < 0) return null + + val c = getComponentAt(index) + if (c is FileSystemViewPanel) { + return c + } + + if (c is SFTPFileSystemViewPanel) { + return c.getData(SFTPDataProviders.FileSystemViewPanel) + } + + return null + } + + + override fun removeTabAt(index: Int) { + val c = getComponentAt(index) + if (c is Disposable) { + Disposer.dispose(c) + } + super.removeTabAt(index) + } + + override fun dispose() { + if (disposed.compareAndSet(false, true)) { + while (tabCount > 0) removeTabAt(0) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SpeedReporter.kt b/src/main/kotlin/app/termora/sftp/SpeedReporter.kt new file mode 100644 index 0000000..f8ed7cf --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/SpeedReporter.kt @@ -0,0 +1,56 @@ +package app.termora.sftp + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds + +class SpeedReporter(private val coroutineScope: CoroutineScope) { + companion object { + val millis = TimeUnit.MILLISECONDS.toMillis(500) + } + + private val events = ConcurrentLinkedQueue>() + + init { + collect() + } + + fun report(transport: Transport, bytes: Long, time: Long) { + events.add(Triple(transport, bytes, time)) + } + + private fun collect() { + // 异步上报数据 + coroutineScope.launch { + while (coroutineScope.isActive) { + val time = System.currentTimeMillis() + val map = linkedMapOf() + + // 收集 + while (events.isNotEmpty() && events.peek().second < time) { + val (a, b) = events.poll() + map[a] = map.computeIfAbsent(a) { 0 } + b + } + + if (map.isNotEmpty()) { + for ((a, b) in map) { + if (b > 0) { + reportTransferredFilesize(a, b, time) + } + } + } + + delay(millis.milliseconds) + } + } + } + + + private fun reportTransferredFilesize(transport: Transport, bytes: Long, time: Long) { + transport.reportTransferredFilesize(bytes, time) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/Transport.kt b/src/main/kotlin/app/termora/sftp/Transport.kt new file mode 100644 index 0000000..c0c8dfb --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/Transport.kt @@ -0,0 +1,267 @@ +package app.termora.sftp + +import app.termora.Database +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import org.apache.commons.io.IOUtils +import org.apache.commons.net.io.Util +import org.slf4j.LoggerFactory +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributeView +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.getLastModifiedTime +import kotlin.io.path.name + +enum class TransportStatus { + Ready, + Processing, + Failed, + Done, +} + +/** + * 传输单位:单个文件 + */ +class Transport( + /** + * 唯一 ID + */ + val id: Long = idGenerator.incrementAndGet(), + + /** + * 是否是文件夹 + */ + val isDirectory: Boolean = false, + + /** + * 父 + */ + val parentId: Long = 0, + + /** + * 源 + */ + val source: Path, + + /** + * 目标 + */ + var target: Path, +) { + + companion object { + val idGenerator = AtomicLong(0) + private val log = LoggerFactory.getLogger(Transport::class.java) + private val isPreserveModificationTime get() = Database.getDatabase().sftp.preserveModificationTime + } + + private val scanned by lazy { AtomicBoolean(false) } + + /** + * 计数器 + */ + private val counter by lazy { SlidingWindowByteCounter() } + + /** + * 父 + */ + var parent: Transport? = null + set(value) { + if (field != null) throw IllegalStateException("parent already exists") + field = value + // 上报大小 + reportFilesize(filesize.get()) + } + + /** + * 文件大小,对于文件夹来说,文件大小是不确定的,它取决于文件夹下的文件 + */ + val filesize = AtomicLong(0) + + /** + * 已经传输完成的文件大小 + */ + val transferredFilesize = AtomicLong(0) + + /** + * 如果是文件夹,是否已经扫描完毕。如果已经扫描完毕,那么该文件夹传输完成后可以立即删除 + */ + val isScanned get() = scanned.get() + + val isFile = !isDirectory + val isRoot = parentId == 0L + + /** + * 获取最近一秒内的速度 + */ + val speed get() = counter.getLastSecondBytes() + + /** + * 状态 + */ + @Volatile + var status: TransportStatus = TransportStatus.Ready + private set + + + fun scanned() { + scanned.compareAndSet(false, true) + } + + fun changeStatus(status: TransportStatus): Boolean { + synchronized(this) { + if (status == TransportStatus.Processing) { + if (this.status != TransportStatus.Ready) { + return false + } + } else if (status == TransportStatus.Failed || status == TransportStatus.Done) { + if (this.status != TransportStatus.Ready && this.status != TransportStatus.Processing) { + return false + } + } else if (status == TransportStatus.Ready) { + if (this.status != TransportStatus.Ready) { + return false + } + } + + this.status = status + + return true + } + } + + private val c = AtomicLong(0) + + /** + * 开始传输 + */ + suspend fun transport(reporter: SpeedReporter) { + + if (isDirectory) { + withContext(Dispatchers.IO) { + try { + if (!target.exists()) { + target.createDirectories() + } + } catch (e: FileAlreadyExistsException) { + if (log.isWarnEnabled) { + log.warn("Directory ${target.name} already exists") + } + } catch (e: Exception) { + throw e + } + } + return + } + + withContext(Dispatchers.IO) { + val input = Files.newInputStream(source) + val output = Files.newOutputStream(target) + + try { + + val buff = ByteArray(Util.DEFAULT_COPY_BUFFER_SIZE) + var len: Int + while (input.read(buff).also { len = it } != -1 && this.isActive) { + + // 写入 + output.write(buff, 0, len) + + val size = len.toLong() + val now = System.currentTimeMillis() + + // 上报传输的字节数量 + reporter.report(this@Transport, size, now) + + // 如果状态错误,那么可能已经取消了 + if (status != TransportStatus.Processing) { + throw TransportStatusException("status is $status") + } + + } + + } finally { + IOUtils.closeQuietly(input, output) + } + + // 尝试修改时间 + preserveModificationTime() + + + } + + } + + private fun preserveModificationTime() { + // 设置修改时间 + if (isPreserveModificationTime) { + Files.getFileAttributeView(target, BasicFileAttributeView::class.java) + .setTimes(source.getLastModifiedTime(), source.getLastModifiedTime(), null) + } + } + + /** + * 一层层上报文件大小 + */ + fun reportFilesize(bytes: Long) { + val p = parent ?: return + if (isRoot) return + // 父状态不正常 + if (p.status == TransportStatus.Failed) return + // 父的文件大小就是自己的文件大小 + p.filesize.addAndGet(bytes) + // 递归上报 + p.reportFilesize(bytes) + } + + /** + * 一层层上报传输大小 + */ + fun reportTransferredFilesize(bytes: Long, time: Long) { + var p = this as Transport? + while (p != null) { + // 记录上报的数量,用于统计速度 + if (bytes > 0) p.counter.addBytes(bytes, time) + // 状态不正常 + if (p.status == TransportStatus.Failed) return + // 父的传输文件大小就是自己的传输文件大小 + p.transferredFilesize.addAndGet(bytes) + p = p.parent + c.incrementAndGet() + } + } + +} + + +private class SlidingWindowByteCounter { + private val events = ConcurrentLinkedQueue>() + private val oneSecondInMillis = TimeUnit.SECONDS.toMillis(1) + + fun addBytes(bytes: Long, time: Long) { + + // 添加当前事件 + events.add(time to bytes) + + // 移除过期事件(超过 1 秒的记录) + while (events.isNotEmpty() && events.peek().first < time - oneSecondInMillis) { + events.poll() + } + + } + + fun getLastSecondBytes(): Long { + val currentTime = System.currentTimeMillis() + // 累加最近 1 秒内的字节数 + return events.filter { it.first >= currentTime - oneSecondInMillis } + .sumOf { it.second } + } + +} diff --git a/src/main/kotlin/app/termora/sftp/TransportListener.kt b/src/main/kotlin/app/termora/sftp/TransportListener.kt new file mode 100644 index 0000000..f26289e --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/TransportListener.kt @@ -0,0 +1,10 @@ +package app.termora.sftp + +import java.util.* + +interface TransportListener : EventListener { + /** + * 状态变化 + */ + fun onTransportChanged(transport: Transport) {} +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/TransportManager.kt b/src/main/kotlin/app/termora/sftp/TransportManager.kt new file mode 100644 index 0000000..bd11aae --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/TransportManager.kt @@ -0,0 +1,11 @@ +package app.termora.sftp + +interface TransportManager { + fun addTransport(transport: Transport): Boolean + fun getTransport(id: Long): Transport? + fun getTransports(pId: Long): List + fun getTransportCount(): Int + fun removeTransport(id: Long) + fun addTransportListener(listener: TransportListener) + fun removeTransportListener(listener: TransportListener) +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/TransportStatusException.kt b/src/main/kotlin/app/termora/sftp/TransportStatusException.kt new file mode 100644 index 0000000..cc090a2 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/TransportStatusException.kt @@ -0,0 +1,3 @@ +package app.termora.sftp + +class TransportStatusException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/TransportTable.kt b/src/main/kotlin/app/termora/sftp/TransportTable.kt new file mode 100644 index 0000000..d759860 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/TransportTable.kt @@ -0,0 +1,261 @@ +package app.termora.sftp + +import app.termora.Disposable +import app.termora.Disposer +import app.termora.I18n +import app.termora.OptionPane +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.extras.components.FlatPopupMenu +import com.formdev.flatlaf.util.SystemInfo +import kotlinx.coroutines.* +import kotlinx.coroutines.swing.Swing +import org.jdesktop.swingx.JXTreeTable +import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode +import java.awt.Component +import java.awt.Graphics +import java.awt.Insets +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.util.* +import javax.swing.* +import javax.swing.table.DefaultTableCellRenderer +import javax.swing.tree.DefaultTreeCellRenderer +import kotlin.math.floor +import kotlin.time.Duration.Companion.milliseconds + +@Suppress("DuplicatedCode") +class TransportTable : JXTreeTable(), Disposable { + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val model = TransportTableModel(coroutineScope) + + + private val table = this + private val transportManager = model as TransportManager + + init { + initViews() + initEvents() + } + + private fun initViews() { + super.getTableHeader().setReorderingAllowed(false) + super.setTreeTableModel(model) + super.setRowHeight(UIManager.getInt("Table.rowHeight")) + super.setAutoResizeMode(JTable.AUTO_RESIZE_OFF) + super.setFillsViewportHeight(true) + super.putClientProperty( + FlatClientProperties.STYLE, mapOf( + "cellMargins" to Insets(0, 4, 0, 4), + "selectionArc" to 0, + ) + ) + + super.setTreeCellRenderer(object : DefaultTreeCellRenderer() { + override fun getTreeCellRendererComponent( + tree: JTree?, + value: Any?, + sel: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean + ): Component { + val node = value as DefaultMutableTreeTableNode + val transport = node.userObject as? Transport + val text = Objects.toString(node.getValueAt(TransportTableModel.COLUMN_NAME)) + val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus) + icon = if (transport?.isDirectory == true) NativeFileIcons.getFolderIcon() + else NativeFileIcons.getFileIcon(text) + return c + } + }) + + columnModel.getColumn(TransportTableModel.COLUMN_NAME).preferredWidth = 300 + columnModel.getColumn(TransportTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200 + columnModel.getColumn(TransportTableModel.COLUMN_TARGET_PATH).preferredWidth = 200 + + columnModel.getColumn(TransportTableModel.COLUMN_STATUS).preferredWidth = 100 + columnModel.getColumn(TransportTableModel.COLUMN_PROGRESS).preferredWidth = 150 + columnModel.getColumn(TransportTableModel.COLUMN_SIZE).preferredWidth = 140 + columnModel.getColumn(TransportTableModel.COLUMN_SPEED).preferredWidth = 80 + + val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER } + columnModel.getColumn(TransportTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer + columnModel.getColumn(TransportTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer + columnModel.getColumn(TransportTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer + columnModel.getColumn(TransportTableModel.COLUMN_ESTIMATED_TIME).cellRenderer = centerTableCellRenderer + columnModel.getColumn(TransportTableModel.COLUMN_PROGRESS).cellRenderer = + object : DefaultTableCellRenderer() { + private var progress = 0.0 + private var progressInt = 0 + private val padding = 4 + + init { + horizontalAlignment = SwingConstants.CENTER + } + + override fun getTableCellRendererComponent( + table: JTable?, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int + ): Component { + + this.progress = 0.0 + this.progressInt = 0 + + if (value is Transport) { + if (value.status == TransportStatus.Processing) { + this.progress = value.transferredFilesize.get() * 1.0 / value.filesize.get() + this.progressInt = floor(progress * 100.0).toInt() + // 因为有一些 0B 大小的文件,所以如果在进行中,那么最大就是99 + if (this.progress >= 1 && value.status == TransportStatus.Processing) { + this.progress = 0.99 + this.progressInt = floor(progress * 100.0).toInt() + } + } + } + + return super.getTableCellRendererComponent( + table, + "${progressInt}%", + isSelected, + hasFocus, + row, + column + ) + } + + override fun paintComponent(g: Graphics) { + // 原始背景 + g.color = background + g.fillRect(0, 0, width, height) + + // 进度条背景 + g.color = UIManager.getColor("Table.selectionInactiveBackground") + g.fillRect(0, padding, width, height - padding * 2) + + // 进度条颜色 + g.color = UIManager.getColor("ProgressBar.foreground") + g.fillRect(0, padding, (width * progress).toInt(), height - padding * 2) + + // 大于某个阀值的时候,就要改变颜色 + if (progress >= 0.45) { + foreground = selectionForeground + } + + // 绘制文字 + ui.paint(g, this) + } + } + } + + + private fun initEvents() { + // contextmenu + table.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (SwingUtilities.isRightMouseButton(e)) { + val r = table.rowAtPoint(e.point) + if (r >= 0 && r < table.rowCount) { + if (!table.isRowSelected(r)) { + table.setRowSelectionInterval(r, r) + } + } else { + table.clearSelection() + } + + val rows = table.selectedRows + + if (!table.hasFocus()) { + table.requestFocusInWindow() + } + + showContextMenu(rows, e) + } + } + }) + + // 刷新状态 + coroutineScope.launch(Dispatchers.Swing) { refreshView() } + + + // Delete key + table.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) { + val transports = selectedRows.map { getPathForRow(it).lastPathComponent } + .filterIsInstance().map { it.transport } + if (transports.isEmpty()) return + if (OptionPane.showConfirmDialog( + SwingUtilities.getWindowAncestor(table), + I18n.getString("termora.keymgr.delete-warning"), + messageType = JOptionPane.WARNING_MESSAGE + ) == JOptionPane.YES_OPTION + ) { + transports.forEach { transportManager.removeTransport(it.id) } + } + } + } + }) + + + Disposer.register(this, model) + + } + + private fun showContextMenu(rows: IntArray, e: MouseEvent) { + val transports = rows.map { getPathForRow(it).lastPathComponent } + .filterIsInstance().map { it.transport } + val popupMenu = FlatPopupMenu() + + val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete")) + val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all")) + delete.addActionListener { + if (OptionPane.showConfirmDialog( + SwingUtilities.getWindowAncestor(this), + I18n.getString("termora.keymgr.delete-warning"), + messageType = JOptionPane.WARNING_MESSAGE + ) == JOptionPane.YES_OPTION + ) { + for (transport in transports) { + transportManager.removeTransport(transport.id) + } + } + } + deleteAll.addActionListener { + if (OptionPane.showConfirmDialog( + SwingUtilities.getWindowAncestor(this), + I18n.getString("termora.keymgr.delete-warning"), + messageType = JOptionPane.WARNING_MESSAGE + ) == JOptionPane.YES_OPTION + ) { + transportManager.removeTransport(0) + } + } + + delete.isEnabled = transports.isNotEmpty() + + popupMenu.show(this, e.x, e.y) + } + + private suspend fun refreshView() { + while (coroutineScope.isActive) { + for (row in 0 until rowCount) { + val treePath = getPathForRow(row) ?: continue + val node = treePath.lastPathComponent as? TransportTreeTableNode ?: continue + model.valueForPathChanged(treePath, node.transport) + } + delay(SpeedReporter.millis.milliseconds) + } + } + + override fun dispose() { + coroutineScope.cancel() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/TransportTableModel.kt b/src/main/kotlin/app/termora/sftp/TransportTableModel.kt new file mode 100644 index 0000000..14bb253 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/TransportTableModel.kt @@ -0,0 +1,443 @@ +package app.termora.sftp + +import app.termora.Disposable +import app.termora.I18n +import app.termora.assertEventDispatchThread +import kotlinx.coroutines.* +import kotlinx.coroutines.swing.Swing +import okio.withLock +import org.apache.commons.lang3.ArrayUtils +import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode +import org.jdesktop.swingx.treetable.DefaultTreeTableModel +import org.jdesktop.swingx.treetable.MutableTreeTableNode +import org.slf4j.LoggerFactory +import java.util.concurrent.locks.ReentrantLock +import javax.swing.SwingUtilities +import kotlin.io.path.name +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds + + +class TransportTableModel(private val coroutineScope: CoroutineScope) : + DefaultTreeTableModel(DefaultMutableTreeTableNode()), TransportManager, Disposable { + + val lock = ReentrantLock() + + private val transports = linkedMapOf() + private val reporter = SpeedReporter(coroutineScope) + private var listeners = emptyArray() + private val activeTransports = linkedMapOf() + + /** + * 最多的平行任务 + */ + private val maxParallels = max(min(Runtime.getRuntime().availableProcessors(), 4), 1) + + companion object { + private val log = LoggerFactory.getLogger(TransportTableModel::class.java) + + const val COLUMN_COUNT = 8 + + const val COLUMN_NAME = 0 + const val COLUMN_STATUS = 1 + const val COLUMN_PROGRESS = 2 + const val COLUMN_SIZE = 3 + const val COLUMN_SOURCE_PATH = 4 + const val COLUMN_TARGET_PATH = 5 + const val COLUMN_SPEED = 6 + const val COLUMN_ESTIMATED_TIME = 7 + } + + init { + setColumnIdentifiers( + listOf( + I18n.getString("termora.transport.jobs.table.name"), + I18n.getString("termora.transport.jobs.table.status"), + I18n.getString("termora.transport.jobs.table.progress"), + I18n.getString("termora.transport.jobs.table.size"), + I18n.getString("termora.transport.jobs.table.source-path"), + I18n.getString("termora.transport.jobs.table.target-path"), + I18n.getString("termora.transport.jobs.table.speed"), + I18n.getString("termora.transport.jobs.table.estimated-time") + ) + ) + coroutineScope.launch { run() } + } + + + override fun getRoot(): DefaultMutableTreeTableNode { + return super.getRoot() as DefaultMutableTreeTableNode + } + + override fun isCellEditable(node: Any?, column: Int): Boolean { + return false + } + + override fun addTransport(transport: Transport): Boolean { + return lock.withLock { + if (!transport.isRoot) { + + // 判断父是否存在 + if (!transports.containsKey(transport.parentId)) { + return@withLock false + } + + // 检测状态 + if (!validGrandfatherStatus(transport)) { + changeStatus(transport, TransportStatus.Failed) + } + } + + val newNode = TransportTreeTableNode(transport) + val parentId = transport.parentId + val root = getRoot() + val p = if (parentId == 0L || !transports.contains(parentId)) { + root + } else { + transports.getValue(transport.parentId).apply { transport.parent = this.transport } + } + + transports[transport.id] = newNode + + if ((transports.containsKey(parentId) || p == root) && transports.containsKey(transport.id)) { + // 同步加入节点 + SwingUtilities.invokeLater { insertNodeInto(newNode, p, p.childCount) } + } + + return@withLock true + } + + + } + + override fun getTransport(id: Long): Transport? { + return transports[id]?.transport + } + + override fun getTransports(pId: Long): List { + lock.withLock { + if (pId == 0L) { + return getRoot().children().toList().filterIsInstance() + .map { it.transport } + } + val p = transports[pId] ?: return emptyList() + return p.children().toList().filterIsInstance() + .map { it.transport } + } + } + + override fun getTransportCount(): Int { + return transports.size + } + + /** + * 获取祖先的状态,如果祖先状态不正常,那么子直接定义为失败 + * + * @return true 正常 + */ + private fun validGrandfatherStatus(transport: Transport): Boolean { + lock.withLock { + // 如果自己/父不正常,那么失败 + if (transport.isRoot) return transport.status != TransportStatus.Failed + + // 父不存在,那么直接定义失败 + val p = transports[transport.parentId] ?: return false + + // 父状态不正常,那么失败 + if (p.transport.status == TransportStatus.Failed) return false + + return validGrandfatherStatus(p.transport) + } + } + + override fun removeTransport(id: Long) { + assertEventDispatchThread() + + lock.withLock { + + // ID 为空就是清空 + if (id <= 0) { + + // 定义为失败 + transports.forEach { changeStatus(it.value.transport, TransportStatus.Failed) } + // 清除所有任务 + transports.clear() + + // 取消任务 + activeTransports.forEach { it.value.cancel() } + activeTransports.clear() + + val root = getRoot() + while (root.childCount > 0) { + val c = root.getChildAt(0) + if (c is MutableTreeTableNode) { + removeNodeFromParent(c) + } + } + + return + } + + val n = transports[id] ?: return + val deletedIds = mutableListOf() + n.visit { deletedIds.add(it.transport.id) } + deletedIds.add(id) + + for (deletedId in deletedIds) { + val node = transports[deletedId] ?: continue + + // 定义为失败 + changeStatus(node.transport, TransportStatus.Failed) + if (deletedId == id) { + val p = if (node.transport.isRoot) root else transports[node.transport.parentId] + if (p != null) { + removeNodeFromParent(node) + } + } + + // 尝试取消 + activeTransports[deletedId]?.cancel() + + transports.remove(deletedId) + } + + // 如果不是成功,那么就是人工手动删除 + if (n.transport.status != TransportStatus.Done) { + // 文件大小减去尚未传输的 + n.transport.reportFilesize(-abs((n.transport.filesize.get() - n.transport.transferredFilesize.get()))) + } + } + } + + override fun addTransportListener(listener: TransportListener) { + listeners += listener + } + + override fun removeTransportListener(listener: TransportListener) { + listeners = ArrayUtils.removeElement(listeners, listener) + } + + private suspend fun run() { + while (coroutineScope.isActive) { + val nodes = getReadyTransport() + if (nodes.isEmpty()) { + delay((Random.nextInt(100, 250)).milliseconds) + continue + } + + // pre process + val readyNodes = mutableListOf() + for (node in nodes) { + val transport = node.transport + + // 因为有可能返回刚刚清理的 Transport,如果不返回清理的 Transport 那么就只能返回 null,返回null就要等待 N 毫秒 + if (transport.status != TransportStatus.Ready) continue + + // 如果祖先状态异常,那么直接定义为失败 + if (!validGrandfatherStatus(transport)) { + changeStatus(transport, TransportStatus.Failed) + continue + } + + // 进行中 + if (!changeStatus(transport, TransportStatus.Processing)) continue + + // 能走到这里表示准备好的任务 + readyNodes.add(node) + } + + // 如果没有准备好的节点,那么跳过 + if (readyNodes.isEmpty()) continue + + // 激活中的任务 + val activeTransports = mutableMapOf() + + // 同步传输 + for (node in readyNodes) { + val transport = node.transport + activeTransports[transport.id] = coroutineScope.launch { doTransport(node) } + } + + // 设置为全局的 + lock.withLock { + this.activeTransports.forEach { it.value.cancel() } + this.activeTransports.clear() + this.activeTransports.putAll(activeTransports) + } + + try { + // 等待所有任务 + activeTransports.values.joinAll() + } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } + } + + } + } + + private suspend fun doTransport(node: TransportTreeTableNode) { + val transport = node.transport + + try { + // 传输 + transport.transport(reporter) + // 变更状态,文件夹不需要变更状态,因为当文件夹下所有文件都成功时,文件夹自然会成功 + if (transport.isFile) { + changeStatus(transport, TransportStatus.Done) + } + } catch (e: Exception) { + if (e is TransportStatusException) { + if (log.isWarnEnabled) { + log.warn("{}: {}", transport.source.name, e.message) + } + } else if (log.isErrorEnabled) { + log.error(e.message, e) + } + + // 定义为失败 + changeStatus(transport, TransportStatus.Failed) + + } finally { + + // 从激活中移除 + if (lock.tryLock()) { + try { + activeTransports.remove(transport.id) + } finally { + lock.unlock() + } + } + + // 安全删除 + if (transport.status == TransportStatus.Done) { + safeRemoveTransport(node) + } + + } + } + + private fun fireTransportEvent(transport: Transport) { + for (listener in listeners) { + listener.onTransportChanged(transport) + } + } + + + private suspend fun safeRemoveTransport(node: TransportTreeTableNode) { + withContext(Dispatchers.Swing) { + lock.withLock { + var n = node as TransportTreeTableNode? + while (n != null) { + // 如果还有子,跳过 + if (n.childCount != 0) break + // 如果文件夹还没扫描完,那么不处理 + if (n.transport.isDirectory && !n.transport.isScanned) break + // 提前保存一下父 + val p = n.parent as? TransportTreeTableNode + // 设置成功 + changeStatus(n.transport, TransportStatus.Done) + // 删除 + removeTransport(n.transport.id) + // 继续向上查找 + n = p + } + + } + } + } + + private suspend fun getReadyTransport(): List { + val nodes = mutableListOf() + val removeNodes = mutableListOf() + + lock.withLock { + + val stack = ArrayDeque() + val root = getRoot() + for (i in root.childCount - 1 downTo 0) { + val child = root.getChildAt(i) + if (child is TransportTreeTableNode) { + stack.addLast(child) + } + } + + while (stack.isNotEmpty()) { + val node = stack.removeLast() + val transport = node.transport + + // 如果父已经失败,那么自己也定义为失败,之所以定义失败要走下去是因为它的子也要定义为失败 + if (transport.parent?.status == TransportStatus.Failed) { + changeStatus(transport, TransportStatus.Failed) + } + + // 这是一个比较特殊的情况,因为传输任务和文件夹扫描并不是一个线程。 + // 如果该文件夹最后一个任务传输任务完成后(已经尝试清理)这时候 + // 因为还没有“定义为扫描完毕”那么清理任务就会认为还在扫描,但是已经 + // 扫描完了,所以这里要执行一次清理。 + if (transport.isDirectory && transport.status == TransportStatus.Processing) { + if (node.childCount == 0 && transport.isScanned) { + removeNodes.add(node) + break + } + } + + if (transport.status == TransportStatus.Ready) { + if (transport.isDirectory) { + // 文件夹不允许和文件作为并行任务 + if (nodes.isNotEmpty()) break + // 加入任务立即退出 + nodes.add(node) + break + } else if (transport.isFile) { + // 如果要准备加入的并行任务不是一个父,那么不允许 + if (nodes.isNotEmpty() && nodes.last().transport.parentId != transport.parentId) break + // 加入任务 + nodes.add(node) + // 如果超出了最大 + if (nodes.size >= maxParallels) break + } + } + + + // 文件不可能有子 + if (transport.isFile) { + continue + } + + + for (i in node.childCount - 1 downTo 0) { + val child = node.getChildAt(i) + if (child is TransportTreeTableNode) { + stack.addLast(child) + } + } + } + + } + + // 如果有要清理的节点,那么直接返回清理的节点 + if (removeNodes.isNotEmpty()) { + removeNodes.forEach { safeRemoveTransport(it) } + return removeNodes + } + + return nodes + } + + private fun changeStatus(transport: Transport, status: TransportStatus): Boolean { + return transport.changeStatus(status).apply { if (this) fireTransportEvent(transport) } + } + + override fun dispose() { + lock.withLock { + // remove all + removeTransport(0L) + coroutineScope.cancel() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt b/src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt new file mode 100644 index 0000000..c6e0c1e --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt @@ -0,0 +1,72 @@ +package app.termora.sftp + +import app.termora.I18n +import app.termora.formatBytes +import app.termora.formatSeconds +import org.apache.commons.io.file.PathUtils +import org.apache.sshd.sftp.client.fs.SftpFileSystem +import org.eclipse.jgit.internal.transport.sshd.JGitClientSession +import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode +import java.nio.file.Path +import kotlin.io.path.absolutePathString + +class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) { + val transport get() = userObject as Transport + + override fun getValueAt(column: Int): Any { + val isProcessing = transport.status == TransportStatus.Processing + val speed = if (isProcessing) transport.speed else 0 + val estimatedTime = if (isProcessing && speed > 0) + (transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0 + + return when (column) { + TransportTableModel.COLUMN_NAME -> PathUtils.getFileNameString(transport.source) + TransportTableModel.COLUMN_STATUS -> formatStatus(transport.status) + TransportTableModel.COLUMN_SIZE -> size() + TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-" + TransportTableModel.COLUMN_ESTIMATED_TIME -> if (isProcessing) formatSeconds(estimatedTime) else "-" + TransportTableModel.COLUMN_SOURCE_PATH -> formatPath(transport.source) + TransportTableModel.COLUMN_TARGET_PATH -> formatPath(transport.target) + else -> super.getValueAt(column) + } + } + + private fun formatPath(path: Path): String { + if (path.fileSystem.isSFTP()) { + val hostname = ((path.fileSystem as SftpFileSystem).session as JGitClientSession).hostConfigEntry.hostName + return hostname + ":" + path.absolutePathString() + } + return path.toUri().scheme + ":" + path.absolutePathString() + } + + private fun formatStatus(status: TransportStatus): String { + return when (status) { + TransportStatus.Processing -> I18n.getString("termora.transport.sftp.status.transporting") + TransportStatus.Ready -> I18n.getString("termora.transport.sftp.status.waiting") + TransportStatus.Done -> I18n.getString("termora.transport.sftp.status.done") + TransportStatus.Failed -> I18n.getString("termora.transport.sftp.status.failed") + } + } + + private fun size(): String { + val transferredFilesize = transport.transferredFilesize.get() + val filesize = transport.filesize.get() + if (transferredFilesize <= 0) return formatBytes(filesize) + return "${formatBytes(transferredFilesize)}/${formatBytes(filesize)}" + } + + override fun getColumnCount(): Int { + return TransportTableModel.COLUMN_COUNT + } + + fun visit(consumer: (TransportTreeTableNode) -> Unit) { + if (childCount == 0) return + for (child in children()) { + if (child is TransportTreeTableNode) { + child.visit(consumer) + consumer.invoke(child) + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/TerminalReader.kt b/src/main/kotlin/app/termora/terminal/TerminalReader.kt index 23b36bc..714824d 100644 --- a/src/main/kotlin/app/termora/terminal/TerminalReader.kt +++ b/src/main/kotlin/app/termora/terminal/TerminalReader.kt @@ -4,7 +4,7 @@ import java.util.* @Suppress("MemberVisibilityCanBePrivate") class TerminalReader { - private val buffer = LinkedList() + private val buffer = ArrayDeque() fun addLast(char: Char) { @@ -12,7 +12,9 @@ class TerminalReader { } fun addFirst(chars: List) { - buffer.addAll(0, chars) + for (i in chars.size - 1 downTo 0) { + addFirst(chars[i]) + } } @@ -25,7 +27,7 @@ class TerminalReader { } fun addLast(text: String) { - text.toCharArray().forEach { addLast(it) } + text.forEach { addLast(it) } } fun read(): Char { diff --git a/src/main/kotlin/app/termora/transport/FileSystemPanel.kt b/src/main/kotlin/app/termora/transport/FileSystemPanel.kt deleted file mode 100644 index fc40c4e..0000000 --- a/src/main/kotlin/app/termora/transport/FileSystemPanel.kt +++ /dev/null @@ -1,1065 +0,0 @@ -package app.termora.transport - -import app.termora.* -import app.termora.actions.AnActionEvent -import app.termora.actions.SettingsAction -import com.formdev.flatlaf.FlatClientProperties -import com.formdev.flatlaf.extras.components.FlatPopupMenu -import com.formdev.flatlaf.extras.components.FlatToolBar -import com.formdev.flatlaf.icons.FlatFileViewDirectoryIcon -import com.formdev.flatlaf.icons.FlatFileViewFileIcon -import com.formdev.flatlaf.ui.FlatTableUI -import com.formdev.flatlaf.util.SystemInfo -import kotlinx.coroutines.* -import kotlinx.coroutines.swing.Swing -import org.apache.commons.io.FileUtils -import org.apache.commons.io.file.PathUtils -import org.apache.commons.lang3.StringUtils -import org.apache.commons.lang3.SystemUtils -import org.apache.commons.lang3.exception.ExceptionUtils -import org.apache.sshd.sftp.client.SftpClient -import org.apache.sshd.sftp.client.fs.SftpFileSystem -import org.apache.sshd.sftp.client.fs.SftpPath -import org.jdesktop.swingx.JXBusyLabel -import org.jdesktop.swingx.action.ActionManager -import org.slf4j.LoggerFactory -import java.awt.BorderLayout -import java.awt.Component -import java.awt.Desktop -import java.awt.datatransfer.DataFlavor -import java.awt.datatransfer.StringSelection -import java.awt.datatransfer.Transferable -import java.awt.datatransfer.UnsupportedFlavorException -import java.awt.event.ActionEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.io.File -import java.nio.file.* -import java.text.MessageFormat -import java.util.* -import java.util.regex.Pattern -import javax.swing.* -import javax.swing.table.DefaultTableCellRenderer -import kotlin.io.path.absolutePathString -import kotlin.io.path.exists -import kotlin.io.path.getLastModifiedTime -import kotlin.io.path.isDirectory -import kotlin.time.Duration.Companion.milliseconds - - -/** - * 文件系统面板 - */ -class FileSystemPanel( - private val fileSystem: FileSystem, - private val host: Host -) : JPanel(BorderLayout()), Disposable { - - companion object { - private val log = LoggerFactory.getLogger(FileSystemPanel::class.java) - } - - private val tableModel = FileSystemTableModel(fileSystem) - private val table = JTable(tableModel) - private val parentBtn = JButton(Icons.up) - private val workdirTextField = OutlineTextField() - private val owner get() = SwingUtilities.getWindowAncestor(this) - private val layeredPane = FileSystemLayeredPane() - private val loadingPanel = LoadingPanel() - private val bookmarkBtn = BookmarkButton() - private val homeBtn = JButton(Icons.homeFolder) - private val showHiddenFilesBtn = JButton(Icons.eyeClose) - private val properties get() = Database.getDatabase().properties - private val showHiddenFilesKey by lazy { "termora.transport.host.${host.id}.show-hidden-files" } - private val evt by lazy { AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) } - private val sftp get() = Database.getDatabase().sftp - private val actionManager get() = ActionManager.getInstance() - - /** - * Edit - */ - private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO + SupervisorJob()) } - - val workdir get() = tableModel.workdir - - init { - initView() - initEvents() - } - - private fun initView() { - - // 设置书签名称 - bookmarkBtn.name = "Host.${host.id}.Bookmarks" - bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString()) - - table.setUI(FlatTableUI()) - table.dragEnabled = true - table.dropMode = DropMode.INSERT_ROWS - table.rowHeight = UIManager.getInt("Table.rowHeight") - table.autoResizeMode = JTable.AUTO_RESIZE_OFF - table.fillsViewportHeight = true - table.putClientProperty( - FlatClientProperties.STYLE, mapOf( - "showHorizontalLines" to true, - "showVerticalLines" to true, - ) - ) - - table.setDefaultRenderer( - Any::class.java, - DefaultTableCellRenderer().apply { - horizontalAlignment = SwingConstants.CENTER - } - ) - - val modifyDateColumn = table.columnModel.getColumn(FileSystemTableModel.COLUMN_LAST_MODIFIED_TIME) - modifyDateColumn.preferredWidth = 130 - - val nameColumn = table.columnModel.getColumn(FileSystemTableModel.COLUMN_NAME) - nameColumn.preferredWidth = 250 - nameColumn.setCellRenderer(object : DefaultTableCellRenderer() { - private val b = BorderFactory.createEmptyBorder(0, 4, 0, 0) - private val d = FlatFileViewDirectoryIcon() - private val f = FlatFileViewFileIcon() - - override fun getTableCellRendererComponent( - table: JTable?, - value: Any, - isSelected: Boolean, - hasFocus: Boolean, - row: Int, - column: Int - ): Component { - var text = value.toString() - // name - if (value is FileSystemTableModel.CacheablePath) { - text = value.fileName - icon = if (value.isDirectory) d else f - iconTextGap = 4 - } - - val c = super.getTableCellRendererComponent(table, text, isSelected, hasFocus, row, column) - border = b - return c - } - }) - - parentBtn.toolTipText = I18n.getString("termora.transport.parent-folder") - showHiddenFilesBtn.toolTipText = I18n.getString("termora.transport.show-hidden-files") - - if (properties.getString(showHiddenFilesKey, "true").toBoolean()) { - showHiddenFilesBtn.icon = Icons.eye - tableModel.isShowHiddenFiles = true - } else { - showHiddenFilesBtn.icon = Icons.eyeClose - properties.putString(showHiddenFilesKey, "true") - tableModel.isShowHiddenFiles = false - } - - - val toolbar = FlatToolBar() - toolbar.add(homeBtn) - toolbar.add(Box.createHorizontalStrut(2)) - toolbar.add(workdirTextField) - toolbar.add(bookmarkBtn) - toolbar.add(showHiddenFilesBtn) - toolbar.add(parentBtn) - toolbar.add(JButton(Icons.refresh).apply { - addActionListener { reload() } - toolTipText = I18n.getString("termora.transport.table.contextmenu.refresh") - }) - toolbar.border = BorderFactory.createEmptyBorder(4, 2, 4, 2) - - val scrollPane = JScrollPane(table) - scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor) - layeredPane.add(scrollPane, JLayeredPane.DEFAULT_LAYER as Any) - layeredPane.add(loadingPanel, JLayeredPane.MODAL_LAYER as Any) - - add(toolbar, BorderLayout.NORTH) - add(layeredPane, BorderLayout.CENTER) - - } - - private fun initEvents() { - - homeBtn.addActionListener { - if (tableModel.isLocalFileSystem) { - tableModel.workdir(SystemUtils.USER_HOME) - } else if (fileSystem is SftpFileSystem) { - tableModel.workdir(fileSystem.defaultDir) - } - reload() - } - - bookmarkBtn.addActionListener { e -> - if (e.actionCommand.isNullOrBlank()) { - if (bookmarkBtn.isBookmark) { - bookmarkBtn.deleteBookmark(workdir.toString()) - } else { - bookmarkBtn.addBookmark(workdir.toString()) - } - bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark - } else if (!loadingPanel.isLoading) { - tableModel.workdir(e.actionCommand) - reload() - } - } - - // contextmenu - table.addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (SwingUtilities.isRightMouseButton(e)) { - val r = table.rowAtPoint(e.point) - if (r >= 0 && r < table.rowCount) { - if (!table.isRowSelected(r)) { - table.setRowSelectionInterval(r, r) - } - } else { - table.clearSelection() - } - - val rows = table.selectedRows - - if (!table.hasFocus()) { - table.requestFocusInWindow() - } - - showContextMenu(rows.filter { it != 0 }.toIntArray(), e) - } - } - }) - - - // double click - table.addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { - val row = table.selectedRow - if (row < 0) return - val path = tableModel.getCacheablePath(row) - if (path.isDirectory) { - openFolder() - } else { - transport(listOf(path)) - } - } - } - }) - - - table.transferHandler = object : TransferHandler() { - override fun canImport(support: TransferSupport): Boolean { - if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { - val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) - return data is FileSystemTableRowTransferable && data.fileSystemPanel != this@FileSystemPanel - } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { - return !tableModel.isLocalFileSystem - } - return false - } - - override fun importData(comp: JComponent?, t: Transferable): Boolean { - if (t.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { - val data = t.getTransferData(FileSystemTableRowTransferable.dataFlavor) - if (data !is FileSystemTableRowTransferable) { - return false - } - data.fileSystemPanel.transport(data.paths) - return true - } else if (t.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { - val files = t.getTransferData(DataFlavor.javaFileListFlavor) as List<*> - if (files.isEmpty()) return false - copyLocalFileToFileSystem(files.filterIsInstance()) - return true - } - return false - } - - override fun getSourceActions(c: JComponent?): Int { - return COPY - } - - override fun createTransferable(c: JComponent?): Transferable? { - val paths = table.selectedRows.filter { it != 0 }.map { tableModel.getCacheablePath(it) } - if (paths.isEmpty()) { - return null - } - return FileSystemTableRowTransferable(this@FileSystemPanel, paths) - } - } - - // 工作目录变动 - tableModel.addPropertyChangeListener { - if (it.propertyName == "workdir") { - workdirTextField.text = tableModel.workdir.toAbsolutePath().toString() - bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdirTextField.text) - } - } - - // 修改工作目录 - workdirTextField.addActionListener { - val text = workdirTextField.text - if (text.isBlank()) { - workdirTextField.text = tableModel.workdir.toAbsolutePath().toString() - reload() - } else { - val path = fileSystem.getPath(workdirTextField.text) - if (Files.exists(path)) { - tableModel.workdir(path) - reload() - } else { - workdirTextField.outline = "error" - } - } - } - - // 返回上一级目录 - parentBtn.addActionListener { - if (tableModel.rowCount > 0) { - val path = tableModel.getCacheablePath(0) - if (path.isDirectory && path.fileName == "..") { - tableModel.workdir(path.path) - reload() - } - } - } - - // 显示隐藏文件 - showHiddenFilesBtn.addActionListener { - val showHiddenFiles = tableModel.isShowHiddenFiles - tableModel.isShowHiddenFiles = !showHiddenFiles - if (tableModel.isShowHiddenFiles) { - showHiddenFilesBtn.icon = Icons.eye - } else { - showHiddenFilesBtn.icon = Icons.eyeClose - } - } - - // 如果不是本地的文件系统,那么支持粘贴 - if (!tableModel.isLocalFileSystem) { - table.actionMap.put("paste", object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - if (!toolkit.systemClipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor)) { - return - } - val files = (toolkit.systemClipboard.getData(DataFlavor.javaFileListFlavor) ?: return) as List<*> - copyLocalFileToFileSystem(files.filterIsInstance()) - } - }) - } - - Disposer.register(this, object : Disposable { - override fun dispose() { - properties.putString(showHiddenFilesKey, "${tableModel.isShowHiddenFiles}") - } - }) - - } - - override fun dispose() { - coroutineScope.cancel() - } - - private fun copyLocalFileToFileSystem(files: List) { - val event = AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) - val transportPanel = event.getData(TransportDataProviders.TransportPanel) ?: return - val leftFileSystemTabbed = event.getData(TransportDataProviders.LeftFileSystemTabbed) ?: return - val localFileSystemPanel = leftFileSystemTabbed.getFileSystemPanel(0) ?: return - - val paths = files.map { FileSystemTableModel.CacheablePath(it.toPath()) } - for (path in paths) { - if (path.isDirectory) { - Files.walk(path.path).use { - for (e in it) { - transportPanel.transport( - sourceWorkdir = path.path.parent, - targetWorkdir = workdir, - isSourceDirectory = e.isDirectory(), - sourcePath = e, - sourceHolder = localFileSystemPanel, - targetHolder = this@FileSystemPanel - ) - } - } - } else { - transportPanel.transport( - sourceWorkdir = path.path.parent, - targetWorkdir = workdir, - isSourceDirectory = false, - sourcePath = path.path, - sourceHolder = localFileSystemPanel, - targetHolder = this@FileSystemPanel - ) - } - } - } - - @OptIn(DelicateCoroutinesApi::class) - fun reload() { - if (loadingPanel.isLoading) { - return - } - - GlobalScope.launch(Dispatchers.IO) { - runCatching { suspendReload() } - } - } - - private suspend fun suspendReload() { - if (loadingPanel.isLoading) { - return - } - - withContext(Dispatchers.Swing) { - // reload - loadingPanel.start() - workdirTextField.text = workdir.toString() - } - - try { - tableModel.reload() - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog( - owner, - ExceptionUtils.getRootCauseMessage(e), - messageType = JOptionPane.ERROR_MESSAGE - ) - } - return - } finally { - withContext(Dispatchers.Swing) { - loadingPanel.stop() - } - } - - withContext(Dispatchers.Swing) { - table.scrollRectToVisible(table.getCellRect(0, 0, true)) - } - } - - - private fun openFolder() { - val row = table.selectedRow - if (row < 0) return - val path = tableModel.getCacheablePath(row) - if (path.isDirectory) { - tableModel.workdir(path.path) - reload() - } - } - - private fun canTransfer(): Boolean { - val event = AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) - val leftFileSystemTabbed = event.getData(TransportDataProviders.LeftFileSystemTabbed) ?: return false - val rightFileSystemTabbed = event.getData(TransportDataProviders.RightFileSystemTabbed) ?: return false - - val parent = SwingUtilities.getAncestorOfClass(FileSystemTabbed::class.java, this) - if (parent == leftFileSystemTabbed) { - return event.getData(TransportDataProviders.RightFileSystemPanel) != null - } else if (parent == rightFileSystemTabbed) { - return event.getData(TransportDataProviders.LeftFileSystemPanel) != null - } - - return false - } - - - private fun showContextMenu(rows: IntArray, event: MouseEvent) { - val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) } - val popupMenu = FlatPopupMenu() - val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new")) - - // 创建文件夹 - newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder")).addActionListener { - newFolderOrFile(file = false) - } - - // 创建文件 - newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file")).addActionListener { - newFolderOrFile(file = true) - } - - - // 传输 - val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer")) - transfer.addActionListener { - if (paths.isNotEmpty()) { - transport(paths) - } - } - - // 编辑 - val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit")) - // 不是本地文件系统 & 包含文件 - edit.isEnabled = !tableModel.isLocalFileSystem && paths.any { !it.isDirectory } - edit.addActionListener { - val files = paths.filter { !it.isDirectory } - if (files.isNotEmpty()) { - editFiles(files) - } - } - - popupMenu.addSeparator() - - // 复制路径 - val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path")) - copyPath.addActionListener { - val row = table.selectedRow - if (row > 0) { - toolkit.systemClipboard.setContents( - StringSelection( - tableModel.getPath(row).toAbsolutePath().toString() - ), null - ) - } - } - - // 如果是本地,那么支持打开本地路径 - if (tableModel.isLocalFileSystem) { - if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) { - popupMenu.add( - I18n.getString( - "termora.transport.table.contextmenu.open-in-folder", - if (SystemInfo.isMacOS) I18n.getString("termora.finder") - else if (SystemInfo.isWindows) I18n.getString("termora.explorer") - else I18n.getString("termora.folder") - ) - ).addActionListener { - val row = table.selectedRow - if (row > 0) { - Desktop.getDesktop().browseFileDirectory(tableModel.getPath(row).toFile()) - } - } - } - - } - popupMenu.addSeparator() - - // 重命名 - val rename = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.rename")) - rename.addActionListener { renamePath(tableModel.getPath(rows.last())) } - - // 删除 - val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete")).apply { - addActionListener { deletePaths(rows) } - } - - // rm -rf - val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.errorIntroduction)).apply { - addActionListener { - deletePaths(rows, true) - } - } - - // 只有 SFTP 可以 - if (fileSystem !is SftpFileSystem) { - rmrf.isVisible = false - } - - // 修改权限 - val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions")) - permission.isEnabled = false - - // 如果是本地系统文件,那么不允许修改权限,用户应该自己修改 - if (!tableModel.isLocalFileSystem && rows.isNotEmpty()) { - permission.isEnabled = true - permission.addActionListener { changePermissions(tableModel.getCacheablePath(rows.last())) } - } - popupMenu.addSeparator() - - // 刷新 - popupMenu.add(I18n.getString("termora.transport.table.contextmenu.refresh")) - .apply { addActionListener { reload() } } - popupMenu.addSeparator() - - // 新建 - popupMenu.add(newMenu) - - - if (rows.isEmpty()) { - transfer.isEnabled = false - rename.isEnabled = false - delete.isEnabled = false - rmrf.isEnabled = false - copyPath.isEnabled = false - permission.isEnabled = false - } else { - transfer.isEnabled = canTransfer() - } - - - popupMenu.show(table, event.x, event.y) - } - - private fun editFiles(files: List) { - if (files.isEmpty()) return - val transportManager = evt.getData(TransportDataProviders.TransportManager) ?: return - - if (SystemInfo.isLinux) { - if (sftp.editCommand.isBlank()) { - OptionPane.showMessageDialog( - owner, - I18n.getString("termora.transport.table.contextmenu.edit-command"), - messageType = JOptionPane.INFORMATION_MESSAGE - ) - actionManager.getAction(SettingsAction.SETTING) - ?.actionPerformed(AnActionEvent(this, StringUtils.EMPTY, EventObject(this))) - return - } - } - - val temporary = Application.getTemporaryDir().toPath() - - for (file in files) { - val dir = Files.createTempDirectory(temporary, "termora-") - val path = Paths.get(dir.absolutePathString(), file.fileName) - transportManager.addTransport( - transport = FileTransport( - name = file.fileName, - source = file.path, - target = path, - sourceHolder = this, - targetHolder = this, - listener = editFileTransportListener(file.path, path) - ) - ) - } - } - - private fun editFileTransportListener(source: Path, localPath: Path): TransportListener { - return object : TransportListener { - override fun onTransportChanged(transport: Transport) { - // 传输成功 - if (transport.state == TransportState.Done) { - val transportManager = evt.getData(TransportDataProviders.TransportManager) ?: return - var lastModifiedTime = localPath.getLastModifiedTime().toMillis() - - try { - if (sftp.editCommand.isNotBlank()) { - ProcessBuilder( - parseCommand( - MessageFormat.format( - sftp.editCommand, - localPath.absolutePathString() - ) - ) - ).start() - } else if (SystemInfo.isMacOS) { - ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start() - } else if (SystemInfo.isWindows) { - ProcessBuilder("notepad", localPath.absolutePathString()).start() - } else { - return - } - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - return - } - - - coroutineScope.launch(Dispatchers.IO) { - while (coroutineScope.isActive) { - try { - - if (!Files.exists(localPath)) { - break - } - - val nowModifiedTime = localPath.getLastModifiedTime().toMillis() - if (nowModifiedTime != lastModifiedTime) { - lastModifiedTime = nowModifiedTime - withContext(Dispatchers.Swing) { - // upload - transportManager.addTransport( - transport = FileTransport( - name = PathUtils.getFileNameString(localPath.fileName), - source = localPath, - target = source, - sourceHolder = this@FileSystemPanel, - targetHolder = this@FileSystemPanel, - ) - ) - } - } - - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - break - } - - delay(250.milliseconds) - } - } - } - } - - fun parseCommand(command: String): List { - val result = mutableListOf() - val matcher = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(command) - - while (matcher.find()) { - if (matcher.group(1) != null) { - result.add(matcher.group(1)) // 处理双引号部分 - } else { - result.add(matcher.group(2).replace("\\\\ ", " ")) - } - } - return result - } - } - } - - @OptIn(DelicateCoroutinesApi::class) - private fun renamePath(path: Path) { - val fileName = path.fileName.toString() - val text = InputDialog( - owner = owner, - title = fileName, - text = fileName, - ).getText() ?: return - - if (fileName == text) return - - loadingPanel.stop() - - GlobalScope.launch(Dispatchers.IO) { - val result = runCatching { - Files.move(path, path.parent.resolve(text), StandardCopyOption.ATOMIC_MOVE) - }.onFailure { - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog( - owner, it.message ?: ExceptionUtils.getRootCauseMessage(it), - messageType = JOptionPane.ERROR_MESSAGE - ) - } - } - - withContext(Dispatchers.Swing) { - loadingPanel.stop() - } - - if (result.isSuccess) { - reload() - } - } - - } - - - @OptIn(DelicateCoroutinesApi::class) - private fun newFolderOrFile(file: Boolean = false) { - val title = I18n.getString("termora.transport.table.contextmenu.new.${if (file) "file" else "folder"}") - val text = InputDialog( - owner = owner, - title = title, - ).getText() ?: return - - if (text.isEmpty()) return - - loadingPanel.stop() - - GlobalScope.launch(Dispatchers.IO) { - val result = runCatching { - val path = workdir.resolve(text) - if (path.exists()) { - throw IllegalStateException(I18n.getString("termora.transport.file-already-exists", text)) - } - if (file) - Files.createFile(path) - else - Files.createDirectories(path) - }.onFailure { - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog( - owner, it.message ?: ExceptionUtils.getRootCauseMessage(it), - messageType = JOptionPane.ERROR_MESSAGE - ) - } - } - - withContext(Dispatchers.Swing) { - loadingPanel.stop() - } - - if (result.isSuccess) { - reload() - } - } - - } - - - @OptIn(DelicateCoroutinesApi::class) - private fun deletePaths(rows: IntArray, rm: Boolean = false) { - if (OptionPane.showConfirmDialog( - SwingUtilities.getWindowAncestor(this), - I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"), - messageType = if (rm) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE - ) != JOptionPane.YES_OPTION - ) { - return - } - - loadingPanel.start() - - GlobalScope.launch(Dispatchers.IO) { - runCatching { - for (row in rows.sortedDescending()) { - try { - deleteRecursively(tableModel.getPath(row), rm) - withContext(Dispatchers.Swing) { - tableModel.removeRow(row) - } - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - } - } - }.onFailure { - if (log.isErrorEnabled) { - log.error(it.message, it) - } - } - - withContext(Dispatchers.Swing) { - loadingPanel.stop() - } - } - } - - private fun deleteRecursively(path: Path, rm: Boolean) { - if (path.fileSystem == FileSystems.getDefault()) { - FileUtils.deleteQuietly(path.toFile()) - } else if (path.fileSystem is SftpFileSystem) { - val fs = path.fileSystem as SftpFileSystem - if (rm) { - fs.session.executeRemoteCommand("rm -rf '$path'") - } else { - fs.client.use { - deleteRecursivelySFTP(path as SftpPath, it) - } - } - } - } - - /** - * 优化删除效率,采用一个连接 - */ - private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) { - val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory() - if (isDirectory) { - for (e in sftpClient.readDir(path.toString())) { - if (e.filename == ".." || e.filename == ".") { - continue - } - if (e.attributes.isDirectory) { - deleteRecursivelySFTP(path.resolve(e.filename), sftpClient) - } else { - sftpClient.remove(path.resolve(e.filename).toString()) - } - } - sftpClient.rmdir(path.toString()) - } else { - sftpClient.remove(path.toString()) - } - - } - - @OptIn(DelicateCoroutinesApi::class) - private fun changePermissions(cacheablePath: FileSystemTableModel.CacheablePath) { - val dialog = PosixFilePermissionDialog( - SwingUtilities.getWindowAncestor(this), - cacheablePath.posixFilePermissions - ) - val permissions = dialog.open() ?: return - - loadingPanel.start() - - GlobalScope.launch(Dispatchers.IO) { - val result = runCatching { - Files.setPosixFilePermissions(cacheablePath.path, permissions) - } - - result.onFailure { - if (log.isErrorEnabled) { - log.error(it.message, it) - } - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog( - SwingUtilities.getWindowAncestor(this@FileSystemPanel), ExceptionUtils.getRootCauseMessage(it), - messageType = JOptionPane.ERROR_MESSAGE - ) - } - } - - withContext(Dispatchers.Swing) { - loadingPanel.stop() - } - - if (result.isSuccess) { - reload() - } - - - } - } - - private fun transport(paths: List) { - assertEventDispatchThread() - if (!canTransfer()) { - return - } - - loadingPanel.start() - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - runCatching { doTransport(paths) } - withContext(Dispatchers.Swing) { - loadingPanel.stop() - } - } - } - - private suspend fun doTransport(paths: List) { - if (paths.isEmpty()) return - val transportPanel = evt.getData(TransportDataProviders.TransportPanel) ?: return - val leftFileSystemPanel = evt.getData(TransportDataProviders.LeftFileSystemPanel) ?: return - val rightFileSystemPanel = evt.getData(TransportDataProviders.RightFileSystemPanel) ?: return - val sourceFileSystemPanel = this - val targetFileSystemPanel = if (this == leftFileSystemPanel) rightFileSystemPanel else leftFileSystemPanel - - // 收集数据 - for (e in paths) { - - if (!e.isDirectory) { - val job = TransportJob( - fileSystemPanel = this, - workdir = workdir, - isDirectory = false, - path = e.path, - ) - withContext(Dispatchers.Swing) { - transportPanel.transport( - sourceWorkdir = workdir, - targetWorkdir = targetFileSystemPanel.workdir, - isSourceDirectory = false, - sourcePath = e.path, - sourceHolder = sourceFileSystemPanel, - targetHolder = targetFileSystemPanel - ) - } - continue - } - - withContext(Dispatchers.IO) { - Files.walk(e.path).use { walkPaths -> - for (path in walkPaths) { - if (path is SftpPath) { - val isDirectory = if (path.attributes != null) - path.attributes.isDirectory else path.isDirectory() - withContext(Dispatchers.Swing) { - transportPanel.transport( - sourceWorkdir = workdir, - targetWorkdir = targetFileSystemPanel.workdir, - isSourceDirectory = isDirectory, - sourcePath = path, - sourceHolder = sourceFileSystemPanel, - targetHolder = targetFileSystemPanel - ) - } - } else { - val isDirectory = path.isDirectory() - withContext(Dispatchers.Swing) { - transportPanel.transport( - sourceWorkdir = workdir, - targetWorkdir = targetFileSystemPanel.workdir, - isSourceDirectory = isDirectory, - sourcePath = path, - sourceHolder = sourceFileSystemPanel, - targetHolder = targetFileSystemPanel - ) - } - } - } - } - } - } - } - - private class LoadingPanel : JPanel() { - private val busyLabel = JXBusyLabel() - - val isLoading get() = busyLabel.isBusy - - init { - isOpaque = false - border = BorderFactory.createEmptyBorder(50, 0, 0, 0) - - add(busyLabel, BorderLayout.CENTER) - addMouseListener(object : MouseAdapter() {}) - isVisible = false - } - - fun start() { - busyLabel.isBusy = true - isVisible = true - } - - fun stop() { - busyLabel.isBusy = false - isVisible = false - } - } - - private class FileSystemLayeredPane : JLayeredPane() { - override fun doLayout() { - synchronized(treeLock) { - val w = width - val h = height - for (c in components) { - if (c is JScrollPane) { - c.setBounds(0, 0, w, h) - } else if (c is LoadingPanel) { - c.setBounds(0, 0, w, h) - } - } - } - } - } - - - private class FileSystemTableRowTransferable( - val fileSystemPanel: FileSystemPanel, - val paths: List - ) : Transferable { - companion object { - val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable") - } - - override fun getTransferDataFlavors(): Array { - return arrayOf(dataFlavor) - } - - override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean { - return flavor == dataFlavor - } - - override fun getTransferData(flavor: DataFlavor?): Any { - if (flavor != dataFlavor) { - throw UnsupportedFlavorException(flavor) - } - return this - } - - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/FileSystemTabbed.kt b/src/main/kotlin/app/termora/transport/FileSystemTabbed.kt deleted file mode 100644 index beadffc..0000000 --- a/src/main/kotlin/app/termora/transport/FileSystemTabbed.kt +++ /dev/null @@ -1,186 +0,0 @@ -package app.termora.transport - -import app.termora.* -import com.formdev.flatlaf.extras.components.FlatTabbedPane -import org.apache.commons.lang3.StringUtils -import java.awt.Component -import java.awt.Point -import java.nio.file.FileSystems -import javax.swing.* -import kotlin.math.max - - -class FileSystemTabbed( - private val transportManager: TransportManager, - private val isLeft: Boolean = false -) : FlatTabbedPane(), Disposable { - private val addBtn = JButton(Icons.add) - - init { - initView() - initEvents() - } - - private fun initView() { - tabLayoutPolicy = SCROLL_TAB_LAYOUT - isTabsClosable = true - tabType = TabType.underlined - styleMap = mapOf( - "focusColor" to UIManager.getColor("TabbedPane.selectedBackground"), - ) - - - val toolbar = JToolBar() - toolbar.add(addBtn) - trailingComponent = toolbar - - if (isLeft) { - addTab( - I18n.getString("termora.transport.local"), FileSystemPanel( - FileSystems.getDefault(), - host = Host( - id = "local", - name = I18n.getString("termora.transport.local"), - protocol = Protocol.Local, - ) - ).apply { reload() }) - setTabClosable(0, false) - } else { - addTab( - I18n.getString("termora.transport.sftp.select-host"), - SftpFileSystemPanel() - ) - } - - } - - - private fun initEvents() { - addBtn.addActionListener { - val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(this)) - dialog.location = Point( - max(0, addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2), - addBtn.locationOnScreen.y + max(tabHeight, addBtn.height) - ) - dialog.setFilter { it.host.protocol == Protocol.SSH } - dialog.setTreeName("FileSystemTabbed.Tree") - dialog.isVisible = true - - for (host in dialog.hosts) { - val panel = SftpFileSystemPanel(host) - addTab(host.name, panel) - panel.connect() - } - - } - - - setTabCloseCallback { _, index -> - removeTabAt(index) - } - } - - override fun removeTabAt(index: Int) { - - val fileSystemPanel = getFileSystemPanel(index) - - // 取消进行中的任务 - if (fileSystemPanel != null) { - val transports = mutableListOf() - for (transport in transportManager.getTransports()) { - if (transport.targetHolder == fileSystemPanel || transport.sourceHolder == fileSystemPanel) { - transports.add(transport) - } - } - - if (transports.isNotEmpty()) { - if (OptionPane.showConfirmDialog( - SwingUtilities.getWindowAncestor(this), - I18n.getString("termora.transport.sftp.close-tab"), - messageType = JOptionPane.WARNING_MESSAGE, - optionType = JOptionPane.OK_CANCEL_OPTION - ) != JOptionPane.OK_OPTION - ) { - return - } - transports.sortedBy { it.state == TransportState.Waiting } - .forEach { transportManager.removeTransport(it) } - } - } - - val c = getComponentAt(index) - if (c is Disposable) { - Disposer.dispose(c) - } - - super.removeTabAt(index) - - if (tabCount == 0) { - if (!isLeft) { - addTab( - I18n.getString("termora.transport.sftp.select-host"), - SftpFileSystemPanel() - ) - } - } - - - } - - override fun addTab(title: String, component: Component) { - super.addTab(title, component) - - selectedIndex = tabCount - 1 - - if (component is SftpFileSystemPanel) { - component.addPropertyChangeListener("TabName") { e -> - SwingUtilities.invokeLater { - val name = StringUtils.defaultIfEmpty( - e.newValue.toString(), - I18n.getString("termora.transport.sftp.select-host") - ) - for (i in 0 until tabCount) { - if (getComponentAt(i) == component) { - setTitleAt(i, name) - break - } - } - } - } - } - - } - - - fun getSelectedFileSystemPanel(): FileSystemPanel? { - return getFileSystemPanel(selectedIndex) - } - - fun getFileSystemPanel(index: Int): FileSystemPanel? { - if (index < 0) return null - val c = getComponentAt(index) - if (c is SftpFileSystemPanel) { - val p = c.fileSystemPanel - if (p != null) { - return p - } - } - - if (c is FileSystemPanel) { - return c - } - - return null - } - - override fun dispose() { - while (tabCount > 0) { - val c = getComponentAt(0) - if (c is Disposable) { - Disposer.dispose(c) - } - super.removeTabAt(0) - } - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/FileSystemTableModel.kt b/src/main/kotlin/app/termora/transport/FileSystemTableModel.kt deleted file mode 100644 index 3ead015..0000000 --- a/src/main/kotlin/app/termora/transport/FileSystemTableModel.kt +++ /dev/null @@ -1,255 +0,0 @@ -package app.termora.transport - -import app.termora.I18n -import app.termora.formatBytes -import org.apache.commons.lang3.StringUtils -import org.apache.commons.lang3.SystemUtils -import org.apache.commons.lang3.time.DateFormatUtils -import org.apache.sshd.sftp.client.fs.SftpFileSystem -import org.apache.sshd.sftp.client.fs.SftpPath -import org.slf4j.LoggerFactory -import java.beans.PropertyChangeEvent -import java.beans.PropertyChangeListener -import java.nio.file.FileSystem -import java.nio.file.FileSystems -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.attribute.PosixFilePermission -import java.nio.file.attribute.PosixFilePermissions -import java.util.* -import javax.swing.SwingUtilities -import javax.swing.table.DefaultTableModel -import kotlin.io.path.* - - -class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableModel() { - - - companion object { - const val COLUMN_NAME = 0 - const val COLUMN_TYPE = 1 - const val COLUMN_FILE_SIZE = 2 - const val COLUMN_LAST_MODIFIED_TIME = 3 - const val COLUMN_ATTRS = 4 - const val COLUMN_OWNER = 5 - } - - private val root = fileSystem.rootDirectories.first() - - var workdir: Path = if (fileSystem is SftpFileSystem) fileSystem.defaultDir - else fileSystem.getPath(SystemUtils.USER_HOME) - private set - - @Volatile - private var files: MutableList? = null - private val propertyChangeListeners = mutableListOf() - - val isLocalFileSystem by lazy { FileSystems.getDefault() == fileSystem } - var isShowHiddenFiles = false - set(value) { - field = value - fireTableDataChanged() - } - - override fun getRowCount(): Int { - return getShownFiles().size - } - - override fun getValueAt(row: Int, column: Int): Any { - val path = getShownFiles()[row] - - if (path.fileName == ".." && column != 0) { - return StringUtils.EMPTY - } - - return try { - when (column) { - COLUMN_NAME -> path - COLUMN_FILE_SIZE -> if (path.isDirectory) StringUtils.EMPTY else formatBytes(path.fileSize) - COLUMN_TYPE -> if (path.isDirectory) I18n.getString("termora.transport.table.type.folder") - else if (path.isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link") - else path.extension - - COLUMN_LAST_MODIFIED_TIME -> DateFormatUtils.format(Date(path.lastModifiedTime), "yyyy/MM/dd HH:mm") - - // 如果是本地的并且还是Windows系统 - COLUMN_ATTRS -> if (isLocalFileSystem && SystemUtils.IS_OS_WINDOWS) StringUtils.EMPTY else PosixFilePermissions.toString( - path.posixFilePermissions - ) - - COLUMN_OWNER -> path.owner - else -> StringUtils.EMPTY - } - } catch (e: Exception) { - StringUtils.EMPTY - } - } - - override fun getColumnCount(): Int { - return 6 - } - - override fun getColumnName(column: Int): String { - return when (column) { - COLUMN_NAME -> I18n.getString("termora.transport.table.filename") - COLUMN_FILE_SIZE -> I18n.getString("termora.transport.table.size") - COLUMN_TYPE -> I18n.getString("termora.transport.table.type") - COLUMN_LAST_MODIFIED_TIME -> I18n.getString("termora.transport.table.modified-time") - COLUMN_ATTRS -> I18n.getString("termora.transport.table.permissions") - COLUMN_OWNER -> I18n.getString("termora.transport.table.owner") - else -> StringUtils.EMPTY - } - } - - fun getPath(index: Int): Path { - return getCacheablePath(index).path - } - - fun getCacheablePath(index: Int): CacheablePath { - return getShownFiles()[index] - } - - override fun isCellEditable(row: Int, column: Int): Boolean { - return false - } - - override fun removeRow(row: Int) { - val e = getShownFiles()[row] - files?.removeIf { it == e } - fireTableRowsDeleted(row, row) - } - - fun reload() { - val files = mutableListOf() - if (root != workdir) { - files.add(CacheablePath(workdir.resolve(".."))) - } - - Files.list(workdir).use { - for (path in it) { - if (path is SftpPath) { - files.add(SftpCacheablePath(path)) - } else { - files.add(CacheablePath(path)) - } - } - } - files.sortWith(compareBy({ !it.isDirectory }, { it.fileName })) - - SwingUtilities.invokeLater { - this.files = files - fireTableDataChanged() - } - } - - fun workdir(absolutePath: String) { - workdir(fileSystem.getPath(absolutePath)) - } - - fun workdir(path: Path) { - this.workdir = path.toAbsolutePath().normalize() - propertyChangeListeners.forEach { - it.propertyChange( - PropertyChangeEvent( - this, - "workdir", - this.workdir, - this.workdir - ) - ) - } - } - - fun addPropertyChangeListener(propertyChangeListener: PropertyChangeListener) { - propertyChangeListeners.add(propertyChangeListener) - } - - private fun getShownFiles(): List { - if (isShowHiddenFiles) { - return files ?: emptyList() - } - return files?.filter { !it.isHidden } ?: emptyList() - } - - open class CacheablePath(val path: Path) { - val fileName by lazy { path.fileName.toString() } - val extension by lazy { path.extension } - - open val isDirectory by lazy { path.isDirectory() } - open val isSymbolicLink by lazy { path.isSymbolicLink() } - open val isHidden by lazy { fileName != ".." && path.isHidden() } - open val fileSize by lazy { path.fileSize() } - open val lastModifiedTime by lazy { Files.getLastModifiedTime(path).toMillis() } - open val owner by lazy { path.getOwner().toString() } - open val posixFilePermissions by lazy { - kotlin.runCatching { path.getPosixFilePermissions() }.getOrElse { emptySet() } - } - } - - class SftpCacheablePath(sftpPath: SftpPath) : CacheablePath(sftpPath) { - private val attributes = sftpPath.attributes - - companion object { - private val log = LoggerFactory.getLogger(SftpCacheablePath::class.java) - private fun fromSftpPermissions(sftpPermissions: Int): Set { - val result = mutableSetOf() - - // 将十进制权限转换为八进制字符串 - val octalPermissions = sftpPermissions.toString(8) - - // 仅取后三位权限部分 - if (octalPermissions.length < 3) { - if (log.isErrorEnabled) { - log.error("Invalid permission value: {}", sftpPermissions) - return result - } - } - - val permissionBits = octalPermissions.takeLast(3) - - // 解析每一部分的权限 - val owner = permissionBits[0].digitToInt() - val group = permissionBits[1].digitToInt() - val others = permissionBits[2].digitToInt() - - // 处理所有者权限 - if ((owner and 4) != 0) result.add(PosixFilePermission.OWNER_READ) - if ((owner and 2) != 0) result.add(PosixFilePermission.OWNER_WRITE) - if ((owner and 1) != 0) result.add(PosixFilePermission.OWNER_EXECUTE) - - // 处理组权限 - if ((group and 4) != 0) result.add(PosixFilePermission.GROUP_READ) - if ((group and 2) != 0) result.add(PosixFilePermission.GROUP_WRITE) - if ((group and 1) != 0) result.add(PosixFilePermission.GROUP_EXECUTE) - - // 处理其他用户权限 - if ((others and 4) != 0) result.add(PosixFilePermission.OTHERS_READ) - if ((others and 2) != 0) result.add(PosixFilePermission.OTHERS_WRITE) - if ((others and 1) != 0) result.add(PosixFilePermission.OTHERS_EXECUTE) - - return result - } - } - - override val isDirectory by lazy { attributes.isDirectory || (isSymbolicLink && Files.isDirectory(path)) } - - override val isSymbolicLink: Boolean - get() = attributes.isSymbolicLink - - override val isHidden: Boolean - get() = fileName != ".." && fileName.startsWith(".") - - override val fileSize: Long - get() = attributes.size - - override val lastModifiedTime: Long - by lazy { attributes.modifyTime.toMillis() } - - override val owner: String - get() = attributes.owner - - override val posixFilePermissions: Set - by lazy { fromSftpPermissions(attributes.permissions) } - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/FileTransportPanel.kt b/src/main/kotlin/app/termora/transport/FileTransportPanel.kt deleted file mode 100644 index ba05af1..0000000 --- a/src/main/kotlin/app/termora/transport/FileTransportPanel.kt +++ /dev/null @@ -1,174 +0,0 @@ -package app.termora.transport - -import app.termora.Disposable -import app.termora.I18n -import app.termora.OptionPane -import com.formdev.flatlaf.FlatClientProperties -import com.formdev.flatlaf.extras.components.FlatPopupMenu -import java.awt.BorderLayout -import java.awt.Component -import java.awt.Graphics -import java.awt.Insets -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import javax.swing.* -import javax.swing.table.DefaultTableCellRenderer - -class FileTransportPanel( - private val transportManager: TransportManager -) : JPanel(BorderLayout()), Disposable { - - private val tableModel = FileTransportTableModel(transportManager) - private val table = JTable(tableModel) - - init { - initView() - initEvents() - } - - private fun initView() { - table.fillsViewportHeight = true - table.autoResizeMode = JTable.AUTO_RESIZE_OFF - table.putClientProperty( - FlatClientProperties.STYLE, mapOf( - "showHorizontalLines" to true, - "showVerticalLines" to true, - "cellMargins" to Insets(2, 2, 2, 2) - ) - ) - table.columnModel.getColumn(FileTransportTableModel.COLUMN_NAME).preferredWidth = 200 - table.columnModel.getColumn(FileTransportTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200 - table.columnModel.getColumn(FileTransportTableModel.COLUMN_TARGET_PATH).preferredWidth = 200 - - table.columnModel.getColumn(FileTransportTableModel.COLUMN_STATUS).preferredWidth = 100 - table.columnModel.getColumn(FileTransportTableModel.COLUMN_PROGRESS).preferredWidth = 150 - table.columnModel.getColumn(FileTransportTableModel.COLUMN_SIZE).preferredWidth = 140 - table.columnModel.getColumn(FileTransportTableModel.COLUMN_SPEED).preferredWidth = 80 - - val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER } - table.columnModel.getColumn(FileTransportTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer - table.columnModel.getColumn(FileTransportTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer - table.columnModel.getColumn(FileTransportTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer - table.columnModel.getColumn(FileTransportTableModel.COLUMN_ESTIMATED_TIME).cellRenderer = - centerTableCellRenderer - - - table.columnModel.getColumn(FileTransportTableModel.COLUMN_PROGRESS).cellRenderer = - object : DefaultTableCellRenderer() { - init { - horizontalAlignment = SwingConstants.CENTER - } - - private var lastRow = -1 - - override fun getTableCellRendererComponent( - table: JTable?, - value: Any?, - isSelected: Boolean, - hasFocus: Boolean, - row: Int, - column: Int - ): Component { - lastRow = row - return super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) - } - - override fun paintComponent(g: Graphics) { - if (lastRow != -1) { - val row = tableModel.getTransport(lastRow) - if (row.state == TransportState.Transporting) { - g.color = UIManager.getColor("textHighlight") - g.fillRect(0, 0, (width * row.progress).toInt(), height) - } - } - super.paintComponent(g) - } - } - - - add(JScrollPane(table).apply { border = BorderFactory.createEmptyBorder() }, BorderLayout.CENTER) - } - - - private fun initEvents() { - - // contextmenu - table.addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (SwingUtilities.isRightMouseButton(e)) { - val r = table.rowAtPoint(e.point) - if (r >= 0 && r < table.rowCount) { - if (!table.isRowSelected(r)) { - table.setRowSelectionInterval(r, r) - } - } else { - table.clearSelection() - } - - val rows = table.selectedRows - - if (!table.hasFocus()) { - table.requestFocusInWindow() - } - - - showContextMenu(kotlin.runCatching { - rows.map { tableModel.getTransport(it) } - }.getOrElse { emptyList() }, e) - } - } - }) - } - - - private fun showContextMenu(transports: List, event: MouseEvent) { - val popupMenu = FlatPopupMenu() - - val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete")).apply { - addActionListener { - if (OptionPane.showConfirmDialog( - SwingUtilities.getWindowAncestor(this), - I18n.getString("termora.keymgr.delete-warning"), - messageType = JOptionPane.WARNING_MESSAGE - ) == JOptionPane.YES_OPTION - ) { - for (transport in transports) { - transportManager.removeTransport(transport) - } - } - } - } - - val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all")) - deleteAll.addActionListener { - if (OptionPane.showConfirmDialog( - SwingUtilities.getWindowAncestor(this), - I18n.getString("termora.keymgr.delete-warning"), - messageType = JOptionPane.WARNING_MESSAGE - ) == JOptionPane.YES_OPTION - ) { - transportManager.removeAllTransports() - } - } - - if (transports.isEmpty()) { - delete.isEnabled = false - deleteAll.isEnabled = transportManager.getTransports().isNotEmpty() - } - - popupMenu.addSeparator() - - popupMenu.add(I18n.getString("termora.transport.jobs.table.status")).addActionListener { - val last = transports.last() - OptionPane.showMessageDialog( - SwingUtilities.getWindowAncestor(this), - if (last.state == TransportState.Failed && last.stateText.isNotBlank()) last.stateText - else tableModel.formatStatus(last.state), - messageType = if (last.state == TransportState.Failed) JOptionPane.ERROR_MESSAGE else JOptionPane.INFORMATION_MESSAGE - ) - } - - popupMenu.show(table, event.x, event.y) - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/FileTransportTableModel.kt b/src/main/kotlin/app/termora/transport/FileTransportTableModel.kt deleted file mode 100644 index 6e7685f..0000000 --- a/src/main/kotlin/app/termora/transport/FileTransportTableModel.kt +++ /dev/null @@ -1,126 +0,0 @@ -package app.termora.transport - -import app.termora.I18n -import app.termora.formatBytes -import app.termora.formatSeconds -import org.apache.commons.lang3.StringUtils -import javax.swing.SwingUtilities -import javax.swing.table.DefaultTableModel - - -class FileTransportTableModel(transportManager: TransportManager) : DefaultTableModel() { - private var isInitialized = false - - private inline fun invokeLater(crossinline block: () -> Unit) { - if (SwingUtilities.isEventDispatchThread()) { - block.invoke() - } else { - SwingUtilities.invokeLater { block.invoke() } - } - } - - init { - transportManager.addTransportListener(object : TransportListener { - override fun onTransportAdded(transport: Transport) { - invokeLater { addRow(arrayOf(transport)) } - } - - override fun onTransportRemoved(transport: Transport) { - invokeLater { - val index = getDataVector().indexOfFirst { it.firstOrNull() == transport } - if (index >= 0) { - removeRow(index) - } - } - } - - override fun onTransportChanged(transport: Transport) { - invokeLater { - for ((index, vector) in getDataVector().withIndex()) { - if (vector.firstOrNull() == transport) { - fireTableRowsUpdated(index, index) - } - } - } - } - - }) - - isInitialized = true - } - - companion object { - const val COLUMN_NAME = 0 - const val COLUMN_STATUS = 1 - const val COLUMN_PROGRESS = 2 - const val COLUMN_SIZE = 3 - const val COLUMN_SOURCE_PATH = 4 - const val COLUMN_TARGET_PATH = 5 - const val COLUMN_SPEED = 6 - const val COLUMN_ESTIMATED_TIME = 7 - } - - override fun getColumnCount(): Int { - return 8 - } - - fun getTransport(row: Int): Transport { - return super.getValueAt(row, COLUMN_NAME) as Transport - } - - override fun getValueAt(row: Int, column: Int): Any { - val transport = getTransport(row) - val isTransporting = transport.state == TransportState.Transporting - val speed = if (isTransporting) transport.speed else 0 - val estimatedTime = if (isTransporting && speed > 0) - (transport.size - transport.transferredSize) / speed else 0 - val progress = transport.progress * 100.0 - - return when (column) { - COLUMN_NAME -> " ${transport.name}" - COLUMN_STATUS -> formatStatus(transport.state) - - // 如果进度已经完成但是状态还是传输中,那么进度显示:99% - COLUMN_PROGRESS -> String.format("%.0f%%", if (progress >= 100.0 && isTransporting) 99.0 else progress) - - // 大小 - COLUMN_SIZE -> if (transport.size < 0) "-" - else if (isTransporting) "${formatBytes(transport.transferredSize)}/${formatBytes(transport.size)}" - else formatBytes(transport.size) - - COLUMN_SOURCE_PATH -> " ${transport.getSourcePath}" - COLUMN_TARGET_PATH -> " ${transport.getTargetPath}" - COLUMN_SPEED -> if (isTransporting) formatBytes(speed) else "-" - COLUMN_ESTIMATED_TIME -> if (isTransporting && speed > 0) formatSeconds(estimatedTime) else "-" - else -> StringUtils.EMPTY - } - } - - fun formatStatus(state: TransportState): String { - return when (state) { - TransportState.Transporting -> I18n.getString("termora.transport.sftp.status.transporting") - TransportState.Waiting -> I18n.getString("termora.transport.sftp.status.waiting") - TransportState.Done -> I18n.getString("termora.transport.sftp.status.done") - TransportState.Failed -> I18n.getString("termora.transport.sftp.status.failed") - TransportState.Cancelled -> I18n.getString("termora.transport.sftp.status.cancelled") - } - } - - override fun getColumnName(column: Int): String { - return when (column) { - COLUMN_NAME -> I18n.getString("termora.transport.jobs.table.name") - COLUMN_STATUS -> I18n.getString("termora.transport.jobs.table.status") - COLUMN_PROGRESS -> I18n.getString("termora.transport.jobs.table.progress") - COLUMN_SIZE -> I18n.getString("termora.transport.jobs.table.size") - COLUMN_SOURCE_PATH -> I18n.getString("termora.transport.jobs.table.source-path") - COLUMN_TARGET_PATH -> I18n.getString("termora.transport.jobs.table.target-path") - COLUMN_SPEED -> I18n.getString("termora.transport.jobs.table.speed") - COLUMN_ESTIMATED_TIME -> I18n.getString("termora.transport.jobs.table.estimated-time") - else -> StringUtils.EMPTY - } - } - - override fun isCellEditable(row: Int, column: Int): Boolean { - return false - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/SFTPAction.kt b/src/main/kotlin/app/termora/transport/SFTPAction.kt deleted file mode 100644 index 450250b..0000000 --- a/src/main/kotlin/app/termora/transport/SFTPAction.kt +++ /dev/null @@ -1,80 +0,0 @@ -package app.termora.transport - -import app.termora.* -import app.termora.actions.AnAction -import app.termora.actions.AnActionEvent -import app.termora.actions.DataProviders - -class SFTPAction : AnAction("SFTP", Icons.folder) { - override fun actionPerformed(evt: AnActionEvent) { - val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return - val selectedTerminalTab = terminalTabbedManager.getSelectedTerminalTab() - val host = if (selectedTerminalTab is SSHTerminalTab || selectedTerminalTab is SFTPPtyTerminalTab) - selectedTerminalTab.host else null - val tab = openOrCreateSFTPTerminalTab(evt) ?: return - - if (host != null) { - connectHost(host.copy(protocol = Protocol.SSH, updateDate = System.currentTimeMillis()), tab) - } - } - - /** - * 打开一个已经存在或者创建一个 SFTP Tab - * - * @return null 表示当前条件下无法创建 - */ - fun openOrCreateSFTPTerminalTab(evt: AnActionEvent, selected: Boolean = true): SFTPTerminalTab? { - val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return null - - val tabs = terminalTabbedManager.getTerminalTabs() - for (tab in tabs) { - if (tab is SFTPTerminalTab) { - if (selected) { - terminalTabbedManager.setSelectedTerminalTab(tab) - } - return tab - } - } - - // 创建一个新的 - val tab = SFTPTerminalTab() - terminalTabbedManager.addTerminalTab(tab, selected) - - return tab - } - - /** - * 如果当前选中的是 SSH 服务器 Tab,那么直接打开 SFTP 通道 - */ - fun connectHost(host: Host, tab: SFTPTerminalTab) { - val tabbed = tab.getData(TransportDataProviders.TransportPanel) - ?.getData(TransportDataProviders.RightFileSystemTabbed) ?: return - - // 如果已经有对应的连接 - for (i in 0 until tabbed.tabCount) { - val c = tabbed.getComponentAt(i) - if (c is SftpFileSystemPanel) { - if (c.host == host) { - tabbed.selectedIndex = i - return - } - } - } - - // 寻找空的 Tab,如果有则占用 - for (i in 0 until tabbed.tabCount) { - val c = tabbed.getComponentAt(i) - if (c is SftpFileSystemPanel) { - if (c.host == null) { - c.host = host - c.connect() - tabbed.selectedIndex = i - return - } - } - } - - // 开启一个新的 - tabbed.addTab(host.name, SftpFileSystemPanel(host).apply { connect() }) - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/Transport.kt b/src/main/kotlin/app/termora/transport/Transport.kt deleted file mode 100644 index 7546616..0000000 --- a/src/main/kotlin/app/termora/transport/Transport.kt +++ /dev/null @@ -1,287 +0,0 @@ -package app.termora.transport - -import app.termora.Disposable -import org.apache.commons.io.IOUtils -import org.apache.commons.lang3.ObjectUtils -import org.apache.commons.lang3.StringUtils -import org.apache.commons.lang3.exception.ExceptionUtils -import org.apache.commons.net.io.CopyStreamEvent -import org.apache.commons.net.io.CopyStreamListener -import org.apache.commons.net.io.Util -import org.apache.sshd.sftp.client.fs.SftpFileSystem -import org.eclipse.jgit.internal.transport.sshd.JGitClientSession -import org.slf4j.LoggerFactory -import java.nio.file.FileSystem -import java.nio.file.Files -import java.nio.file.Path -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.TimeUnit -import kotlin.io.path.exists - -enum class TransportState { - Waiting, - Transporting, - Done, - Failed, - Cancelled, -} - -abstract class Transport( - val name: String, - // 源路径 - val source: Path, - // 目标路径 - val target: Path, - val sourceHolder: Disposable, - val targetHolder: Disposable, - val listener: TransportListener = TransportListener.EMPTY -) : Disposable, Runnable { - - private val listeners = ArrayList() - - init { - listeners.add(listener) - } - - @Volatile - var state = TransportState.Waiting - protected set(value) { - field = value - listeners.forEach { it.onTransportChanged(this) } - } - var stateText: String = StringUtils.EMPTY - - // 0 - 1 - var progress = 0.0 - protected set(value) { - field = value - listeners.forEach { it.onTransportChanged(this) } - } - - /** - * 要传输的大小 - */ - var size = -1L - protected set - - /** - * 已经传输的大小 - */ - var transferredSize = 0L - protected set - - /** - * 传输速度 - */ - open val speed get() = 0L - - open val getSourcePath by lazy { - getFileSystemName(source.fileSystem) + ":" + source.toAbsolutePath().normalize().toString() - } - open val getTargetPath by lazy { - getFileSystemName(target.fileSystem) + ":" + target.toAbsolutePath().normalize().toString() - } - - - fun addTransportListener(listener: TransportListener) { - listeners.add(listener) - } - - fun removeTransportListener(listener: TransportListener) { - listeners.remove(listener) - } - - override fun run() { - if (state != TransportState.Waiting) { - throw IllegalStateException("$name has already been started") - } - - state = TransportState.Transporting - } - - open fun stop() { - if (state == TransportState.Waiting || state == TransportState.Transporting) { - state = TransportState.Cancelled - } - } - - private fun getFileSystemName(fileSystem: FileSystem): String { - if (fileSystem is SftpFileSystem) { - val clientSession = fileSystem.session - if (clientSession is JGitClientSession) { - return ObjectUtils.defaultIfNull( - clientSession.hostConfigEntry.host, - clientSession.hostConfigEntry.hostName - ) - } - } - return "file" - } -} - -private class SlidingWindowByteCounter { - private val events = ConcurrentLinkedQueue>() - private val oneSecondInMillis = TimeUnit.SECONDS.toMillis(1) - - fun addBytes(bytes: Long, time: Long) { - - // 添加当前事件 - events.add(time to bytes) - - // 移除过期事件(超过 1 秒的记录) - while (events.isNotEmpty() && events.peek().first < time - oneSecondInMillis) { - events.poll() - } - - } - - fun getLastSecondBytes(): Long { - val currentTime = System.currentTimeMillis() - - // 累加最近 1 秒内的字节数 - return events.filter { it.first >= currentTime - oneSecondInMillis } - .sumOf { it.second } - } - - fun clear() { - events.clear() - } -} - - -/** - * 文件传输 - */ -class FileTransport( - name: String, source: Path, target: Path, - sourceHolder: Disposable, targetHolder: Disposable, listener: TransportListener = TransportListener.EMPTY -) : Transport( - name, source, target, sourceHolder, targetHolder, listener -), CopyStreamListener { - - companion object { - private val log = LoggerFactory.getLogger(FileTransport::class.java) - } - - private var lastVisitTime = 0L - private val input by lazy { Files.newInputStream(source) } - private val output by lazy { Files.newOutputStream(target) } - private val counter = SlidingWindowByteCounter() - - override val speed: Long - get() = counter.getLastSecondBytes() - - - override fun run() { - - try { - super.run() - doTransport() - state = TransportState.Done - } catch (e: Exception) { - if (state == TransportState.Cancelled) { - if (log.isWarnEnabled) { - log.warn("Transport $name is canceled") - } - return - } - if (log.isErrorEnabled) { - log.error(e.message, e) - } - state = TransportState.Failed - stateText = ExceptionUtils.getRootCauseMessage(e) - } finally { - counter.clear() - } - - } - - override fun stop() { - - // 如果在传输中,那么直接关闭流 - if (state == TransportState.Transporting) { - runCatching { IOUtils.closeQuietly(input) } - runCatching { IOUtils.closeQuietly(output) } - } - - super.stop() - - counter.clear() - } - - private fun doTransport() { - size = Files.size(source) - try { - Util.copyStream( - input, - output, - Util.DEFAULT_COPY_BUFFER_SIZE * 8, - size, - this - ) - } finally { - IOUtils.closeQuietly(input, output) - } - } - - override fun bytesTransferred(event: CopyStreamEvent?) { - throw UnsupportedOperationException() - } - - override fun bytesTransferred(totalBytesTransferred: Long, bytesTransferred: Int, streamSize: Long) { - - if (state == TransportState.Cancelled) { - throw IllegalStateException("$name has already been cancelled") - } - - val now = System.currentTimeMillis() - val progress = totalBytesTransferred * 1.0 / streamSize - - counter.addBytes(bytesTransferred.toLong(), now) - - if (now - lastVisitTime < 750) { - if (progress < 1.0) { - return - } - } - - this.transferredSize = totalBytesTransferred - this.progress = progress - lastVisitTime = now - } -} - -/** - * 创建文件夹 - */ -class DirectoryTransport( - name: String, source: Path, target: Path, - sourceHolder: Disposable, - targetHolder: Disposable, -) : Transport(name, source, target, sourceHolder, targetHolder) { - companion object { - private val log = LoggerFactory.getLogger(DirectoryTransport::class.java) - } - - - override fun run() { - - try { - super.run() - if (!target.exists()) { - Files.createDirectory(target) - } - state = TransportState.Done - } catch (e: FileAlreadyExistsException) { - if (log.isWarnEnabled) { - log.warn("Directory $name already exists") - } - state = TransportState.Done - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - state = TransportState.Failed - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/TransportDataProviders.kt b/src/main/kotlin/app/termora/transport/TransportDataProviders.kt deleted file mode 100644 index ea42569..0000000 --- a/src/main/kotlin/app/termora/transport/TransportDataProviders.kt +++ /dev/null @@ -1,17 +0,0 @@ -package app.termora.transport - -import app.termora.terminal.DataKey - -object TransportDataProviders { - val LeftFileSystemPanel = DataKey(FileSystemPanel::class) - val RightFileSystemPanel = DataKey(FileSystemPanel::class) - - val LeftFileSystemTabbed = DataKey(FileSystemTabbed::class) - val RightFileSystemTabbed = DataKey(FileSystemTabbed::class) - - val TransportManager = DataKey(app.termora.transport.TransportManager::class) - - val TransportPanel = DataKey(app.termora.transport.TransportPanel::class) -} - - diff --git a/src/main/kotlin/app/termora/transport/TransportJob.kt b/src/main/kotlin/app/termora/transport/TransportJob.kt deleted file mode 100644 index eb02b30..0000000 --- a/src/main/kotlin/app/termora/transport/TransportJob.kt +++ /dev/null @@ -1,27 +0,0 @@ -package app.termora.transport - -import java.nio.file.Path - -data class TransportJob( - /** - * 发起方 - */ - val fileSystemPanel: FileSystemPanel, - /** - * 发起方工作目录 - */ - val workdir: Path, - /** - * 要传输的文件是否是文件夹 - */ - val isDirectory: Boolean, - /** - * 要传输的文件/文件夹 - */ - val path: Path, - - /** - * 监听 - */ - val listener: TransportListener? = null -) \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/TransportListener.kt b/src/main/kotlin/app/termora/transport/TransportListener.kt deleted file mode 100644 index 2ba4f99..0000000 --- a/src/main/kotlin/app/termora/transport/TransportListener.kt +++ /dev/null @@ -1,35 +0,0 @@ -package app.termora.transport - -import java.util.* - -interface TransportListener : EventListener { - - companion object { - val EMPTY = object : TransportListener { - override fun onTransportAdded(transport: Transport) { - - } - - override fun onTransportRemoved(transport: Transport) { - } - - override fun onTransportChanged(transport: Transport) { - } - } - } - - /** - * Added - */ - fun onTransportAdded(transport: Transport){} - - /** - * Removed - */ - fun onTransportRemoved(transport: Transport){} - - /** - * 状态变化 - */ - fun onTransportChanged(transport: Transport){} -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/TransportManager.kt b/src/main/kotlin/app/termora/transport/TransportManager.kt deleted file mode 100644 index 11c60a1..0000000 --- a/src/main/kotlin/app/termora/transport/TransportManager.kt +++ /dev/null @@ -1,130 +0,0 @@ -package app.termora.transport - -import app.termora.Disposable -import kotlinx.coroutines.* -import org.slf4j.LoggerFactory -import java.util.* -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.time.Duration.Companion.milliseconds - -class TransportManager : Disposable { - private val transports = Collections.synchronizedList(mutableListOf()) - private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) } - private val isProcessing = AtomicBoolean(false) - private val listeners = mutableListOf() - private val listener = object : TransportListener { - override fun onTransportAdded(transport: Transport) { - listeners.forEach { it.onTransportAdded(transport) } - } - - override fun onTransportRemoved(transport: Transport) { - listeners.forEach { it.onTransportRemoved(transport) } - } - - override fun onTransportChanged(transport: Transport) { - listeners.forEach { it.onTransportChanged(transport) } - } - - } - - companion object { - private val log = LoggerFactory.getLogger(TransportManager::class.java) - } - - fun getTransports(): List = transports - - fun addTransport(transport: Transport) { - synchronized(transports) { - transport.addTransportListener(listener) - if (transports.add(transport)) { - listeners.forEach { it.onTransportAdded(transport) } - if (isProcessing.compareAndSet(false, true)) { - coroutineScope.launch(Dispatchers.IO) { process() } - } - } - } - } - - fun removeTransport(transport: Transport) { - synchronized(transports) { - transport.stop() - if (transports.remove(transport)) { - listeners.forEach { it.onTransportRemoved(transport) } - } - } - } - - fun removeAllTransports() { - synchronized(transports) { - while (transports.isNotEmpty()) { - removeTransport(transports.last()) - } - } - } - - fun addTransportListener(listener: TransportListener) { - listeners.add(listener) - } - - fun removeTransportListener(listener: TransportListener) { - listeners.remove(listener) - } - - private suspend fun process() { - var needDelay = false - while (coroutineScope.isActive) { - try { - - // 如果为空或者其中一个正在传输中那么挑过 - if (needDelay || transports.isEmpty()) { - needDelay = false - delay(250.milliseconds) - continue - } - - val transport = synchronized(transports) { - var transport: Transport? = null - for (e in transports) { - if (e.state != TransportState.Waiting) { - continue - } - - // 遇到传输中,那么直接跳过 - if (e.state == TransportState.Transporting) { - needDelay = true - break - } - - transport = e - break - } - return@synchronized transport - } - - if (transport == null) { - needDelay = true - continue - } - - transport.run() - - // 成功之后 删除 - if (transport.state == TransportState.Done) { - // remove - removeTransport(transport) - } - - } catch (e: Exception) { - if (log.isErrorEnabled) { - log.error(e.message, e) - } - } - } - } - - - override fun dispose() { - transports.clear() - coroutineScope.cancel() - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/TransportPanel.kt b/src/main/kotlin/app/termora/transport/TransportPanel.kt deleted file mode 100644 index 068b0bf..0000000 --- a/src/main/kotlin/app/termora/transport/TransportPanel.kt +++ /dev/null @@ -1,174 +0,0 @@ -package app.termora.transport - -import app.termora.Disposable -import app.termora.Disposer -import app.termora.DynamicColor -import app.termora.actions.DataProvider -import app.termora.actions.DataProviderSupport -import app.termora.terminal.DataKey -import org.apache.commons.lang3.StringUtils -import org.slf4j.LoggerFactory -import java.awt.BorderLayout -import java.awt.event.ComponentAdapter -import java.awt.event.ComponentEvent -import java.io.File -import java.nio.file.Path -import javax.swing.BorderFactory -import javax.swing.JPanel -import javax.swing.JSplitPane - -/** - * 传输面板 - */ -class TransportPanel : JPanel(BorderLayout()), Disposable, DataProvider { - - companion object { - private val log = LoggerFactory.getLogger(TransportPanel::class.java) - } - - private val dataProviderSupport = DataProviderSupport() - - private val transportManager = TransportManager() - private val leftFileSystemTabbed = FileSystemTabbed(transportManager, true) - private val rightFileSystemTabbed = FileSystemTabbed(transportManager, false) - private val fileTransportPanel = FileTransportPanel(transportManager) - - init { - initView() - initEvents() - } - - private fun initView() { - - Disposer.register(this, transportManager) - Disposer.register(this, leftFileSystemTabbed) - Disposer.register(this, rightFileSystemTabbed) - Disposer.register(this, fileTransportPanel) - - dataProviderSupport.addData(TransportDataProviders.LeftFileSystemTabbed, leftFileSystemTabbed) - dataProviderSupport.addData(TransportDataProviders.RightFileSystemTabbed, rightFileSystemTabbed) - dataProviderSupport.addData(TransportDataProviders.TransportManager, transportManager) - dataProviderSupport.addData(TransportDataProviders.TransportPanel, this) - - leftFileSystemTabbed.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor) - rightFileSystemTabbed.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor) - - - val splitPane = JSplitPane() - splitPane.leftComponent = leftFileSystemTabbed - splitPane.rightComponent = rightFileSystemTabbed - splitPane.resizeWeight = 0.5 - splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor) - splitPane.addComponentListener(object : ComponentAdapter() { - override fun componentResized(e: ComponentEvent) { - removeComponentListener(this) - splitPane.setDividerLocation(splitPane.resizeWeight) - } - }) - - fileTransportPanel.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor) - - val rootSplitPane = JSplitPane() - rootSplitPane.orientation = JSplitPane.VERTICAL_SPLIT - rootSplitPane.topComponent = splitPane - rootSplitPane.bottomComponent = fileTransportPanel - rootSplitPane.resizeWeight = 0.75 - rootSplitPane.addComponentListener(object : ComponentAdapter() { - override fun componentResized(e: ComponentEvent) { - removeComponentListener(this) - rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight) - } - }) - - add(rootSplitPane, BorderLayout.CENTER) - } - - @Suppress("DuplicatedCode") - private fun initEvents() { - transportManager.addTransportListener(object : TransportListener { - override fun onTransportAdded(transport: Transport) { - } - - override fun onTransportRemoved(transport: Transport) { - - } - - override fun onTransportChanged(transport: Transport) { - if (transport.state == TransportState.Done) { - val targetHolder = transport.targetHolder - if (targetHolder is FileSystemPanel) { - if (transport.target.parent == targetHolder.workdir) { - targetHolder.reload() - } - } - } - } - - }) - - - } - - fun transport( - sourceWorkdir: Path, - targetWorkdir: Path, - isSourceDirectory: Boolean, - sourcePath: Path, - sourceHolder: Disposable, - targetHolder: Disposable - ) { - val relativizePath = sourceWorkdir.relativize(sourcePath).toString() - if (StringUtils.isEmpty(relativizePath) || relativizePath == File.separator || - relativizePath == sourceWorkdir.fileSystem.separator || - relativizePath == targetWorkdir.fileSystem.separator - ) { - return - } - - val transport: Transport - if (isSourceDirectory) { - transport = DirectoryTransport( - name = sourcePath.fileName.toString(), - source = sourcePath, - target = targetWorkdir.resolve(relativizePath), - sourceHolder = sourceHolder, - targetHolder = targetHolder, - ) - } else { - transport = FileTransport( - name = sourcePath.fileName.toString(), - source = sourcePath, - target = targetWorkdir.resolve(relativizePath), - sourceHolder = sourceHolder, - targetHolder = targetHolder, - ) - } - - transportManager.addTransport(transport) - } - - override fun dispose() { - if (log.isInfoEnabled) { - log.info("Transport is disposed") - } - } - - override fun getData(dataKey: DataKey): T? { - if (dataKey == TransportDataProviders.LeftFileSystemPanel || - dataKey == TransportDataProviders.RightFileSystemPanel - ) { - dataProviderSupport.removeData(dataKey) - if (dataKey == TransportDataProviders.LeftFileSystemPanel) { - leftFileSystemTabbed.getSelectedFileSystemPanel()?.let { - dataProviderSupport.addData(dataKey, it) - } - } else { - rightFileSystemTabbed.getSelectedFileSystemPanel()?.let { - dataProviderSupport.addData(dataKey, it) - } - } - - } - return dataProviderSupport.getData(dataKey) - } -} \ No newline at end of file diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index c8bf6c1..5abf3ba 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -111,6 +111,8 @@ termora.settings.keymap.already-exists=The shortcut [{0}] is already in use by [ termora.settings.sftp.edit-command=Edit Command termora.settings.sftp.fixed-tab=Fixed tab +termora.settings.sftp.default-directory=Default Directory +termora.settings.sftp.preserve-time=Preserve original file modification time termora.settings.restart.title=Restart @@ -306,15 +308,14 @@ termora.transport.permissions.others=Others termora.transport.sftp.retry=Retry termora.transport.sftp.select-another-host=Select another host termora.transport.sftp.select-host=Select host -termora.transport.sftp.connect-a-host=Connect to a Host termora.transport.sftp.connecting=Connecting... termora.transport.sftp.closed=The connection has been closed termora.transport.sftp.close-tab=Transfer is still in activated status. Are you sure you want to remove all jobs and close this session? -termora.transport.sftp.status.transporting=Transporting +termora.transport.sftp.close-tab-has-active-session=Session is still active. Do you want to close all sessions? +termora.transport.sftp.status.transporting=In progress termora.transport.sftp.status.waiting=Waiting termora.transport.sftp.status.done=Done termora.transport.sftp.status.failed=Failed -termora.transport.sftp.status.cancelled=Cancelled # transport job @@ -326,6 +327,10 @@ termora.transport.jobs.table.source-path=Source Path termora.transport.jobs.table.target-path=Target Path termora.transport.jobs.table.speed=Speed termora.transport.jobs.table.estimated-time=Estimated time +termora.transport.jobs.table.estimated-time-days-format={0}d {1}h {2}m {3}s +termora.transport.jobs.table.estimated-time-hours-format={0}h {1}m {2}s +termora.transport.jobs.table.estimated-time-minutes-format={0}m {1}s +termora.transport.jobs.table.estimated-time-seconds-format={0}s termora.transport.jobs.contextmenu.delete=${termora.remove} termora.transport.jobs.contextmenu.delete-all=Delete All diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 12d8a7c..a10f2fe 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -114,7 +114,8 @@ termora.settings.keymap.already-exists=快捷键 [{0}] 已经被 [{1}] 占用 termora.settings.sftp.edit-command=编辑命令 termora.settings.sftp.fixed-tab=固定标签 - +termora.settings.sftp.default-directory=默认目录 +termora.settings.sftp.preserve-time=保留原始文件修改时间 # Welcome termora.welcome.my-hosts=我的主机 @@ -283,16 +284,15 @@ termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令删除文件 termora.transport.sftp.retry=重试 termora.transport.sftp.select-another-host=选择其他主机 termora.transport.sftp.select-host=选择主机 -termora.transport.sftp.connect-a-host=连接一个主机 termora.transport.sftp.connecting=连接中... termora.transport.sftp.closed=连接已经关闭 termora.transport.sftp.close-tab=传输还处于活动状态,是否删除所有传输任务并关闭此会话? +termora.transport.sftp.close-tab-has-active-session=会话还处于活动状态,是否关闭所有会话? termora.transport.sftp.status.transporting=传输中 termora.transport.sftp.status.waiting=等待中 termora.transport.sftp.status.done=已完成 termora.transport.sftp.status.failed=已失败 -termora.transport.sftp.status.cancelled=已取消 # Permission @@ -314,6 +314,10 @@ termora.transport.jobs.table.source-path=源路径 termora.transport.jobs.table.target-path=目标路径 termora.transport.jobs.table.speed=速度 termora.transport.jobs.table.estimated-time=剩余时间 +termora.transport.jobs.table.estimated-time-days-format={0}天{1}小时{2}分{3}秒 +termora.transport.jobs.table.estimated-time-hours-format={0}小时{1}分{2}秒 +termora.transport.jobs.table.estimated-time-minutes-format={0}分{1}秒 +termora.transport.jobs.table.estimated-time-seconds-format={0}秒 termora.transport.jobs.contextmenu.delete-all=删除所有 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 2d75d22..7e9a766 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -64,6 +64,8 @@ termora.settings.keymap.already-exists=快捷鍵 [{0}] 已經被 [{1}] 占用 termora.settings.sftp.edit-command=編輯命令 termora.settings.sftp.fixed-tab=固定標籤 +termora.settings.sftp.default-directory=預設目錄 +termora.settings.sftp.preserve-time=保留原始文件修改時間 # Find everywhere @@ -278,15 +280,14 @@ termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令刪除資料 termora.transport.sftp.retry=重試 termora.transport.sftp.select-another-host=選擇其他主機 termora.transport.sftp.select-host=選擇主機 -termora.transport.sftp.connect-a-host=連接一個主機 termora.transport.sftp.connecting=連接中... termora.transport.sftp.closed=連線已經關閉 termora.transport.sftp.close-tab=傳輸仍處於活動狀態,是否刪除所有傳輸任務並關閉此會話? +termora.transport.sftp.close-tab-has-active-session=會話仍處於活動狀態,是否關閉所有會話? termora.transport.sftp.status.transporting=傳輸中 termora.transport.sftp.status.waiting=等待中 termora.transport.sftp.status.done=已完成 termora.transport.sftp.status.failed=已失敗 -termora.transport.sftp.status.cancelled=已取消 # transport job termora.transport.jobs.table.name=名稱 @@ -297,6 +298,10 @@ termora.transport.jobs.table.source-path=來源路徑 termora.transport.jobs.table.target-path=目標路徑 termora.transport.jobs.table.speed=速度 termora.transport.jobs.table.estimated-time=剩餘時間 +termora.transport.jobs.table.estimated-time-days-format={0}天{1}小時{2}分{3}秒 +termora.transport.jobs.table.estimated-time-hours-format={0}小時{1}分{2}秒 +termora.transport.jobs.table.estimated-time-minutes-format={0}分{1}秒 +termora.transport.jobs.table.estimated-time-seconds-format={0}秒 termora.transport.jobs.contextmenu.delete-all=刪除所有 diff --git a/src/main/resources/icons/chevronRight.svg b/src/main/resources/icons/chevronRight.svg new file mode 100644 index 0000000..5120ed8 --- /dev/null +++ b/src/main/resources/icons/chevronRight.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/chevronRight_dark.svg b/src/main/resources/icons/chevronRight_dark.svg new file mode 100644 index 0000000..8381421 --- /dev/null +++ b/src/main/resources/icons/chevronRight_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/eye.svg b/src/main/resources/icons/eye.svg index 96d12b9..7576f6f 100644 --- a/src/main/resources/icons/eye.svg +++ b/src/main/resources/icons/eye.svg @@ -1,7 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/icons/eyeClose.svg b/src/main/resources/icons/eyeClose.svg index 7185afe..8c3194b 100644 --- a/src/main/resources/icons/eyeClose.svg +++ b/src/main/resources/icons/eyeClose.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/icons/eyeClose_dark.svg b/src/main/resources/icons/eyeClose_dark.svg index d13ce95..2644a54 100644 --- a/src/main/resources/icons/eyeClose_dark.svg +++ b/src/main/resources/icons/eyeClose_dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/icons/eye_dark.svg b/src/main/resources/icons/eye_dark.svg index 3578a02..431d589 100644 --- a/src/main/resources/icons/eye_dark.svg +++ b/src/main/resources/icons/eye_dark.svg @@ -1,7 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/icons/inspectionsEye.svg b/src/main/resources/icons/inspectionsEye.svg new file mode 100644 index 0000000..e3f2c17 --- /dev/null +++ b/src/main/resources/icons/inspectionsEye.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/inspectionsEye_dark.svg b/src/main/resources/icons/inspectionsEye_dark.svg new file mode 100644 index 0000000..989c92a --- /dev/null +++ b/src/main/resources/icons/inspectionsEye_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/warningIntroduction.svg b/src/main/resources/icons/warningIntroduction.svg new file mode 100644 index 0000000..d9dd279 --- /dev/null +++ b/src/main/resources/icons/warningIntroduction.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/icons/warningIntroduction_dark.svg b/src/main/resources/icons/warningIntroduction_dark.svg new file mode 100644 index 0000000..d147bd3 --- /dev/null +++ b/src/main/resources/icons/warningIntroduction_dark.svg @@ -0,0 +1,6 @@ + + + + + +