Compare commits

..

13 Commits

Author SHA1 Message Date
hstyi
5050aa37f5 release: 2.0.0-beta.5 2025-07-07 09:47:35 +08:00
hstyi
53d3d96a06 chore: dynamically modify icons 2025-07-07 09:35:55 +08:00
hstyi
d40b8a4c9c fix: windows drive list failure 2025-07-06 16:48:50 +08:00
hstyi
728671509c feat: transfer support disconnection and reconnection 2025-07-06 16:16:43 +08:00
hstyi
b7178a30fb chore: telnet supports backspace key setting 2025-07-06 11:01:44 +08:00
hstyi
939d6a1fd7 chore: improve flatlaf 2025-07-05 16:02:56 +08:00
hstyi
2986a9cc46 fix: binary compatibility 2025-07-05 14:35:42 +08:00
hstyi
f36afaf5d3 chore: telnet default port 23 2025-07-05 14:07:10 +08:00
hstyi
8cec835583 feat: support telnet 2025-07-05 14:07:10 +08:00
hstyi
a32838dad6 feat: support clone session 2025-07-05 12:07:33 +08:00
hstyi
d54671757e fix: tab drag and drop 2025-07-05 10:17:57 +08:00
hstyi
d1dba56bcd chore: improve sidebar 2025-07-04 16:36:37 +08:00
hstyi
919c06779d chore: improve rm -rf 2025-07-04 15:32:51 +08:00
66 changed files with 2070 additions and 494 deletions

View File

@@ -1 +1 @@
2.0.0-beta.4
2.0.0-beta.5

View File

@@ -22,10 +22,7 @@ import org.apache.commons.lang3.LocaleUtils
import org.apache.commons.lang3.SystemUtils
import org.json.JSONObject
import org.slf4j.LoggerFactory
import java.awt.MenuItem
import java.awt.PopupMenu
import java.awt.SystemTray
import java.awt.TrayIcon
import java.awt.*
import java.awt.desktop.AppReopenedEvent
import java.awt.desktop.AppReopenedListener
import java.awt.desktop.SystemEventListener
@@ -173,7 +170,6 @@ class ApplicationRunner {
private fun setupLaf() {
System.setProperty(FlatSystemProperties.USE_WINDOW_DECORATIONS, "${SystemInfo.isLinux || SystemInfo.isWindows}")
System.setProperty(FlatSystemProperties.USE_ROUNDED_POPUP_BORDER, "false")
if (SystemInfo.isLinux) {
JFrame.setDefaultLookAndFeelDecorated(true)
@@ -197,12 +193,13 @@ class ApplicationRunner {
themeManager.change(theme, true)
FlatInspector.install("ctrl shift X")
if (Application.isBetaVersion()) {
FlatInspector.install("ctrl shift X")
}
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)
UIManager.put("TitlePane.useWindowDecorations", false)
UIManager.put(FlatClientProperties.POPUP_FORCE_HEAVY_WEIGHT, true)
UIManager.put("Component.arc", 5)
UIManager.put("TextComponent.arc", UIManager.getInt("Component.arc"))
@@ -213,7 +210,6 @@ class ApplicationRunner {
UIManager.put("Dialog.width", 650)
UIManager.put("Dialog.height", 550)
if (SystemInfo.isMacOS) {
UIManager.put("TabbedPane.tabHeight", UIManager.getInt("TitleBar.height"))
} else if (SystemInfo.isLinux) {
@@ -231,15 +227,33 @@ class ApplicationRunner {
UIManager.put("Table.rowHeight", 24)
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)
UIManager.put("Tree.background", DynamicColor("window"))
UIManager.put("Tree.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Tree.showCellFocusIndicator", false)
UIManager.put("Tree.repaintWholeRow", true)
UIManager.put("List.selectionArc", UIManager.getInt("Component.arc"))
// Linux 更多的是尖锐风格
if (SystemInfo.isMacOS || SystemInfo.isWindows) {
val selectionInsets = Insets(0, 2, 0, 2)
UIManager.put("Tree.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Tree.selectionInsets", selectionInsets)
UIManager.put("List.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("List.selectionInsets", selectionInsets)
UIManager.put("ComboBox.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("ComboBox.selectionInsets", selectionInsets)
UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Table.selectionInsets", selectionInsets)
UIManager.put("MenuBar.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("MenuBar.selectionInsets", selectionInsets)
UIManager.put("MenuItem.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("MenuItem.selectionInsets", selectionInsets)
}
}

View File

@@ -34,6 +34,7 @@ object Icons {
val empty by lazy { DynamicIcon("icons/empty.svg") }
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") }
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val breakpoint by lazy { DynamicIcon("icons/breakpoint.svg", "icons/breakpoint_dark.svg") }
val softWrap by lazy { DynamicIcon("icons/softWrap.svg", "icons/softWrap_dark.svg") }
val scrollUp by lazy { DynamicIcon("icons/scrollUp.svg", "icons/scrollUp_dark.svg") }
val reformatCode by lazy { DynamicIcon("icons/reformatCode.svg", "icons/reformatCode_dark.svg") }
@@ -78,6 +79,7 @@ object Icons {
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") }
val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_dark.svg") }
val telnet by lazy { DynamicIcon("icons/telnet.svg", "icons/telnet_dark.svg") }
val ssh by lazy { DynamicIcon("icons/ssh.svg", "icons/ssh_dark.svg") }
val ftp by lazy { DynamicIcon("icons/ftp.svg", "icons/ftp_dark.svg") }
val minio by lazy { DynamicIcon("icons/minio.svg", "icons/minio_dark.svg") }

View File

@@ -94,6 +94,7 @@ class JSplitPaneWithZeroSizeDivider(
synchronized(treeLock) {
for (c in components) {
if (c == divider) {
c.isVisible = splitPane.leftComponent.isVisible
c.setBounds(
splitPane.dividerLocation - w,
topOffset.get(),
@@ -109,8 +110,10 @@ class JSplitPaneWithZeroSizeDivider(
override fun paint(g: Graphics) {
super.paint(g)
g.color = UIManager.getColor("controlShadow")
g.fillRect(splitPane.dividerLocation, 0, 1, topOffset.get())
if (divider.isVisible) {
g.color = UIManager.getColor("controlShadow")
g.fillRect(splitPane.dividerLocation, 0, 1, topOffset.get())
}
}
}

View File

@@ -9,10 +9,8 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProviderExtension
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.plugin.ExtensionManager
import app.termora.plugin.internal.extension.DynamicExtensionHandler
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
import app.termora.plugin.internal.sftppty.SFTPPtyTerminalTab
import app.termora.plugin.internal.ssh.SSHProtocolProvider
import app.termora.terminal.DataKey
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu
@@ -24,7 +22,6 @@ import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.beans.PropertyChangeListener
import java.util.*
import javax.swing.*
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
import kotlin.math.min
@@ -211,6 +208,15 @@ class TerminalTabbed(
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
val c = tabbedPane.getComponentAt(tabIndex) as JComponent
val tab = tabs[tabIndex]
val extensions = ExtensionManager.getInstance().getExtensions(TerminalTabbedContextMenuExtension::class.java)
val menuItems = mutableListOf<JMenuItem>()
for (extension in extensions) {
try {
menuItems.add(extension.createJMenuItem(windowScope, tab))
} catch (_: UnsupportedOperationException) {
continue
}
}
val popupMenu = FlatPopupMenu()
@@ -232,7 +238,7 @@ class TerminalTabbed(
}
// 克隆
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
val clone = popupMenu.add(I18n.getString("termora.copy"))
clone.addActionListener { evt ->
if (tab is HostTerminalTab) {
actionManager
@@ -284,14 +290,10 @@ class TerminalTabbed(
}
})
if (tab is HostTerminalTab) {
val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST)
if (openHostAction != null) {
if (tab.host.protocol == SSHProtocolProvider.PROTOCOL || tab.host.protocol == SFTPPtyProtocolProvider.PROTOCOL) {
popupMenu.addSeparator()
val sftpCommand = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
sftpCommand.addActionListener { openSFTPPtyTab(tab, openHostAction, it) }
}
if (menuItems.isNotEmpty()) {
popupMenu.addSeparator()
for (item in menuItems) {
popupMenu.add(item)
}
}
@@ -383,36 +385,6 @@ class TerminalTabbed(
}
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
if (!SFTPPtyTerminalTab.canSupports) {
OptionPane.showMessageDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.tabbed.contextmenu.sftp-not-install"),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
var host = tab.host
if (host.protocol == SSHProtocolProvider.PROTOCOL) {
val envs = tab.host.options.envs().toMutableMap()
val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel()
?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY
if (currentDir.isNotBlank()) {
envs["CurrentDir"] = currentDir
}
host = host.copy(
protocol = SFTPPtyProtocolProvider.PROTOCOL,
options = host.options.copy(env = envs.toPropertiesString())
)
}
openHostAction.actionPerformed(OpenHostActionEvent(this, host, evt))
}
/**
* 对着 ToolBar 右键
*/

View File

@@ -0,0 +1,12 @@
package app.termora
import app.termora.plugin.Extension
import javax.swing.JMenuItem
interface TerminalTabbedContextMenuExtension : Extension {
/**
* 抛出 [UnsupportedOperationException] 表示不支持
*/
fun createJMenuItem(windowScope: WindowScope, tab: TerminalTab): JMenuItem
}

View File

@@ -1,13 +1,20 @@
package app.termora
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.tree.NewHostTree
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.util.SystemInfo
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Font
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import javax.swing.*
import kotlin.math.max
class TermoraFencePanel(
@@ -24,6 +31,8 @@ class TermoraFencePanel(
private val leftTreePanel = LeftTreePanel()
private val mySplitPane = JSplitPaneWithZeroSizeDivider(splitPane) { tabbed.tabHeight }
private val enableManager get() = EnableManager.getInstance()
private val toolbar = FlatToolBar().apply { isFloatable = false }
private var dividerLocation = 0
init {
initView()
@@ -44,12 +53,39 @@ class TermoraFencePanel(
tabbed.tabType = FlatTabbedPane.TabType.underlined
tabbed.tabAreaInsets = null
// macOS 避开控制栏
if (SystemInfo.isMacOS) {
toolbar.add(Box.createHorizontalStrut(76))
}
toolbar.add(createColspanAction())
tabbed.leadingComponent = toolbar
toolbar.isVisible = false
add(mySplitPane, BorderLayout.CENTER)
}
private fun initEvents() {
Disposer.register(this, leftTreePanel)
splitPane.addPropertyChangeListener("dividerLocation") { mySplitPane.doLayout() }
leftTreePanel.addComponentListener(object : ComponentAdapter() {
override fun componentHidden(e: ComponentEvent) {
toolbar.isVisible = true
}
override fun componentShown(e: ComponentEvent) {
toolbar.isVisible = false
}
})
actionMap.put("toggle", createColspanAction())
getInputMap(WHEN_IN_FOCUSED_WINDOW).put(
KeyStroke.getKeyStroke(
KeyEvent.VK_B,
toolkit.menuShortcutKeyMaskEx or KeyEvent.SHIFT_DOWN_MASK
), "toggle"
)
}
private inner class LeftTreePanel : JPanel(BorderLayout()), Disposable {
@@ -70,9 +106,14 @@ class TermoraFencePanel(
val label = JLabel(Application.getName())
label.foreground = UIManager.getColor("textInactiveText")
label.font = label.font.deriveFont(Font.BOLD)
// 与最后一个按钮对冲,使其宽度和谐
box.add(JButton(Icons.empty))
box.add(Box.createHorizontalGlue())
if (SystemInfo.isMacOS.not()) box.add(label)
if (SystemInfo.isMacOS.not()) {
box.add(label)
}
box.add(Box.createHorizontalGlue())
box.add(createColspanAction())
if (SystemInfo.isMacOS || SystemInfo.isLinux) {
box.addMouseListener(moveMouseAdapter)
@@ -95,8 +136,24 @@ class TermoraFencePanel(
}
}
private fun createColspanAction(): Action {
return object : AnAction(Icons.dataColumn) {
init {
val text = I18n.getString("termora.welcome.toggle-sidebar")
putValue(SHORT_DESCRIPTION, "$text (${if (SystemInfo.isMacOS) '⌘' else "Ctrl"} + Shift + B)")
}
override fun actionPerformed(evt: AnActionEvent) {
if (leftTreePanel.isVisible) dividerLocation = splitPane.dividerLocation
leftTreePanel.isVisible = leftTreePanel.isVisible.not()
if (leftTreePanel.isVisible) splitPane.dividerLocation = dividerLocation
}
}
}
override fun dispose() {
enableManager.setFlag("Termora.Fence.dividerLocation", splitPane.dividerLocation)
enableManager.setFlag("Termora.Fence.dividerLocation", max(splitPane.dividerLocation, 10))
}
fun getHostTree(): NewHostTree {

View File

@@ -169,14 +169,6 @@ class TermoraFrame : JFrame(), DataProvider {
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICON, false)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, false)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, height)
} else if (SystemInfo.isMacOS) {
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
rootPane.putClientProperty(
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING,
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING_MEDIUM
)
}
if (SystemInfo.isWindows || SystemInfo.isLinux) {

View File

@@ -10,7 +10,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import java.util.function.Consumer
import javax.swing.PopupFactory
import javax.swing.SwingUtilities
import javax.swing.UIManager
@@ -118,11 +117,7 @@ internal class ThemeManager private constructor() {
private fun immediateChange(classname: String) {
try {
val oldPopupFactory = PopupFactory.getSharedInstance()
UIManager.setLookAndFeel(classname)
PopupFactory.setSharedInstance(oldPopupFactory)
} catch (ex: Exception) {
log.error(ex.message, ex)
}

View File

@@ -2,6 +2,7 @@ package app.termora.keymgr
import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.plugin.internal.ssh.SshClients
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnectorDelegate

View File

@@ -11,6 +11,7 @@ import app.termora.plugin.internal.rdp.RDPInternalPlugin
import app.termora.plugin.internal.serial.SerialInternalPlugin
import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin
import app.termora.plugin.internal.ssh.SSHInternalPlugin
import app.termora.plugin.internal.telnet.TelnetInternalPlugin
import app.termora.plugin.internal.wsl.WSLInternalPlugin
import app.termora.swingCoroutineScope
import app.termora.terminal.panel.vw.FloatingToolbarPlugin
@@ -111,12 +112,14 @@ internal class PluginManager private constructor() {
// ssh plugin
plugins.add(PluginDescriptor(SSHInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// serial plugin
plugins.add(PluginDescriptor(SerialInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// local plugin
plugins.add(PluginDescriptor(LocalInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// rdp plugin
plugins.add(PluginDescriptor(RDPInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// telnet plugin
plugins.add(PluginDescriptor(TelnetInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// serial plugin
plugins.add(PluginDescriptor(SerialInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// wsl plugin
if (SystemUtils.IS_OS_WINDOWS) {
plugins.add(PluginDescriptor(WSLInternalPlugin(), origin = PluginOrigin.Internal, version = version))

View File

@@ -10,7 +10,10 @@ import java.awt.Component
import java.awt.event.ItemEvent
import javax.swing.*
class BasicProxyOption(private val proxyTypes: List<ProxyType> = listOf(ProxyType.HTTP, ProxyType.SOCKS5)) :
class BasicProxyOption(
private val proxyTypes: List<ProxyType> = listOf(ProxyType.HTTP, ProxyType.SOCKS5),
private val authenticationTypes: List<AuthenticationType> = listOf(AuthenticationType.Password),
) :
JPanel(BorderLayout()), Option {
private val formMargin = "7dlu"
@@ -21,6 +24,10 @@ class BasicProxyOption(private val proxyTypes: List<ProxyType> = listOf(ProxyTyp
val proxyPortTextField = PortSpinner(1080)
val proxyAuthenticationTypeComboBox = FlatComboBox<AuthenticationType>()
constructor(proxyTypes: List<ProxyType> = listOf(ProxyType.HTTP, ProxyType.SOCKS5)) : this(
proxyTypes,
listOf(AuthenticationType.Password)
)
init {
initView()
@@ -67,7 +74,9 @@ class BasicProxyOption(private val proxyTypes: List<ProxyType> = listOf(ProxyTyp
}
proxyAuthenticationTypeComboBox.addItem(AuthenticationType.No)
proxyAuthenticationTypeComboBox.addItem(AuthenticationType.Password)
for (type in authenticationTypes) {
proxyAuthenticationTypeComboBox.addItem(type)
}
proxyUsernameTextField.text = "root"

View File

@@ -18,4 +18,8 @@ internal class LocalProtocolHostPanelExtension private constructor() : ProtocolH
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return LocalProtocolHostPanel()
}
override fun ordered(): Long {
return 1
}
}

View File

@@ -3,10 +3,8 @@ package app.termora.plugin.internal.local
import app.termora.*
import app.termora.actions.DataProvider
import app.termora.protocol.GenericProtocolProvider
import app.termora.protocol.ProtocolTestRequest
import app.termora.protocol.ProtocolTester
internal class LocalProtocolProvider private constructor() : GenericProtocolProvider, ProtocolTester {
internal class LocalProtocolProvider private constructor() : GenericProtocolProvider {
companion object {
val instance by lazy { LocalProtocolProvider() }
const val PROTOCOL = "local"
@@ -20,9 +18,6 @@ internal class LocalProtocolProvider private constructor() : GenericProtocolProv
return Icons.powershell
}
override fun canTestConnection(requester: ProtocolTestRequest): Boolean {
return true
}
override fun createTerminalTab(dataProvider: DataProvider, windowScope: WindowScope, host: Host): TerminalTab {
return LocalTerminalTab(windowScope, host)

View File

@@ -232,7 +232,8 @@ class PluginPanel(val descriptor: PluginPluginDescriptor) : JPanel(), Disposable
// 当有多个插件正在安装时,那么最后一个安装成功的询问是否重启
if (installing.get() <= 1) {
restarter.scheduleRestart(owner)
// 不阻塞按钮状态变更
SwingUtilities.invokeLater { restarter.scheduleRestart(owner) }
}
// 如果是更新,那么也需要刷新 InstalledPanel 下的按钮状态

View File

@@ -18,4 +18,8 @@ internal class RDPProtocolHostPanelExtension private constructor() : ProtocolHos
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return RDPProtocolHostPanel()
}
override fun ordered(): Long {
return 2
}
}

View File

@@ -19,4 +19,7 @@ internal class SerialProtocolHostPanelExtension private constructor() : Protocol
return SerialProtocolHostPanel()
}
override fun ordered(): Long {
return 5
}
}

View File

@@ -4,6 +4,7 @@ import app.termora.*
import app.termora.database.DatabaseManager
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.plugin.internal.ssh.SshClients
import app.termora.terminal.*
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.Charsets

View File

@@ -0,0 +1,44 @@
package app.termora.plugin.internal.ssh
import app.termora.I18n
import app.termora.TerminalTab
import app.termora.TerminalTabbedContextMenuExtension
import app.termora.WindowScope
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import javax.swing.JMenuItem
class CloneSessionTerminalTabbedContextMenuExtension private constructor() : TerminalTabbedContextMenuExtension {
companion object {
val instance = CloneSessionTerminalTabbedContextMenuExtension()
}
override fun createJMenuItem(
windowScope: WindowScope,
tab: TerminalTab
): JMenuItem {
if (tab is SSHTerminalTab) {
if (tab.host.protocol == SSHProtocolProvider.PROTOCOL) {
val cloneSession = JMenuItem(I18n.getString("termora.tabbed.contextmenu.clone-session"))
val c = tab.getData(SSHTerminalTab.MySshHandler)
cloneSession.isEnabled = c?.channel?.isOpen == true
if (c != null) {
cloneSession.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val handler = c.copy(channel = null)
val newTab = SSHTerminalTab(windowScope, tab.host, handler)
terminalTabbedManager.addTerminalTab(newTab)
newTab.start()
}
})
}
return cloneSession
}
}
throw UnsupportedOperationException()
}
}

View File

@@ -1,5 +1,6 @@
package app.termora.plugin.internal.ssh
import app.termora.TerminalTabbedContextMenuExtension
import app.termora.plugin.Extension
import app.termora.plugin.InternalPlugin
import app.termora.protocol.ProtocolHostPanelExtension
@@ -9,6 +10,8 @@ internal class SSHInternalPlugin : InternalPlugin() {
init {
support.addExtension(ProtocolProviderExtension::class.java) { SSHProtocolProviderExtension.instance }
support.addExtension(ProtocolHostPanelExtension::class.java) { SSHProtocolHostPanelExtension.instance }
support.addExtension(TerminalTabbedContextMenuExtension::class.java) { SftpCommandTerminalTabbedContextMenuExtension.instance }
support.addExtension(TerminalTabbedContextMenuExtension::class.java) { CloneSessionTerminalTabbedContextMenuExtension.instance }
}
override fun getName(): String {

View File

@@ -19,4 +19,8 @@ internal class SSHProtocolHostPanelExtension private constructor() : ProtocolHos
return SSHProtocolHostPanel(accountOwner)
}
override fun ordered(): Long {
return 0
}
}

View File

@@ -10,41 +10,36 @@ import app.termora.keymap.KeymapManager
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import org.apache.commons.io.Charsets
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.SshConstants
import org.apache.sshd.common.channel.Channel
import org.apache.sshd.common.channel.ChannelListener
import org.apache.sshd.common.session.Session
import org.apache.sshd.common.session.SessionListener
import org.apache.sshd.common.future.CloseFuture
import org.apache.sshd.common.future.SshFutureListener
import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.SwingUtilities
import kotlin.time.Duration.Companion.milliseconds
class SSHTerminalTab(
windowScope: WindowScope, host: Host,
private val handler: SshHandler = SshHandler()
) : PtyHostTerminalTab(windowScope, host) {
class SSHTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
companion object {
val SSHSession = DataKey(ClientSession::class)
internal val MySshHandler = DataKey(SshHandler::class)
private val log = LoggerFactory.getLogger(SSHTerminalTab::class.java)
}
private val mutex = Mutex()
private val tab = this
private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null
private var sshChannelShell: ChannelShell? = null
private val owner get() = SwingUtilities.getWindowAncestor(terminalPanel)
private val tab get() = this
init {
terminalPanel.dropFiles = false
@@ -55,12 +50,10 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
return terminalPanel
}
override fun canReconnect(): Boolean {
return !mutex.isLocked
return mutex.isLocked.not()
}
override suspend fun openPtyConnector(): PtyConnector {
if (mutex.tryLock()) {
try {
@@ -82,74 +75,32 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
// hide cursor
terminalModel.setData(DataKey.Companion.ShowCursor, false)
// print
terminal.write("SSH client is opening...\r\n")
terminal.write("Connecting to remote server ")
}
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
val client = SshClients.openClient(host, owner).also { sshClient = it }
val sessionListener = MySessionListener()
val channelListener = MyChannelListener()
withContext(Dispatchers.Swing) { terminal.write("SSH client opened successfully.\r\n\r\n") }
client.addSessionListener(sessionListener)
client.addChannelListener(channelListener)
val (session, channel) = try {
val session = SshClients.openSession(host, client).also { sshSession = it }
val channel = SshClients.openShell(
host,
terminalPanel.winSize(),
session
).also { sshChannelShell = it }
Pair(session, channel)
} finally {
client.removeSessionListener(sessionListener)
client.removeChannelListener(channelListener)
}
// newline
withContext(Dispatchers.Swing) {
terminal.write("\r\n")
}
channel.addChannelListener(object : ChannelListener {
private val reconnectShortcut
get() = KeymapManager.Companion.getInstance().getActiveKeymap()
.getShortcut(TabReconnectAction.Companion.RECONNECT_TAB).firstOrNull()
override fun channelClosed(channel: Channel, reason: Throwable?) {
coroutineScope.launch(Dispatchers.Swing) {
terminal.write("\r\n\r\n${ControlCharacters.Companion.ESC}[31m")
terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
if (reconnectShortcut is KeyShortcut) {
terminal.write(
I18n.getString(
"termora.terminal.channel-reconnect",
reconnectShortcut.toString()
)
)
}
terminal.write("\r\n")
terminal.write("${ControlCharacters.Companion.ESC}[0m")
terminalModel.setData(DataKey.Companion.ShowCursor, false)
if (DatabaseManager.getInstance().terminal.autoCloseTabWhenDisconnected) {
terminalTabbedManager?.let { manager ->
SwingUtilities.invokeLater {
manager.closeTerminalTab(tab, true)
}
}
}
// stop
stop()
}
val loading = coroutineScope.launch(Dispatchers.Swing) {
var c = 0
while (isActive) {
if (++c > 6) c = 1
terminal.write("${ControlCharacters.ESC}[1;32m")
terminal.write(".".repeat(c))
terminal.write(" ".repeat(6 - c))
terminal.write("${ControlCharacters.ESC}[0m")
delay(350.milliseconds)
terminal.write("${ControlCharacters.BS}".repeat(6))
}
})
}
// 打开隧道
openTunnelings(session, host)
val channel: ChannelShell
try {
val client = openClient()
val session = openSession(client)
channel = openChannel(session)
// 打开隧道
openTunnelings(session, host)
} finally {
loading.cancel()
}
// 隐藏提示
withContext(Dispatchers.Swing) {
@@ -194,10 +145,68 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
}
}
private fun openClient(): SshClient {
val client = handler.client
if (client != null) return client
return SshClients.openClient(host, owner).also { handler.client = it }
}
private fun openSession(client: SshClient): ClientSession {
val session = handler.session
if (session != null) return SshSessionPool.register(session, client)
return SshClients.openSession(host, client).also { handler.session = SshSessionPool.register(it, client) }
}
private fun openChannel(session: ClientSession): ChannelShell {
val channel = SshClients.openShell(host, terminalPanel.winSize(), session)
handler.channel = channel
channel.addCloseFutureListener(object : SshFutureListener<CloseFuture> {
private val reconnectShortcut
get() = KeymapManager.Companion.getInstance().getActiveKeymap()
.getShortcut(TabReconnectAction.Companion.RECONNECT_TAB).firstOrNull()
private val autoCloseTabWhenDisconnected get() = DatabaseManager.getInstance().terminal.autoCloseTabWhenDisconnected
override fun operationComplete(future: CloseFuture) {
coroutineScope.launch(Dispatchers.Swing) {
terminal.write("\r\n\r\n${ControlCharacters.Companion.ESC}[31m")
terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
if (reconnectShortcut is KeyShortcut) {
terminal.write(
I18n.getString(
"termora.terminal.channel-reconnect",
reconnectShortcut.toString()
)
)
}
terminal.write("\r\n")
terminal.write("${ControlCharacters.Companion.ESC}[0m")
terminalModel.setData(DataKey.Companion.ShowCursor, false)
if (autoCloseTabWhenDisconnected) {
terminalTabbedManager?.let { manager ->
SwingUtilities.invokeLater {
manager.closeTerminalTab(tab, true)
}
}
}
// stop
stop()
}
}
})
return channel
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == SSHSession) {
return sshSession as T?
return handler.session as T?
}
if (dataKey == MySshHandler) {
return handler as T?
}
return super.getData(dataKey)
}
@@ -206,16 +215,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
if (mutex.tryLock()) {
try {
super.stop()
sshChannelShell?.close(true)
sshSession?.disableSessionHeartbeat()
sshSession?.disconnect(SshConstants.SSH2_DISCONNECT_BY_APPLICATION, StringUtils.EMPTY)
sshSession?.close(true)
sshClient?.close(true)
sshChannelShell = null
sshSession = null
sshClient = null
handler.close()
} finally {
mutex.unlock()
}
@@ -231,36 +231,4 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
terminalPanel.storeVisualWindows(host.id)
}
private inner class MySessionListener : SessionListener, Disposable {
override fun sessionEvent(session: Session, event: SessionListener.Event) {
coroutineScope.launch {
when (event) {
SessionListener.Event.KeyEstablished -> terminal.write("Session Key exchange successful.\r\n")
SessionListener.Event.Authenticated -> terminal.write("Session authentication successful.\r\n\r\n")
SessionListener.Event.KexCompleted -> terminal.write("Session KEX negotiation successful.\r\n")
}
}
}
override fun sessionEstablished(session: Session) {
coroutineScope.launch { terminal.write("Session established.\r\n") }
}
override fun sessionCreated(session: Session?) {
coroutineScope.launch { terminal.write("Session created.\r\n") }
}
}
private inner class MyChannelListener : ChannelListener, Disposable {
override fun channelOpenSuccess(channel: Channel) {
coroutineScope.launch { terminal.write("Channel shell opened successfully.\r\n") }
}
override fun channelInitialized(channel: Channel) {
coroutineScope.launch { terminal.write("Channel shell initialization successful.\r\n") }
}
}
}

View File

@@ -0,0 +1,72 @@
package app.termora.plugin.internal.ssh
import app.termora.*
import app.termora.actions.*
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
import app.termora.plugin.internal.sftppty.SFTPPtyTerminalTab
import app.termora.terminal.DataKey
import org.apache.commons.lang3.StringUtils
import java.util.*
import javax.swing.Action
import javax.swing.JMenuItem
import javax.swing.JOptionPane
class SftpCommandTerminalTabbedContextMenuExtension private constructor() : TerminalTabbedContextMenuExtension {
companion object {
val instance = SftpCommandTerminalTabbedContextMenuExtension()
}
private val actionManager = ActionManager.getInstance()
override fun createJMenuItem(
windowScope: WindowScope,
tab: TerminalTab
): JMenuItem {
if (tab is HostTerminalTab) {
val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST)
if (openHostAction != null) {
if (tab.host.protocol == SSHProtocolProvider.PROTOCOL || tab.host.protocol == SFTPPtyProtocolProvider.PROTOCOL) {
val sftpCommand = JMenuItem(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
sftpCommand.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
openSFTPPtyTab(tab, openHostAction, evt)
}
})
return sftpCommand
}
}
}
throw UnsupportedOperationException()
}
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
if (SFTPPtyTerminalTab.canSupports.not()) {
OptionPane.showMessageDialog(
tab.windowScope.window,
I18n.getString("termora.tabbed.contextmenu.sftp-not-install"),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
var host = tab.host
if (host.protocol == SSHProtocolProvider.PROTOCOL) {
val envs = tab.host.options.envs().toMutableMap()
val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel()
?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY
if (currentDir.isNotBlank()) {
envs["CurrentDir"] = currentDir
}
host = host.copy(
protocol = SFTPPtyProtocolProvider.PROTOCOL,
options = host.options.copy(env = envs.toPropertiesString())
)
}
openHostAction.actionPerformed(OpenHostActionEvent(evt.source, host, evt))
}
}

View File

@@ -1,5 +1,6 @@
package app.termora
package app.termora.plugin.internal.ssh
import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize
@@ -29,7 +30,6 @@ import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.client.session.ClientSessionImpl
import org.apache.sshd.client.session.SessionFactory
import org.apache.sshd.common.AttributeRepository
import org.apache.sshd.common.SshConstants
import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.ChannelFactory
import org.apache.sshd.common.channel.PtyChannelConfiguration
@@ -63,7 +63,7 @@ import org.eclipse.jgit.internal.transport.sshd.proxy.AbstractClientProxyConnect
import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector
import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector
import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.SshConstants.IDENTITY_AGENT
import org.eclipse.jgit.transport.SshConstants
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory
import org.slf4j.LoggerFactory
@@ -89,7 +89,7 @@ object SshClients {
val HOST_KEY = AttributeRepository.AttributeKey<Host>()
private val timeout = Duration.ofSeconds(30)
private val hostManager get() = HostManager.getInstance()
private val hostManager get() = HostManager.Companion.getInstance()
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
/**
@@ -166,7 +166,7 @@ object SshClients {
}
val jumpHosts = mutableListOf<Host>()
val hosts = HostManager.getInstance().hosts().associateBy { it.id }
val hosts = HostManager.Companion.getInstance().hosts().associateBy { it.id }
for (jumpHostId in h.options.jumpHosts) {
val e = hosts[jumpHostId]
if (e == null) {
@@ -235,16 +235,16 @@ object SshClients {
if (SystemInfo.isMacOS) {
val file = FileUtils.getFile(Application.getBaseDataDir(), "config", "ssh-agent.sock")
if (file.exists()) {
entry.setProperty(IDENTITY_AGENT, file.absolutePath)
entry.setProperty(SshConstants.IDENTITY_AGENT, file.absolutePath)
}
}
if (entry.getProperty(IDENTITY_AGENT).isNullOrBlank()) {
if (entry.getProperty(SshConstants.IDENTITY_AGENT).isNullOrBlank()) {
if (host.authentication.password.isNotBlank())
entry.setProperty(IDENTITY_AGENT, host.authentication.password)
entry.setProperty(SshConstants.IDENTITY_AGENT, host.authentication.password)
else if (SystemInfo.isWindows)
entry.setProperty(IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent)
entry.setProperty(SshConstants.IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent)
else
entry.setProperty(IDENTITY_AGENT, UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
entry.setProperty(SshConstants.IDENTITY_AGENT, UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
}
}
@@ -272,7 +272,7 @@ object SshClients {
throw SshException("Authentication failed")
}
} catch (e: Exception) {
if (e !is SshException || e.disconnectCode != SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e
if (e !is SshException || e.disconnectCode != org.apache.sshd.common.SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e
val owner = client.properties["owner"] as Window? ?: throw e
val askUserInfo = ask(host, entry, owner) ?: throw e
if (askUserInfo.authentication.type == AuthenticationType.No) throw e
@@ -383,7 +383,7 @@ object SshClients {
val channelFactories = mutableListOf<ChannelFactory>()
channelFactories.addAll(ClientBuilder.DEFAULT_CHANNEL_FACTORIES)
channelFactories.add(X11ChannelFactory.INSTANCE)
channelFactories.add(X11ChannelFactory.Companion.INSTANCE)
builder.channelFactories(channelFactories)
val sshClient = builder.build() as JGitSshClient
@@ -725,5 +725,4 @@ object SshClients {
}
}
}

View File

@@ -0,0 +1,27 @@
package app.termora.plugin.internal.ssh
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.channel.Channel
data class SshHandler(
var client: SshClient? = null,
var session: ClientSession? = null,
var channel: Channel? = null
) : AutoCloseable {
override fun close() {
channel?.close(true)?.await()
session?.close(true)?.await()
channel = null
session = null
// client 由 SshSessionPool 负责关闭
if (client?.isClosing == true || client?.isClosed == true) {
client = null
}
}
}

View File

@@ -0,0 +1,394 @@
package app.termora.plugin.internal.ssh
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ChannelExec
import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.client.session.forward.DynamicPortForwardingTracker
import org.apache.sshd.client.session.forward.ExplicitPortForwardingTracker
import org.apache.sshd.common.AttributeRepository
import org.apache.sshd.common.channel.Channel
import org.apache.sshd.common.channel.PtyChannelConfigurationHolder
import org.apache.sshd.common.channel.throttle.ChannelStreamWriter
import org.apache.sshd.common.channel.throttle.ChannelStreamWriterResolver
import org.apache.sshd.common.future.CloseFuture
import org.apache.sshd.common.future.DefaultCloseFuture
import org.apache.sshd.common.io.IoWriteFuture
import org.apache.sshd.common.session.SessionHeartbeatController
import org.apache.sshd.common.util.buffer.Buffer
import org.apache.sshd.common.util.net.SshdSocketAddress
import java.io.OutputStream
import java.net.SocketAddress
import java.nio.charset.Charset
import java.time.Duration
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.function.Function
internal object SshSessionPool {
private val map = WeakHashMap<ClientSession, MyClientSession>()
fun register(session: ClientSession, client: SshClient): ClientSession {
if (session is MyClientSession) {
session.refCount.incrementAndGet()
return session
}
synchronized(session) {
val delegate = map[session] ?: MyClientSession(client, session)
map[session] = delegate
delegate.refCount.incrementAndGet()
return delegate
}
}
private class MyClientSession(
private val client: SshClient,
private val delegate: ClientSession
) : ClientSession by delegate {
val refCount = AtomicInteger(0)
override fun createShellChannel(): ChannelShell? {
return delegate.createShellChannel()
}
override fun createExecChannel(command: String?): ChannelExec? {
return delegate.createExecChannel(command)
}
override fun createExecChannel(
command: String?,
ptyConfig: PtyChannelConfigurationHolder?,
env: Map<String?, *>?
): ChannelExec? {
return delegate.createExecChannel(command, ptyConfig, env)
}
override fun executeRemoteCommand(command: String?): String? {
return delegate.executeRemoteCommand(command)
}
override fun executeRemoteCommand(
command: String?,
stderr: OutputStream?,
charset: Charset?
): String? {
return delegate.executeRemoteCommand(command, stderr, charset)
}
override fun executeRemoteCommand(
command: String?,
stdout: OutputStream?,
stderr: OutputStream?,
charset: Charset?
) {
delegate.executeRemoteCommand(command, stdout, stderr, charset)
}
override fun createLocalPortForwardingTracker(
localPort: Int,
remote: SshdSocketAddress?
): ExplicitPortForwardingTracker? {
return delegate.createLocalPortForwardingTracker(localPort, remote)
}
override fun createLocalPortForwardingTracker(
local: SshdSocketAddress?,
remote: SshdSocketAddress?
): ExplicitPortForwardingTracker? {
return delegate.createLocalPortForwardingTracker(local, remote)
}
override fun createRemotePortForwardingTracker(
remote: SshdSocketAddress?,
local: SshdSocketAddress?
): ExplicitPortForwardingTracker? {
return delegate.createRemotePortForwardingTracker(remote, local)
}
override fun createDynamicPortForwardingTracker(local: SshdSocketAddress?): DynamicPortForwardingTracker? {
return delegate.createDynamicPortForwardingTracker(local)
}
override fun waitFor(
mask: Collection<ClientSession.ClientSessionEvent?>?,
timeout: Duration?
): Set<ClientSession.ClientSessionEvent?>? {
return delegate.waitFor(mask, timeout)
}
override fun createBuffer(cmd: Byte): Buffer? {
return delegate.createBuffer(cmd)
}
override fun writePacket(
buffer: Buffer?,
timeout: Duration?
): IoWriteFuture? {
return delegate.writePacket(buffer, timeout)
}
override fun writePacket(
buffer: Buffer?,
maxWaitMillis: Long
): IoWriteFuture? {
return delegate.writePacket(buffer, maxWaitMillis)
}
override fun request(
request: String?,
buffer: Buffer?,
timeout: Long,
unit: TimeUnit?
): Buffer? {
return delegate.request(request, buffer, timeout, unit)
}
override fun request(
request: String?,
buffer: Buffer?,
timeout: Duration?
): Buffer? {
return delegate.request(request, buffer, timeout)
}
override fun getLocalAddress(): SocketAddress? {
return delegate.getLocalAddress()
}
override fun getRemoteAddress(): SocketAddress? {
return delegate.getRemoteAddress()
}
override fun <T : Any?> resolveAttribute(key: AttributeRepository.AttributeKey<T?>?): T? {
return delegate.resolveAttribute(key)
}
override fun getSessionHeartbeatType(): SessionHeartbeatController.HeartbeatType? {
return delegate.getSessionHeartbeatType()
}
override fun getSessionHeartbeatInterval(): Duration? {
return delegate.getSessionHeartbeatInterval()
}
override fun disableSessionHeartbeat() {
delegate.disableSessionHeartbeat()
}
override fun setSessionHeartbeat(
type: SessionHeartbeatController.HeartbeatType?,
unit: TimeUnit?,
count: Long
) {
delegate.setSessionHeartbeat(type, unit, count)
}
override fun setSessionHeartbeat(
type: SessionHeartbeatController.HeartbeatType?,
interval: Duration?
) {
delegate.setSessionHeartbeat(type, interval)
}
override fun isEmpty(): Boolean {
return delegate.isEmpty()
}
override fun getLongProperty(name: String?, def: Long): Long {
return delegate.getLongProperty(name, def)
}
override fun getLong(name: String?): Long? {
return delegate.getLong(name)
}
override fun getIntProperty(name: String?, def: Int): Int {
return delegate.getIntProperty(name, def)
}
override fun getInteger(name: String?): Int? {
return delegate.getInteger(name)
}
override fun getBooleanProperty(name: String?, def: Boolean): Boolean {
return delegate.getBooleanProperty(name, def)
}
override fun getBoolean(name: String?): Boolean? {
return delegate.getBoolean(name)
}
override fun getStringProperty(name: String?, def: String?): String? {
return delegate.getStringProperty(name, def)
}
override fun getString(name: String?): String? {
return delegate.getString(name)
}
override fun getObject(name: String?): Any? {
return delegate.getObject(name)
}
override fun getCharset(
name: String?,
defaultValue: Charset?
): Charset? {
return delegate.getCharset(name, defaultValue)
}
override fun <T : Any?> computeAttributeIfAbsent(
key: AttributeRepository.AttributeKey<T?>?,
resolver: Function<in AttributeRepository.AttributeKey<T>, out T?>?
): T? {
return delegate.computeAttributeIfAbsent(key, resolver)
}
override fun close() {
close(true)?.await()
}
override fun close(immediately: Boolean): CloseFuture? {
synchronized(delegate) {
if (refCount.decrementAndGet() < 1) {
delegate.close(immediately).await()
return client.close(immediately)
}
}
return DefaultCloseFuture(this, this).apply { setClosed() }
}
override fun isOpen(): Boolean {
return delegate.isOpen()
}
override fun getCipherFactoriesNameList(): String? {
return delegate.getCipherFactoriesNameList()
}
override fun getCipherFactoriesNames(): List<String?>? {
return delegate.getCipherFactoriesNames()
}
override fun setCipherFactoriesNameList(names: String?) {
delegate.setCipherFactoriesNameList(names)
}
override fun setCipherFactoriesNames(vararg names: String?) {
delegate.setCipherFactoriesNames(*names)
}
override fun setCipherFactoriesNames(names: Collection<String?>?) {
delegate.setCipherFactoriesNames(names)
}
override fun getCompressionFactoriesNameList(): String? {
return delegate.getCompressionFactoriesNameList()
}
override fun getCompressionFactoriesNames(): List<String?>? {
return delegate.getCompressionFactoriesNames()
}
override fun setCompressionFactoriesNameList(names: String?) {
delegate.setCompressionFactoriesNameList(names)
}
override fun setCompressionFactoriesNames(vararg names: String?) {
delegate.setCompressionFactoriesNames(*names)
}
override fun setCompressionFactoriesNames(names: Collection<String?>?) {
delegate.setCompressionFactoriesNames(names)
}
override fun getMacFactoriesNameList(): String? {
return delegate.getMacFactoriesNameList()
}
override fun getMacFactoriesNames(): List<String?>? {
return delegate.getMacFactoriesNames()
}
override fun setMacFactoriesNameList(names: String?) {
delegate.setMacFactoriesNameList(names)
}
override fun setMacFactoriesNames(vararg names: String?) {
delegate.setMacFactoriesNames(*names)
}
override fun setMacFactoriesNames(names: Collection<String?>?) {
delegate.setMacFactoriesNames(names)
}
override fun setSignatureFactoriesNameList(names: String?) {
delegate.setSignatureFactoriesNameList(names)
}
override fun setSignatureFactoriesNames(vararg names: String?) {
delegate.setSignatureFactoriesNames(*names)
}
override fun setSignatureFactoriesNames(names: Collection<String?>?) {
delegate.setSignatureFactoriesNames(names)
}
override fun getSignatureFactoriesNameList(): String? {
return delegate.getSignatureFactoriesNameList()
}
override fun getSignatureFactoriesNames(): List<String?>? {
return delegate.getSignatureFactoriesNames()
}
override fun resolveChannelStreamWriterResolver(): ChannelStreamWriterResolver? {
return delegate.resolveChannelStreamWriterResolver()
}
override fun resolveChannelStreamWriter(
channel: Channel?,
cmd: Byte
): ChannelStreamWriter? {
return delegate.resolveChannelStreamWriter(channel, cmd)
}
override fun isLocalPortForwardingStartedForPort(port: Int): Boolean {
return delegate.isLocalPortForwardingStartedForPort(port)
}
override fun isRemotePortForwardingStartedForPort(port: Int): Boolean {
return delegate.isRemotePortForwardingStartedForPort(port)
}
override fun setUserAuthFactoriesNames(names: Collection<String?>?) {
delegate.setUserAuthFactoriesNames(names)
}
override fun setUserAuthFactoriesNames(vararg names: String?) {
delegate.setUserAuthFactoriesNames(*names)
}
override fun getUserAuthFactoriesNameList(): String? {
return delegate.getUserAuthFactoriesNameList()
}
override fun getUserAuthFactoriesNames(): List<String?>? {
return delegate.getUserAuthFactoriesNames()
}
override fun setUserAuthFactoriesNameList(names: String?) {
delegate.setUserAuthFactoriesNameList(names)
}
override fun startLocalPortForwarding(
localPort: Int,
remote: SshdSocketAddress?
): SshdSocketAddress? {
return delegate.startLocalPortForwarding(localPort, remote)
}
}
}

View File

@@ -0,0 +1,448 @@
package app.termora.plugin.internal.telnet
import app.termora.*
import app.termora.account.AccountOwner
import app.termora.keymgr.KeyManager
import app.termora.plugin.internal.BasicProxyOption
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.ui.FlatTextBorder
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout
import java.awt.Component
import java.awt.KeyboardFocusManager
import java.awt.Window
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.nio.charset.Charset
import javax.swing.*
class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPane() {
protected val generalOption = GeneralOption()
// telnet 不支持代理密码
private val proxyOption = BasicProxyOption(authenticationTypes = listOf())
private val terminalOption = TerminalOption()
private val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init {
addOption(generalOption)
addOption(proxyOption)
addOption(terminalOption)
}
fun getHost(): Host {
val name = generalOption.nameTextField.text
val protocol = TelnetProtocolProvider.PROTOCOL
val host = generalOption.hostTextField.text
val port = (generalOption.portTextField.value ?: 23) as Int
var authentication = Authentication.No
var proxy = Proxy.Companion.No
val authenticationType = generalOption.authenticationTypeComboBox.selectedItem as AuthenticationType
if (authenticationType == AuthenticationType.Password) {
authentication = authentication.copy(
type = authenticationType,
password = String(generalOption.passwordTextField.password)
)
}
if (proxyOption.proxyTypeComboBox.selectedItem != ProxyType.No) {
proxy = proxy.copy(
type = proxyOption.proxyTypeComboBox.selectedItem as ProxyType,
host = proxyOption.proxyHostTextField.text,
username = proxyOption.proxyUsernameTextField.text,
password = String(proxyOption.proxyPasswordTextField.password),
port = proxyOption.proxyPortTextField.value as Int,
authenticationType = proxyOption.proxyAuthenticationTypeComboBox.selectedItem as AuthenticationType,
)
}
val serialComm = SerialComm()
val options = Options.Companion.Default.copy(
encoding = terminalOption.charsetComboBox.selectedItem as String,
env = terminalOption.environmentTextArea.text,
startupCommand = terminalOption.startupCommandTextField.text,
serialComm = serialComm,
extras = mutableMapOf("backspace" to (terminalOption.backspaceComboBox.selectedItem as Backspace).name)
)
return Host(
name = name,
protocol = protocol,
host = host,
port = port,
username = generalOption.usernameTextField.text,
authentication = authentication,
proxy = proxy,
sort = System.currentTimeMillis(),
remark = generalOption.remarkTextArea.text,
options = options,
)
}
fun setHost(host: Host) {
generalOption.portTextField.value = host.port
generalOption.nameTextField.text = host.name
generalOption.usernameTextField.text = host.username
generalOption.hostTextField.text = host.host
generalOption.remarkTextArea.text = host.remark
generalOption.authenticationTypeComboBox.selectedItem = host.authentication.type
if (host.authentication.type == AuthenticationType.Password) {
generalOption.passwordTextField.text = host.authentication.password
}
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
proxyOption.proxyHostTextField.text = host.proxy.host
proxyOption.proxyPasswordTextField.text = host.proxy.password
proxyOption.proxyUsernameTextField.text = host.proxy.username
proxyOption.proxyPortTextField.value = host.proxy.port
proxyOption.proxyAuthenticationTypeComboBox.selectedItem = host.proxy.authenticationType
terminalOption.charsetComboBox.selectedItem = host.options.encoding
terminalOption.environmentTextArea.text = host.options.env
terminalOption.startupCommandTextField.text = host.options.startupCommand
terminalOption.backspaceComboBox.selectedItem =
Backspace.valueOf(host.options.extras["backspace"] ?: Backspace.Delete.name)
}
fun validateFields(): Boolean {
val host = getHost()
// general
if (validateField(generalOption.nameTextField)
|| validateField(generalOption.hostTextField)
) {
return false
}
if (host.authentication.type == AuthenticationType.Password) {
if (validateField(generalOption.usernameTextField)) {
return false
}
if (validateField(generalOption.passwordTextField)) {
return false
}
}
// proxy
if (host.proxy.type != ProxyType.No) {
if (validateField(proxyOption.proxyHostTextField)
) {
return false
}
if (host.proxy.authenticationType != AuthenticationType.No) {
if (validateField(proxyOption.proxyUsernameTextField)
|| validateField(proxyOption.proxyPasswordTextField)
) {
return false
}
}
}
return true
}
/**
* 返回 true 表示有错误
*/
private fun validateField(textField: JTextField): Boolean {
if (textField.isEnabled && textField.text.isBlank()) {
setOutlineError(textField)
return true
}
return false
}
private fun setOutlineError(textField: JTextField) {
selectOptionJComponent(textField)
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
textField.requestFocusInWindow()
}
/**
* 返回 true 表示有错误
*/
private fun validateField(comboBox: JComboBox<*>): Boolean {
val selectedItem = comboBox.selectedItem
if (comboBox.isEnabled && (selectedItem == null || (selectedItem is String && selectedItem.isBlank()))) {
selectOptionJComponent(comboBox)
comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
comboBox.requestFocusInWindow()
return true
}
return false
}
protected inner class GeneralOption : JPanel(BorderLayout()), Option {
val portTextField = PortSpinner(23)
val nameTextField = OutlineTextField(128)
val usernameTextField = OutlineTextField(128)
val hostTextField = OutlineTextField(255)
val passwordTextField = OutlinePasswordField(255)
val publicKeyComboBox = OutlineComboBox<String>()
val remarkTextArea = FixedLengthTextArea(512)
val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
publicKeyComboBox.isEditable = false
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = StringUtils.EMPTY
if (value is String) {
text = KeyManager.getInstance().getOhKeyPair(value)?.name ?: text
}
return super.getListCellRendererComponent(
list,
text,
index,
isSelected,
cellHasFocus
)
}
}
authenticationTypeComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value?.toString() ?: ""
when (value) {
AuthenticationType.Password -> {
text = "Password"
}
AuthenticationType.PublicKey -> {
text = "Public Key"
}
AuthenticationType.KeyboardInteractive -> {
text = "Keyboard Interactive"
}
}
return super.getListCellRendererComponent(
list,
text,
index,
isSelected,
cellHasFocus
)
}
}
authenticationTypeComboBox.addItem(AuthenticationType.No)
authenticationTypeComboBox.addItem(AuthenticationType.Password)
authenticationTypeComboBox.selectedItem = AuthenticationType.Password
}
private fun initEvents() {
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
SwingUtilities.invokeLater { nameTextField.requestFocusInWindow() }
removeComponentListener(this)
}
})
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.settings
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.general")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, pref, $FORM_MARGIN, default",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS)
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS)
)
remarkTextArea.rows = 8
remarkTextArea.lineWrap = true
remarkTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.new-host.general.name")}:").xy(1, rows)
.add(nameTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.host")}:").xy(1, rows)
.add(hostTextField).xy(3, rows)
.add("${I18n.getString("termora.new-host.general.port")}:").xy(5, rows)
.add(portTextField).xy(7, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.username")}:").xy(1, rows)
.add(usernameTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, rows)
.add(authenticationTypeComboBox).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, rows)
.add(passwordTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.remark")}:").xy(1, rows)
.add(JScrollPane(remarkTextArea).apply { border = FlatTextBorder() })
.xyw(3, rows, 5).apply { rows += step }
.build()
return panel
}
}
private inner class TerminalOption : JPanel(BorderLayout()), Option {
val charsetComboBox = JComboBox<String>()
val backspaceComboBox = JComboBox<Backspace>()
val startupCommandTextField = OutlineTextField()
val environmentTextArea = FixedLengthTextArea(2048)
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
backspaceComboBox.addItem(Backspace.Delete)
backspaceComboBox.addItem(Backspace.Backspace)
backspaceComboBox.addItem(Backspace.VT220)
environmentTextArea.setFocusTraversalKeys(
KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS)
)
environmentTextArea.setFocusTraversalKeys(
KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS)
)
environmentTextArea.rows = 8
environmentTextArea.lineWrap = true
environmentTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
for (e in Charset.availableCharsets()) {
charsetComboBox.addItem(e.key)
}
charsetComboBox.selectedItem = "UTF-8"
}
private fun initEvents() {
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.terminal
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.terminal")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.new-host.terminal.encoding")}:").xy(1, rows)
.add(charsetComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.backspace")}:").xy(1, rows)
.add(backspaceComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.startup-commands")}:").xy(1, rows)
.add(startupCommandTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.env")}:").xy(1, rows)
.add(JScrollPane(environmentTextArea).apply { border = FlatTextBorder() }).xy(3, rows)
.apply { rows += step }
.build()
return panel
}
}
enum class Backspace {
/**
* 0x08
*/
Backspace,
/**
* 0x7F 默认
*/
Delete,
/**
* ESC[3~
*/
VT220, ;
override fun toString(): String {
return when (this) {
Backspace -> "ASCII Backspace (0x08)"
Delete -> "ASCII Delete (0x7F)"
VT220 -> "VT220 Delete (ESC[3~)"
}
}
}
}

View File

@@ -0,0 +1,24 @@
package app.termora.plugin.internal.telnet
import app.termora.plugin.Extension
import app.termora.plugin.InternalPlugin
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProviderExtension
internal class TelnetInternalPlugin : InternalPlugin() {
init {
support.addExtension(ProtocolProviderExtension::class.java) { TelnetProtocolProviderExtension.instance }
support.addExtension(ProtocolHostPanelExtension::class.java) { TelnetProtocolHostPanelExtension.instance }
}
override fun getName(): String {
return "Telnet Protocol"
}
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
return support.getExtensions(clazz)
}
}

View File

@@ -0,0 +1,38 @@
package app.termora.plugin.internal.telnet
import app.termora.Disposer
import app.termora.Host
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import java.awt.BorderLayout
class TelnetProtocolHostPanel(accountOwner: AccountOwner) : ProtocolHostPanel() {
private val pane = TelnetHostOptionsPane(accountOwner)
init {
initView()
initEvents()
}
private fun initView() {
add(pane, BorderLayout.CENTER)
Disposer.register(this, pane)
}
private fun initEvents() {}
override fun getHost(): Host {
return pane.getHost()
}
override fun setHost(host: Host) {
pane.setHost(host)
}
override fun validateFields(): Boolean {
return pane.validateFields()
}
}

View File

@@ -0,0 +1,25 @@
package app.termora.plugin.internal.telnet
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
internal class TelnetProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension {
companion object {
val instance = TelnetProtocolHostPanelExtension()
}
override fun getProtocolProvider(): ProtocolProvider {
return TelnetProtocolProvider.instance
}
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return TelnetProtocolHostPanel(accountOwner)
}
override fun ordered(): Long {
return 4
}
}

View File

@@ -0,0 +1,26 @@
package app.termora.plugin.internal.telnet
import app.termora.*
import app.termora.actions.DataProvider
import app.termora.protocol.GenericProtocolProvider
internal class TelnetProtocolProvider private constructor() : GenericProtocolProvider {
companion object {
val instance by lazy { TelnetProtocolProvider() }
const val PROTOCOL = "Telnet"
}
override fun getProtocol(): String {
return PROTOCOL
}
override fun createTerminalTab(dataProvider: DataProvider, windowScope: WindowScope, host: Host): TerminalTab {
return TelnetTerminalTab(windowScope, host)
}
override fun getIcon(width: Int, height: Int): DynamicIcon {
return Icons.telnet
}
override fun ordered() = Int.MIN_VALUE
}

View File

@@ -0,0 +1,14 @@
package app.termora.plugin.internal.telnet
import app.termora.protocol.ProtocolProvider
import app.termora.protocol.ProtocolProviderExtension
internal class TelnetProtocolProviderExtension private constructor() : ProtocolProviderExtension {
companion object {
val instance = TelnetProtocolProviderExtension()
}
override fun getProtocolProvider(): ProtocolProvider {
return TelnetProtocolProvider.instance
}
}

View File

@@ -0,0 +1,42 @@
package app.termora.plugin.internal.telnet
import app.termora.terminal.StreamPtyConnector
import org.apache.commons.net.telnet.TelnetClient
import org.apache.commons.net.telnet.TelnetOption
import org.apache.commons.net.telnet.WindowSizeOptionHandler
import java.io.InputStreamReader
import java.nio.charset.Charset
class TelnetStreamPtyConnector(
private val telnet: TelnetClient,
private val charset: Charset
) :
StreamPtyConnector(telnet.inputStream, telnet.outputStream) {
private val reader = InputStreamReader(telnet.inputStream, getCharset())
override fun read(buffer: CharArray): Int {
return reader.read(buffer)
}
override fun write(buffer: ByteArray, offset: Int, len: Int) {
output.write(buffer, offset, len)
output.flush()
}
override fun resize(rows: Int, cols: Int) {
telnet.deleteOptionHandler(TelnetOption.WINDOW_SIZE)
telnet.addOptionHandler(WindowSizeOptionHandler(cols, rows, true, false, true, false))
}
override fun waitFor(): Int {
return -1
}
override fun close() {
telnet.disconnect()
}
override fun getCharset(): Charset {
return charset
}
}

View File

@@ -0,0 +1,93 @@
package app.termora.plugin.internal.telnet
import app.termora.*
import app.termora.terminal.ControlCharacters
import app.termora.terminal.KeyEncoderImpl
import app.termora.terminal.PtyConnector
import app.termora.terminal.TerminalKeyEvent
import org.apache.commons.net.telnet.*
import java.awt.event.KeyEvent
import java.net.InetSocketAddress
import java.net.Proxy
import java.nio.charset.Charset
class TelnetTerminalTab(
windowScope: WindowScope, host: Host,
) : PtyHostTerminalTab(windowScope, host) {
override suspend fun openPtyConnector(): PtyConnector {
val winSize = terminalPanel.winSize()
val telnet = TelnetClient()
telnet.charset = Charset.forName(host.options.encoding)
telnet.connectTimeout = 60 * 1000
if (host.proxy.type == ProxyType.HTTP) {
telnet.proxy = Proxy(
Proxy.Type.HTTP,
InetSocketAddress(host.proxy.host, host.proxy.port)
)
} else if (host.proxy.type == ProxyType.SOCKS5) {
telnet.proxy = Proxy(
Proxy.Type.SOCKS,
InetSocketAddress(host.proxy.host, host.proxy.port)
)
}
val termtype = host.options.envs()["TERM"] ?: "xterm-256color"
val ttopt = TerminalTypeOptionHandler(termtype, false, false, true, false)
val echoopt = EchoOptionHandler(false, true, false, true)
val gaopt = SuppressGAOptionHandler(true, true, true, true)
val wsopt = WindowSizeOptionHandler(winSize.cols, winSize.rows, true, false, true, false)
telnet.addOptionHandler(ttopt)
telnet.addOptionHandler(echoopt)
telnet.addOptionHandler(gaopt)
telnet.addOptionHandler(wsopt)
telnet.connect(host.host, host.port)
telnet.keepAlive = true
val encoder = terminal.getKeyEncoder()
if (encoder is KeyEncoderImpl) {
val backspace = host.options.extras["backspace"]
if (backspace == TelnetHostOptionsPane.Backspace.Backspace.name) {
encoder.putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_BACK_SPACE), String(byteArrayOf(0x08)))
} else if (backspace == TelnetHostOptionsPane.Backspace.VT220.name) {
encoder.putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_BACK_SPACE), "${ControlCharacters.ESC}[3~")
}
}
return ptyConnectorFactory.decorate(TelnetStreamPtyConnector(telnet, telnet.charset))
}
override fun loginScriptsPtyConnector(host: Host, ptyConnector: PtyConnector): PtyConnector {
if (host.authentication.type != AuthenticationType.Password) {
return ptyConnector
}
val scripts = mutableListOf<LoginScript>()
scripts.add(
LoginScript(
expect = "login:",
send = host.username,
regex = false,
matchCase = false
)
)
scripts.add(
LoginScript(
expect = "password:",
send = host.authentication.password,
regex = false,
matchCase = false
)
)
return super.loginScriptsPtyConnector(
host.copy(options = host.options.copy(loginScripts = scripts)),
ptyConnector
)
}
}

View File

@@ -10,7 +10,6 @@ interface ProtocolHostPanelExtension : Extension {
val extensions
get() = ExtensionManager.getInstance()
.getExtensions(ProtocolHostPanelExtension::class.java)
.sortedBy { it.getProtocolProvider().ordered() }
}
/**

View File

@@ -28,8 +28,8 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
configureLeftRight()
// Ctrl + C
putCode(TerminalKeyEvent(keyCode = 8), String(byteArrayOf(127)))
// Ctrl + C: 0x7F ASCII Delete
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_BACK_SPACE), String(byteArrayOf(0x7F)))
// Enter
if (terminalModel.getData(DataKey.AutoNewline, false)) {
@@ -113,7 +113,7 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
return terminal
}
private fun putCode(event: TerminalKeyEvent, encode: String) {
internal fun putCode(event: TerminalKeyEvent, encode: String) {
mapping[event] = encode
}
@@ -202,7 +202,7 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
|| key == KeyEvent.VK_PAGE_UP || key == KeyEvent.VK_PAGE_DOWN
}
fun arrowKeysApplicationSequences() {
private fun arrowKeysApplicationSequences() {
// Up
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_UP), encode = "${ControlCharacters.ESC}OA")
// Down
@@ -213,7 +213,7 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_RIGHT), encode = "${ControlCharacters.ESC}OC")
}
fun arrowKeysAnsiCursorSequences() {
private fun arrowKeysAnsiCursorSequences() {
// Up
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_UP), encode = "${ControlCharacters.ESC}[A")
// Down
@@ -227,7 +227,7 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
/**
* Alt + Left/Right
*/
fun configureLeftRight() {
private fun configureLeftRight() {
if (SystemInfo.isMacOS) {
putCode(
TerminalKeyEvent(keyCode = KeyEvent.VK_LEFT, TerminalEvent.ALT_MASK),
@@ -262,7 +262,7 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
}
fun keypadApplicationSequences() {
private fun keypadApplicationSequences() {
// Up
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_UP), encode = "${ControlCharacters.ESC}OA")
// Down
@@ -277,7 +277,7 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_END), encode = "${ControlCharacters.ESC}OF")
}
fun keypadAnsiSequences() {
private fun keypadAnsiSequences() {
// Up
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_UP), encode = "${ControlCharacters.ESC}[A")
// Down

View File

@@ -3,8 +3,8 @@ package app.termora.terminal.panel.vw
import app.termora.Disposer
import app.termora.I18n
import app.termora.Icons
import app.termora.SshClients
import app.termora.plugin.internal.ssh.SSHTerminalTab
import app.termora.plugin.internal.ssh.SshClients
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
@@ -28,7 +28,7 @@ import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathFactory
import kotlin.time.Duration.Companion.milliseconds
class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
internal class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "NVIDIA-SMI", visualWindowManager) {
companion object {

View File

@@ -1,7 +1,11 @@
package app.termora.terminal.panel.vw
import app.termora.*
import app.termora.Disposer
import app.termora.DynamicColor
import app.termora.I18n
import app.termora.formatBytes
import app.termora.plugin.internal.ssh.SSHTerminalTab
import app.termora.plugin.internal.ssh.SshClients
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
@@ -16,7 +20,7 @@ import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel
class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
internal class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "SystemInformation", visualWindowManager) {
companion object {

View File

@@ -38,7 +38,7 @@ import kotlin.reflect.cast
import kotlin.time.Duration.Companion.milliseconds
class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
internal class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "Transfer", visualWindowManager) {
companion object {
@@ -99,9 +99,11 @@ class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindo
if (key == DataKey.CurrentDir) {
val dir = DataKey.CurrentDir.clazz.cast(data)
val navigator = getTransportNavigator() ?: return
val path = navigator.getFileSystem().getPath(dir)
if (path == navigator.workdir) return
navigator.navigateTo(path)
val loader = navigator.loader
if (loader.isOpened().not()) return
val fileSystem = loader.getSyncTransportSupport().getFileSystem()
val path = fileSystem.getPath(dir)
navigator.navigateTo(path.absolutePathString())
}
}
})
@@ -146,14 +148,25 @@ class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindo
try {
val session = getSession()
val fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
val support = TransportSupport(fileSystem, fileSystem.defaultDir.absolutePathString())
val support = DefaultTransportSupport(fileSystem, fileSystem.defaultDir)
withContext(Dispatchers.Swing) {
val internalTransferManager = MyInternalTransferManager()
val transportPanel = TransportPanel(
internalTransferManager, tab.host,
TransportSupportLoader { support })
internalTransferManager.setTransferPanel(transportPanel)
object : TransportSupportLoader {
override suspend fun getTransportSupport(): TransportSupport {
return support
}
override fun getSyncTransportSupport(): TransportSupport {
return support
}
override fun isLoaded(): Boolean {
return true
}
})
internalTransferManager.setTransferPanel(transportPanel)
Disposer.register(transportPanel, object : Disposable {
override fun dispose() {
panel.remove(transportPanel)

View File

@@ -0,0 +1,34 @@
package app.termora.transfer
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath
import kotlin.math.max
class CommandTransfer(
parentId: String,
path: SftpPath,
isDirectory: Boolean,
private val size: Long,
val command: String,
) : AbstractTransfer(parentId, path, path, isDirectory) {
private var executed = false
override suspend fun transfer(bufferSize: Int): Long {
if (executed) return 0
val fs = source().fileSystem as SftpFileSystem
fs.session.executeRemoteCommand(command)
executed = true
return this.size()
}
override fun scanning(): Boolean {
return false
}
override fun size(): Long {
return max(size, 1)
}
}

View File

@@ -11,6 +11,7 @@ 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.awt.Component
import java.awt.Dimension
@@ -31,6 +32,7 @@ import kotlin.collections.ArrayDeque
import kotlin.collections.List
import kotlin.collections.Set
import kotlin.collections.isNotEmpty
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists
import kotlin.io.path.name
import kotlin.io.path.pathString
@@ -63,7 +65,9 @@ class DefaultInternalTransferManager(
override fun canTransfer(paths: List<Path>): Boolean {
return paths.isNotEmpty() && target.getWorkdir() != null
val c = target.getWorkdir() ?: return false
if (c.fileSystem.isOpen.not()) return false
return paths.isNotEmpty()
}
override fun addTransfer(
@@ -267,8 +271,9 @@ class DefaultInternalTransferManager(
val isDirectory = pair.second.isDirectory
val path = pair.first
if (isDirectory.not()) {
val transfer = createTransfer(path, workdir.resolve(path.name), false, StringUtils.EMPTY, mode, action)
if (isDirectory.not() || mode == TransferMode.Rmrf) {
val transfer =
createTransfer(path, workdir.resolve(path.name), isDirectory, StringUtils.EMPTY, mode, action)
return if (transferManager.addTransfer(transfer)) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
}
@@ -363,39 +368,49 @@ class DefaultInternalTransferManager(
action:TransferAction,
permissions: Set<PosixFilePermission>? = null
): Transfer {
if (mode == TransferMode.Delete) {
return DeleteTransfer(
parentId,
source,
isDirectory,
if (isDirectory) 1 else Files.size(source)
)
} else if (mode == TransferMode.ChangePermission) {
if (permissions == null) throw IllegalStateException()
return ChangePermissionTransfer(
parentId,
target,
isDirectory = isDirectory,
permissions = permissions,
size = if (isDirectory) 1 else Files.size(target)
)
}
if (isDirectory) {
return DirectoryTransfer(
when {
mode == TransferMode.Delete -> {
return DeleteTransfer(
parentId,
source,
isDirectory,
if (isDirectory) 1 else Files.size(source)
)
}
mode == TransferMode.Rmrf -> {
return CommandTransfer(
parentId,
source as SftpPath,
isDirectory,
if (isDirectory) 1 else Files.size(source),
"rm -rf ${source.absolutePathString()}",
)
}
mode == TransferMode.ChangePermission -> {
if (permissions == null) throw IllegalStateException()
return ChangePermissionTransfer(
parentId,
target,
isDirectory = isDirectory,
permissions = permissions,
size = if (isDirectory) 1 else Files.size(target)
)
}
isDirectory -> {
return DirectoryTransfer(
parentId = parentId,
source = source,
target = target,
)
}
else -> return FileTransfer(
parentId = parentId,
source = source,
target = target,
action = action,
size = Files.size(source)
)
}
return FileTransfer(
parentId = parentId,
source = source,
target = target,
action = action,
size = Files.size(source)
)
}

View File

@@ -0,0 +1,14 @@
package app.termora.transfer
import java.nio.file.FileSystem
import java.nio.file.Path
class DefaultTransportSupport(private val fileSystem: FileSystem, private val defaultPath: Path) : TransportSupport {
override fun getFileSystem(): FileSystem {
return fileSystem
}
override fun getDefaultPath(): Path {
return defaultPath
}
}

View File

@@ -9,6 +9,7 @@ interface InternalTransferManager {
Delete,
Transfer,
ChangePermission,
Rmrf,
}
/**

View File

@@ -0,0 +1,102 @@
package app.termora.transfer
import app.termora.*
import app.termora.protocol.PathHandler
import app.termora.protocol.PathHandlerRequest
import app.termora.protocol.TransferProtocolProvider
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.slf4j.LoggerFactory
import java.awt.Window
import java.nio.file.FileSystem
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicReference
internal class ReconnectableTransportSupportLoader(private val owner: Window, private val host: Host) :
TransportSupportLoader {
companion object {
private val log = LoggerFactory.getLogger(ReconnectableTransportSupportLoader::class.java)
}
private val mutex = Mutex()
private val reference = AtomicReference<MyTransportSupport>()
private var support: MyTransportSupport?
set(value) = reference.set(value)
get() = reference.get()
override suspend fun getTransportSupport(): TransportSupport {
mutex.withLock {
var c = support
if (c != null) {
if (c.getFileSystem().isOpen) {
return c
}
if (log.isWarnEnabled) {
log.warn("Host {} has been disconnected and will reconnect soon", host.name)
}
support = null
Disposer.dispose(c)
}
c = connect().also { support = it }
return c
}
}
override fun getSyncTransportSupport(): TransportSupport {
assertEventDispatchThread()
val c = support
if (c == null) throw IllegalStateException("No transport support")
return c
}
override fun isLoaded(): Boolean {
assertEventDispatchThread()
return support != null
}
override fun isOpened(): Boolean {
if (isLoaded().not()) return false
val c = support ?: return false
return c.getFileSystem().isOpen
}
override fun dispose() {
val c = support
if (c != null) {
Disposer.dispose(c)
}
}
override fun isOpening(): Boolean {
if (isOpened()) return false
return mutex.isLocked
}
private fun connect(): MyTransportSupport {
val provider = TransferProtocolProvider.valueOf(host.protocol)
if (provider == null) {
throw IllegalStateException(I18n.getString("termora.protocol.not-supported", host.protocol))
}
val handler = provider.createPathHandler(PathHandlerRequest(host, owner))
return MyTransportSupport(handler)
}
private inner class MyTransportSupport(private val handler: PathHandler) : TransportSupport, Disposable {
init {
Disposer.register(this, handler)
}
override fun getFileSystem(): FileSystem {
return handler.fileSystem
}
override fun getDefaultPath(): Path {
return handler.path
}
}
}

View File

@@ -398,7 +398,9 @@ class TransferTableModel(private val coroutineScope: CoroutineScope) :
if (continueTransfer(node, false)) {
doTransfer(node)
} else {
changeState(node, State.Failed)
withContext(Dispatchers.Swing) {
changeState(node, State.Failed)
}
}
}
lock.withLock { condition.signalAll() }

View File

@@ -77,13 +77,15 @@ class TransferTreeTableNode(transfer: Transfer) : DefaultMutableTreeTableNode(tr
private fun formatPath(path: Path, target: Boolean): String {
if (target) {
if (transfer is DeleteTransfer) {
return I18n.getString("termora.transport.sftp.status.deleting")
} else if (transfer is ChangePermissionTransfer) {
val permissions = (transfer as ChangePermissionTransfer).permissions
// @formatter:off
return "${I18n.getString("termora.transport.table.permissions")} -> ${PosixFilePermissions.toString(permissions)}"
// @formatter:on
when (transfer) {
is DeleteTransfer -> return I18n.getString("termora.transport.sftp.status.deleting")
is CommandTransfer -> return (transfer as CommandTransfer).command
is ChangePermissionTransfer -> {
val permissions = (transfer as ChangePermissionTransfer).permissions
// @formatter:off
return "${I18n.getString("termora.transport.table.permissions")} -> ${PosixFilePermissions.toString(permissions)}"
// @formatter:on
}
}
}
@@ -95,7 +97,7 @@ class TransferTreeTableNode(transfer: Transfer) : DefaultMutableTreeTableNode(tr
}
private fun formatStatus(state: State): String {
if (transfer is DeleteTransfer && state == State.Processing) {
if ((transfer is DeleteTransfer || transfer is CommandTransfer) && state == State.Processing) {
return I18n.getString("termora.transport.sftp.status.deleting")
}

View File

@@ -2,7 +2,6 @@ package app.termora.transfer
import app.termora.DynamicColor
import app.termora.Icons
import app.termora.OptionPane
import app.termora.transfer.TransportPanel.Companion.isWindowsFileSystem
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
@@ -14,8 +13,6 @@ import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.FilenameUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.slf4j.LoggerFactory
import java.awt.CardLayout
import java.awt.Dimension
import java.awt.Insets
@@ -24,7 +21,6 @@ import java.awt.event.*
import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener
import java.nio.file.Path
import java.util.function.Supplier
import javax.swing.*
import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener
@@ -33,13 +29,9 @@ import kotlin.io.path.name
import kotlin.io.path.pathString
import kotlin.math.round
class TransportNavigationPanel(
private val support: Supplier<TransportSupport>,
private val navigator: TransportNavigator
) : JPanel() {
internal class TransportNavigationPanel(private val navigator: TransportNavigator) : JPanel() {
companion object {
private val log = LoggerFactory.getLogger(TransportNavigationPanel::class.java)
private const val TEXT_FIELD = "TextField"
private const val SEGMENTS = "Segments"
@@ -50,7 +42,6 @@ class TransportNavigationPanel(
}
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val layeredPane = LayeredPane()
private val textField = FlatTextField()
private val downBtn = JButton(Icons.chevronDown)
@@ -115,8 +106,7 @@ class TransportNavigationPanel(
val itemListener = object : ItemListener {
override fun itemStateChanged(e: ItemEvent) {
val path = comboBox.selectedItem as Path? ?: return
if (navigator.loading) return
navigator.navigateTo(path)
navigator.navigateTo(path.absolutePathString())
}
}
@@ -179,17 +169,7 @@ class TransportNavigationPanel(
override fun actionPerformed(e: ActionEvent) {
if (navigator.loading) return
if (textField.text.isBlank()) return
try {
val path = support.get().fileSystem.getPath(textField.text)
navigator.navigateTo(path)
} catch (e: Exception) {
if (log.isErrorEnabled) log.error(e.message, e)
OptionPane.showMessageDialog(
owner, ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
navigator.navigateTo(textField.text)
}
})
@@ -257,11 +237,17 @@ class TransportNavigationPanel(
button.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON)
button.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (navigator.loading) return
if (path == navigator.workdir) {
setTextFieldText(path)
} else {
navigator.navigateTo(path)
if (SwingUtilities.isLeftMouseButton(e)) {
if (navigator.loading) return
if (path == navigator.workdir) {
setTextFieldText(path)
} else {
if (path.fileSystem.isWindowsFileSystem() && path.pathString == path.fileSystem.separator) {
navigator.navigateTo(path.pathString)
} else {
navigator.navigateTo(path.absolutePathString())
}
}
}
}
})
@@ -353,7 +339,7 @@ class TransportNavigationPanel(
text = item.pathString
}
}
popupMenu.add(text).addActionListener { navigator.navigateTo(item) }
popupMenu.add(text).addActionListener { navigator.navigateTo(item.absolutePathString()) }
}
popupMenu.show(
button,

View File

@@ -8,7 +8,7 @@ interface TransportNavigator {
val loading: Boolean
val workdir: Path?
fun navigateTo(destination: Path): Boolean
fun navigateTo(destination: String): Boolean
fun addPropertyChangeListener(propertyName: String, listener: PropertyChangeListener)
fun removePropertyChangeListener(propertyName: String, listener: PropertyChangeListener)

View File

@@ -18,7 +18,6 @@ import org.apache.commons.lang3.ArrayUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.WithFileAttributes
import org.jdesktop.swingx.JXBusyLabel
import org.jdesktop.swingx.JXPanel
@@ -34,7 +33,6 @@ import java.awt.event.*
import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener
import java.io.File
import java.io.OutputStream
import java.nio.file.FileSystem
import java.nio.file.FileSystems
import java.nio.file.Files
@@ -61,9 +59,9 @@ import kotlin.io.path.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class TransportPanel(
internal class TransportPanel(
private val transferManager: InternalTransferManager,
var host: Host,
val host: Host,
val loader: TransportSupportLoader,
) : JPanel(BorderLayout()), DataProvider, Disposable, TransportNavigator {
companion object {
@@ -120,9 +118,6 @@ class TransportPanel(
private val disposed = AtomicBoolean(false)
private val futures = Collections.synchronizedSet(mutableSetOf<Future<*>>())
private val _fileSystem by lazy { getSupport().fileSystem }
private val defaultPath by lazy { getSupport().path }
/**
* 工作目录
@@ -156,7 +151,7 @@ class TransportPanel(
toolbar.add(prevBtn)
toolbar.add(homeBtn)
toolbar.add(nextBtn)
toolbar.add(TransportNavigationPanel(loader, this))
toolbar.add(TransportNavigationPanel(this))
toolbar.add(bookmarkBtn)
toolbar.add(parentBtn)
toolbar.add(eyeBtn)
@@ -237,7 +232,7 @@ class TransportPanel(
Disposer.register(this, editTransferListener)
refreshBtn.addActionListener { reload() }
refreshBtn.addActionListener { reload(requestFocus = true) }
prevBtn.addActionListener { navigator.back() }
nextBtn.addActionListener { navigator.forward() }
@@ -245,7 +240,7 @@ class TransportPanel(
parentBtn.addActionListener(createSmartAction(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (hasParent.not()) return
navigator.navigateTo(model.getPath(0))
reload(newPath = model.getPath(0).absolutePathString(), requestFocus = true)
}
}))
@@ -254,20 +249,23 @@ class TransportPanel(
val workdir = workdir ?: return
if (e.actionCommand.isNullOrBlank()) {
if (bookmarkBtn.isBookmark) {
bookmarkBtn.deleteBookmark(workdir.absolutePathString())
bookmarkBtn.deleteBookmark(workdir.pathString)
} else {
bookmarkBtn.addBookmark(workdir.absolutePathString())
bookmarkBtn.addBookmark(workdir.pathString)
}
bookmarkBtn.isBookmark = bookmarkBtn.isBookmark.not()
} else {
navigateTo(_fileSystem.getPath(e.actionCommand))
navigateTo(e.actionCommand)
}
}
}))
homeBtn.addActionListener(createSmartAction(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
navigator.navigateTo(_fileSystem.getPath(defaultPath))
if (loader.isLoaded()) {
val home = loader.getSyncTransportSupport().getDefaultPath().absolutePathString()
reload(newPath = home, requestFocus = true)
}
}
}))
@@ -275,7 +273,7 @@ class TransportPanel(
override fun actionPerformed(e: ActionEvent) {
showHiddenFiles = showHiddenFiles.not()
eyeBtn.icon = if (showHiddenFiles) Icons.eye else Icons.eyeClose
reload()
reload(requestFocus = true)
}
}))
@@ -291,8 +289,11 @@ class TransportPanel(
transferManager.addTransferListener(object : TransferListener {
override fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) {
if (state != TransferTreeTableNode.State.Done && state != TransferTreeTableNode.State.Failed) return
if (transfer.target().fileSystem != _fileSystem) return
if (transfer.target() == workdir || transfer.target().parent == workdir) {
val target = transfer.target()
if (loader.isLoaded()) {
if (target.fileSystem != loader.getSyncTransportSupport().getFileSystem()) return
}
if (target.pathString == workdir?.pathString || target.parent.pathString == workdir?.pathString) {
reload(requestFocus = false)
}
}
@@ -344,7 +345,7 @@ class TransportPanel(
addPropertyChangeListener("workdir") { evt ->
val newValue = evt.newValue
if (newValue is Path) {
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(newValue.absolutePathString())
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(newValue.pathString)
}
}
@@ -364,7 +365,7 @@ class TransportPanel(
undoManager.addEdit(object : AbstractUndoableEdit() {
override fun undo() {
super.undo()
if (navigator.navigateTo(oldValue)) {
if (navigator.reload(newPath = oldValue.absolutePathString(), requestFocus = true)) {
undoOrRedo = true
undoOrRedoPath = oldValue
}
@@ -372,7 +373,7 @@ class TransportPanel(
override fun redo() {
super.redo()
if (navigator.navigateTo(newValue)) {
if (navigator.reload(newPath = newValue.absolutePathString(), requestFocus = true)) {
undoOrRedo = true
undoOrRedoPath = newValue
}
@@ -404,14 +405,6 @@ class TransportPanel(
}
})
table.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if (e.keyCode == KeyEvent.VK_ENTER) {
enterSelectionFolder()
}
}
})
// https://github.com/TermoraDev/termora/issues/401
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
@@ -433,10 +426,10 @@ class TransportPanel(
if (attributes.isDirectory) {
enterSelectionFolder()
} else {
transferManager.addTransfer(
listOf(model.getPath(row) to attributes),
InternalTransferManager.TransferMode.Transfer
)
val paths = listOf(model.getPath(row) to attributes)
if (loader.isOpened() && transferManager.canTransfer(paths.map { it.first })) {
transferManager.addTransfer(paths, InternalTransferManager.TransferMode.Transfer)
}
}
} else if (SwingUtilities.isRightMouseButton(e)) {
val r = table.rowAtPoint(e.point)
@@ -467,6 +460,12 @@ class TransportPanel(
}
})
table.actionMap.put("EnterSelectionFolder", object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
enterSelectionFolder()
}
})
// 快速导航
table.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
@@ -494,6 +493,7 @@ class TransportPanel(
if (SystemInfo.isMacOS.not()) {
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0), "Reload")
}
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "EnterSelectionFolder")
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_R, toolkit.menuShortcutKeyMaskEx), "Reload")
}
@@ -526,7 +526,6 @@ class TransportPanel(
}
private fun getTransferData(support: TransferSupport, load: Boolean): TransferData? {
if (loader.isLoaded.not()) return null
val workdir = workdir ?: return null
val dropLocation = support.dropLocation as? JTable.DropLocation ?: return null
val row = if (dropLocation.isInsertRow) 0 else sorter.convertRowIndexToModel(dropLocation.row)
@@ -542,7 +541,8 @@ class TransportPanel(
if (transferTransferable.component == panel) return null
paths.addAll(transferTransferable.files)
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
if (_fileSystem.isLocallyFileSystem()) return null
if (loader.isLoaded() && loader.getSyncTransportSupport().getFileSystem().isLocallyFileSystem())
return null
if (load) {
val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return null
@@ -579,22 +579,12 @@ class TransportPanel(
}
fun getTableModel(): TransportTableModel {
return model
private suspend fun getFileSystem(): FileSystem {
return loader.getTransportSupport().getFileSystem()
}
fun getFileSystem(): FileSystem {
return _fileSystem
}
/**
* 不能在 EDT 线程调用
*/
private fun getSupport(): TransportSupport {
if (SwingUtilities.isEventDispatchThread()) {
throw WrongThreadException("AWT EventQueue")
}
return loader.get()
private suspend fun getTransportSupport(): TransportSupport {
return loader.getTransportSupport()
}
private fun enterSelectionFolder() {
@@ -611,7 +601,12 @@ class TransportPanel(
if (workdir != null) registerSelectRow(workdir.name)
}
navigator.navigateTo(path)
// Windows 比较特殊,显示盘符页
if (path.fileSystem.isWindowsFileSystem() && path.pathString == path.fileSystem.separator) {
navigator.navigateTo(path.pathString)
} else {
navigator.navigateTo(path.absolutePathString())
}
}
private fun registerSelectRow(name: String) {
@@ -628,7 +623,11 @@ class TransportPanel(
}
}
private fun reload(oldPath: Path? = workdir, newPath: Path? = workdir, requestFocus: Boolean = false): Boolean {
fun reload(
oldPath: String? = workdir?.absolutePathString(),
newPath: String? = workdir?.absolutePathString(),
requestFocus: Boolean = false
): Boolean {
assertEventDispatchThread()
if (loading) return false
@@ -664,20 +663,26 @@ class TransportPanel(
return true
}
private suspend fun doReload(oldPath: Path? = null, newPath: Path? = null, requestFocus: Boolean = false): Path {
private suspend fun doReload(
oldPath: String? = null,
newPath: String? = null,
requestFocus: Boolean = false
): Path {
val support = getTransportSupport()
val fileSystem = support.getFileSystem()
val workdir = newPath ?: oldPath
if (workdir == null) {
val path = _fileSystem.getPath(defaultPath)
return doReload(null, path)
val path = support.getDefaultPath()
return doReload(null, path.absolutePathString())
}
val path = workdir
val path = fileSystem.getPath(workdir)
val first = AtomicBoolean(false)
var parent = path.parent
if (parent == null && _fileSystem.isWindowsFileSystem() && workdir.pathString != _fileSystem.separator) {
parent = _fileSystem.getPath(_fileSystem.separator)
if (parent == null && fileSystem.isWindowsFileSystem() && path.pathString != fileSystem.separator) {
parent = fileSystem.getPath(fileSystem.separator)
}
val files = mutableListOf<Pair<Path, Attributes>>()
if ((parent != null).also { hasParent = it }) {
@@ -698,8 +703,8 @@ class TransportPanel(
files.clear()
}
if (_fileSystem.isWindowsFileSystem() && workdir.pathString == _fileSystem.separator) {
for (path in _fileSystem.rootDirectories) {
if (fileSystem.isWindowsFileSystem() && path.pathString == fileSystem.separator) {
for (path in fileSystem.rootDirectories) {
val attributes = getAttributes(path)
files.add(path to attributes)
}
@@ -718,7 +723,7 @@ class TransportPanel(
if (requestFocus)
coroutineScope.launch(Dispatchers.Swing) { table.requestFocusInWindow() }
return workdir
return path
}
private fun listFiles(path: Path): Stream<Pair<Path, Attributes>> {
@@ -789,21 +794,24 @@ class TransportPanel(
}
}
private fun showContextmenu(rows: Array<Int>, e: MouseEvent) {
val files = rows.map { model.getPath(it) to model.getAttributes(it) }
val popupMenu = TransportPopupMenu(owner, model, transferManager, _fileSystem, files)
val popupMenu = TransportPopupMenu(owner, model, transferManager, loader, files)
popupMenu.addActionListener(PopupMenuActionListener(files))
popupMenu.show(table, e.x, e.y)
}
override fun navigateTo(destination: Path): Boolean {
override fun navigateTo(destination: String): Boolean {
assertEventDispatchThread()
if (loading) return false
if (workdir == destination) return false
return reload(workdir, destination)
if (loader.isOpened()) {
if (workdir?.absolutePathString() == destination) return false
}
return reload(newPath = destination)
}
override fun getHistory(): List<Path> {
@@ -827,7 +835,7 @@ class TransportPanel(
}
private fun setNewWorkdir(destination: Path) {
val oldValue = workdir
val oldValue = if (destination.fileSystem == workdir?.fileSystem) workdir else null
workdir = destination
firePropertyChange("workdir", oldValue, destination)
}
@@ -914,8 +922,12 @@ class TransportPanel(
val millis = Files.getLastModifiedTime(localPath).toMillis()
if (oldMillis == millis) continue
// 正在编辑时可能会出现断线的情况 ,安全获取
val fs = getFileSystem()
if (fs.isOpen.not()) continue
// 发送到服务器
transferManager.addHighTransfer(localPath, target)
transferManager.addHighTransfer(localPath, fs.getPath(target.absolutePathString()))
oldMillis = millis
}
}
@@ -1011,8 +1023,9 @@ class TransportPanel(
} else if (actionCommand == TransportPopupMenu.ActionCommand.NewFolder || actionCommand == TransportPopupMenu.ActionCommand.NewFile) {
val name = e.source.toString()
val workdir = workdir ?: return
val path = workdir.resolve(name)
processPath(e.source.toString()) {
// 因为此时可能已经断线,任何 Path 都不可完全相信
val path = getFileSystem().getPath(workdir.resolve(name).absolutePathString())
if (actionCommand == TransportPopupMenu.ActionCommand.NewFolder)
path.createDirectories()
else
@@ -1023,16 +1036,10 @@ class TransportPanel(
val target = source.parent.resolve(e.source.toString())
processPath(e.source.toString()) { source.moveTo(target) }
} else if (actionCommand == TransportPopupMenu.ActionCommand.Rmrf) {
processPath(StringUtils.EMPTY) {
val session = (_fileSystem as SftpFileSystem).clientSession
for (path in files.map { it.first }) {
session.executeRemoteCommand(
"rm -rf '${path.absolutePathString()}'",
OutputStream.nullOutputStream(),
Charsets.UTF_8
)
}
}
transferManager.addTransfer(files, InternalTransferManager.TransferMode.Rmrf)
} else if (actionCommand == TransportPopupMenu.ActionCommand.Reconnect) {
// reload now
reload()
} else if (actionCommand == TransportPopupMenu.ActionCommand.ChangePermissions) {
val c = e.source as TransportPopupMenu.ChangePermission
val path = files.first().first
@@ -1115,7 +1122,7 @@ class TransportPanel(
private inner class MyDefaultTableCellRenderer : DefaultTableCellRenderer() {
override fun getTableCellRendererComponent(
table: JTable?,
table: JTable,
value: Any?,
isSelected: Boolean,
hasFocus: Boolean,
@@ -1144,12 +1151,13 @@ class TransportPanel(
text = StringUtils.EMPTY
}
foreground = null
val c = super.getTableCellRendererComponent(table, text, isSelected, hasFocus, row, column)
icon = null
if (column == TransportTableModel.COLUMN_NAME) {
if (_fileSystem.isWindowsFileSystem()) {
val path = model.getPath(sorter.convertRowIndexToModel(row))
val path = model.getPath(sorter.convertRowIndexToModel(row))
if (path.fileSystem.isWindowsFileSystem()) {
icon = if (attributes.isParent) {
NativeIcons.folderIcon
} else {
@@ -1176,6 +1184,12 @@ class TransportPanel(
}
}
if (loader.isOpened().not()) {
if (isSelected.not()) {
foreground = UIManager.getColor("textInactiveText")
}
}
return c
}

View File

@@ -6,6 +6,7 @@ import app.termora.Icons
import app.termora.OptionPane
import app.termora.transfer.TransportPanel.Companion.isLocallyFileSystem
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import java.awt.Window
@@ -13,7 +14,6 @@ import java.awt.datatransfer.StringSelection
import java.awt.event.ActionEvent
import java.awt.event.ActionListener
import java.awt.event.KeyEvent
import java.nio.file.FileSystem
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission
import java.util.*
@@ -26,11 +26,11 @@ import kotlin.io.path.absolutePathString
import kotlin.io.path.name
class TransportPopupMenu(
internal class TransportPopupMenu(
private val owner: Window,
private val model: TransportTableModel,
private val transferManager: InternalTransferManager,
private val fileSystem: FileSystem,
private val loader: TransportSupportLoader,
private val files: List<Pair<Path, TransportTableModel.Attributes>>
) : FlatPopupMenu() {
private val paths = files.map { it.first }
@@ -71,25 +71,45 @@ class TransportPopupMenu(
private fun initView() {
inheritsPopupMenu = false
if (loader.isOpened().not()) {
val reconnect = add(I18n.getString("termora.tabbed.contextmenu.reconnect"))
reconnect.addActionListener { e -> fireActionPerformed(e, ActionCommand.Reconnect) }
return
}
val fileSystem = if (loader.isLoaded()) loader.getSyncTransportSupport().getFileSystem() else null
add(transferMenu)
add(editMenu)
addSeparator()
add(copyPathMenu)
if (fileSystem.isLocallyFileSystem()) add(openInFinderMenu)
if (fileSystem?.isLocallyFileSystem() == true) {
add(openInFinderMenu)
}
addSeparator()
add(renameMenu)
add(deleteMenu)
if (fileSystem is SftpFileSystem) add(rmrfMenu)
if (fileSystem is SftpFileSystem) {
add(rmrfMenu)
}
add(changePermissionsMenu)
addSeparator()
add(refreshMenu)
addSeparator()
add(newMenu)
// 开发环境提供断线
if (Application.getAppPath().isBlank() && loader.isOpened()) {
addSeparator()
add("Disconnect").addActionListener {
IOUtils.closeQuietly(loader.getSyncTransportSupport().getFileSystem())
}
}
transferMenu.isEnabled = hasParent.not() && files.isNotEmpty() && transferManager.canTransfer(paths)
copyPathMenu.isEnabled = files.isNotEmpty()
openInFinderMenu.isEnabled = files.isNotEmpty() && fileSystem.isLocallyFileSystem()
editMenu.isEnabled = files.isNotEmpty() && fileSystem.isLocallyFileSystem().not()
openInFinderMenu.isEnabled = files.isNotEmpty() && fileSystem?.isLocallyFileSystem() == true
editMenu.isEnabled = files.isNotEmpty() && fileSystem?.isLocallyFileSystem() != true
&& files.all { it.second.isFile && it.second.isSymbolicLink.not() }
renameMenu.isEnabled = hasParent.not() && files.size == 1
deleteMenu.isEnabled = hasParent.not() && files.isNotEmpty()
@@ -211,6 +231,7 @@ class TransportPopupMenu(
Refresh,
ChangePermissions,
Rmrf,
Reconnect,
}
data class ChangePermission(val permissions: Set<PosixFilePermission>, val includeSubFolder: Boolean)

View File

@@ -2,7 +2,6 @@ package app.termora.transfer
import app.termora.*
import app.termora.database.DatabaseManager
import app.termora.protocol.PathHandlerRequest
import app.termora.protocol.TransferProtocolProvider
import app.termora.tree.*
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
@@ -24,9 +23,8 @@ import java.util.concurrent.Executors
import javax.swing.*
import javax.swing.event.TreeExpansionEvent
import javax.swing.event.TreeExpansionListener
import kotlin.io.path.absolutePathString
class TransportSelectionPanel(
internal class TransportSelectionPanel(
private val tabbed: TransportTabbed,
private val transferManager: InternalTransferManager,
) : JPanel(BorderLayout()), Disposable {
@@ -99,24 +97,21 @@ class TransportSelectionPanel(
private suspend fun doConnect(host: Host) {
val provider = TransferProtocolProvider.valueOf(host.protocol)
if (provider == null) {
throw IllegalStateException(I18n.getString("termora.protocol.not-supported", host.protocol))
}
val loader = ReconnectableTransportSupportLoader(owner, host)
val handler = provider.createPathHandler(PathHandlerRequest(host, owner))
val support = TransportSupport(handler.fileSystem, handler.path.absolutePathString())
// try load
loader.getTransportSupport()
withContext(Dispatchers.Swing) {
val panel = TransportPanel(transferManager, host, TransportSupportLoader { support })
val panel = TransportPanel(transferManager, host, loader)
Disposer.register(panel, object : Disposable {
override fun dispose() {
Disposer.dispose(handler)
Disposer.dispose(loader)
}
})
swingCoroutineScope.launch {
tabbed.remove(that)
tabbed.addTab(host.name, panel)
tabbed.addTab(host.name, TransportViewer.MyIcon.Success, panel)
tabbed.selectedIndex = tabbed.tabCount - 1
}
}

View File

@@ -1,9 +1,10 @@
package app.termora.transfer
import java.nio.file.FileSystem
import java.nio.file.Path
class TransportSupport(
val fileSystem: FileSystem,
val path: String
)
internal interface TransportSupport {
fun getFileSystem(): FileSystem
fun getDefaultPath(): Path
}

View File

@@ -1,52 +1,31 @@
package app.termora.transfer
import okio.withLock
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.locks.ReentrantLock
import java.util.function.Supplier
import app.termora.Disposable
class TransportSupportLoader(private val support: Supplier<TransportSupport>) : Supplier<TransportSupport> {
private val loading = AtomicBoolean(false)
private lateinit var mySupport: TransportSupport
private val lock = ReentrantLock()
private val condition = lock.newCondition()
private val exceptionReference = AtomicReference<Exception>(null)
internal interface TransportSupportLoader : Disposable {
val isLoaded get() = ::mySupport.isInitialized
/**
* 获取传输支持
*/
suspend fun getTransportSupport(): TransportSupport
/**
* 只有当 [isLoaded] 返回 true 时才能调用,为了不出现问题,只有 EDT 线程才能调用
*/
fun getSyncTransportSupport(): TransportSupport
override fun get(): TransportSupport {
if (isLoaded) return mySupport
if (loading.compareAndSet(false, true)) {
try {
mySupport = support.get()
} catch (e: Exception) {
exceptionReference.set(e)
throw e
} finally {
lock.withLock {
loading.set(false)
condition.signalAll()
}
}
} else {
lock.lock()
try {
condition.await()
} finally {
lock.unlock()
}
}
val exception = exceptionReference.get()
if (exception != null) {
throw exception
}
return get()
}
/**
* 是否已经加载,已经加载不表示可以正常使用,它仅证明已经加载可以同步调用
*/
fun isLoaded(): Boolean
/**
* 快速检查是否已经成功打开
*/
fun isOpened(): Boolean = true
/**
* 是否正在打开中,也有可能是加载中
*/
fun isOpening(): Boolean = false
}

View File

@@ -19,7 +19,7 @@ import javax.swing.JToolBar
import javax.swing.SwingUtilities
@Suppress("DuplicatedCode")
class TransportTabbed(
internal class TransportTabbed(
private val transferManager: TransferManager,
) : FlatTabbedPane(), Disposable {
private val addBtn = JButton(Icons.add)
@@ -64,14 +64,15 @@ class TransportTabbed(
// 右键菜单
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (!SwingUtilities.isRightMouseButton(e)) {
return
}
val index = indexAtLocation(e.x, e.y)
if (index < 0) return
showContextMenu(index, e)
if (SwingUtilities.isRightMouseButton(e)) {
showContextMenu(index, e)
} else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val tab = getTransportPanel(index) ?: return
if (tab.loader.isOpening() || tab.loader.isOpened()) return
tab.reload()
}
}
})
@@ -105,8 +106,9 @@ class TransportTabbed(
private fun tabClose(c: TransportPanel): Boolean {
if (transferManager.getTransferCount() < 1) return true
if (c.loader.isLoaded.not()) return false
val fileSystem = c.getFileSystem()
val loader = c.loader
if (loader.isLoaded().not()) return false
val fileSystem = loader.getSyncTransportSupport()
val transfers = transferManager.getTransfers()
.filter { it.source().fileSystem == fileSystem || it.target().fileSystem == fileSystem }
if (transfers.isEmpty()) return true
@@ -136,10 +138,24 @@ class TransportTabbed(
}
fun addLocalTab() {
val host = Host(name = "Local", protocol = LocalProtocolProvider.PROTOCOL)
val support = TransportSupport(FileSystems.getDefault(), getDefaultLocalPath())
val panel = TransportPanel(internalTransferManager, host, TransportSupportLoader { support })
addTab(I18n.getString("termora.transport.local"), panel)
// local id 必须固定,不然书签无法使用
val host = Host(id = "local", name = "Local", protocol = LocalProtocolProvider.PROTOCOL)
val fs = FileSystems.getDefault()
val support = DefaultTransportSupport(fs, fs.getPath(getDefaultLocalPath()))
val panel = TransportPanel(internalTransferManager, host, object : TransportSupportLoader {
override suspend fun getTransportSupport(): TransportSupport {
return support
}
override fun getSyncTransportSupport(): TransportSupport {
return support
}
override fun isLoaded(): Boolean {
return true
}
})
addTab(I18n.getString("termora.transport.local"), TransportViewer.MyIcon.Success, panel)
super.setTabClosable(0, false)
}
@@ -154,7 +170,7 @@ class TransportTabbed(
val popupMenu = FlatPopupMenu()
// 克隆
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
val clone = popupMenu.add(I18n.getString("termora.copy"))
clone.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val c = addSelectionTab()
@@ -165,20 +181,30 @@ class TransportTabbed(
// 编辑
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
edit.addActionListener(object : AnAction() {
private val hostManager get() = HostManager.getInstance()
private val accountManager get() = AccountManager.getInstance()
override fun actionPerformed(evt: AnActionEvent) {
val window = evt.window
val dialog = NewHostDialogV2(
window,
panel.host,
getHost(panel),
accountOwner = accountManager.getOwners().first { it.id == panel.host.ownerId })
dialog.setLocationRelativeTo(window)
dialog.title = panel.host.name
dialog.isVisible = true
val host = dialog.host ?: return
HostManager.getInstance().addHost(host, DatabaseChangedExtension.Source.Sync)
hostManager.addHost(host, DatabaseChangedExtension.Source.User)
setTitleAt(tabIndex, host.name)
panel.host = host
setHost(panel, host)
}
private fun getHost(panel: TransportPanel): Host {
return panel.getClientProperty("EditHost") as Host? ?: panel.host
}
private fun setHost(panel: TransportPanel, host: Host) {
panel.putClientProperty("EditHost", host)
}
})

View File

@@ -3,18 +3,18 @@ package app.termora.transfer
import app.termora.*
import app.termora.database.DatabaseManager
import app.termora.terminal.DataKey
import app.termora.transfer.TransportPanel.Companion.isLocallyFileSystem
import java.beans.PropertyChangeListener
import java.nio.file.FileSystems
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
class TransportTerminalTab : RememberFocusTerminalTab() {
internal class TransportTerminalTab : RememberFocusTerminalTab() {
private val transportViewer = TransportViewer()
private val sftp get() = DatabaseManager.getInstance().sftp
private val transferManager get() = transportViewer.getTransferManager()
val leftTabbed get() = transportViewer.getLeftTabbed()
private val leftTabbed get() = transportViewer.getLeftTabbed()
val rightTabbed get() = transportViewer.getRightTabbed()
init {
@@ -66,8 +66,8 @@ class TransportTerminalTab : RememberFocusTerminalTab() {
private fun hasActiveTab(tabbed: TransportTabbed): Boolean {
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getComponentAt(i) ?: continue
if (c is TransportPanel && c.loader.isLoaded) {
if (c.getFileSystem() != FileSystems.getDefault()) {
if (c is TransportPanel && c.loader.isOpened()) {
if (c.loader.getSyncTransportSupport().getFileSystem().isLocallyFileSystem().not()) {
return true
}
}

View File

@@ -4,21 +4,17 @@ import app.termora.Disposable
import app.termora.Disposer
import app.termora.DynamicColor
import app.termora.actions.DataProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import java.awt.*
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.nio.file.Path
import javax.swing.*
import kotlin.time.Duration.Companion.milliseconds
class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
companion object {
private val log = LoggerFactory.getLogger(TransportViewer::class.java)
}
internal class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val splitPane = JSplitPane()
@@ -78,10 +74,39 @@ class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
}
})
coroutineScope.launch(Dispatchers.Swing) {
while (isActive) {
checkDisconnected(leftTabbed)
checkDisconnected(rightTabbed)
delay(250.milliseconds)
}
}
Disposer.register(this, leftTabbed)
Disposer.register(this, rightTabbed)
}
private fun checkDisconnected(tabbed: TransportTabbed) {
for (i in 0 until tabbed.tabCount) {
val tab = tabbed.getTransportPanel(i) ?: continue
val icon = tabbed.getIconAt(i)
if (tab.loader.isOpened()) {
if (icon != MyIcon.Success) {
tabbed.setIconAt(i, MyIcon.Success)
}
} else if (tab.loader.isOpening()) {
if (icon != MyIcon.Warning) {
tabbed.setIconAt(i, MyIcon.Warning)
}
} else {
if (icon != MyIcon.Error) {
tabbed.setIconAt(i, MyIcon.Error)
}
}
}
}
private fun createInternalTransferManager(
source: TransportTabbed,
target: TransportTabbed
@@ -118,4 +143,38 @@ class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
return rightTabbed
}
override fun dispose() {
coroutineScope.cancel()
}
internal class MyIcon(private val color: Color) : Icon {
private val size = 10
// https://plugins.jetbrains.com/docs/intellij/icons-style.html#action-icons
companion object {
val Success = MyIcon(DynamicColor(Color(89, 168, 105), Color(73, 156, 84)))
val Error = MyIcon(DynamicColor(Color(219, 88, 96), Color(199, 84, 80)))
val Warning = MyIcon(DynamicColor(Color(237, 162, 0), Color(240, 167, 50)))
}
override fun paintIcon(c: Component, g: Graphics, x: Int, y: Int) {
if (g is Graphics2D) {
g.color = color
val centerX = x + (iconWidth - size) / 2
val centerY = y + (iconHeight - size) / 2
g.fillRoundRect(centerX, centerY, size, size, size, size)
}
}
override fun getIconWidth(): Int {
return 16
}
override fun getIconHeight(): Int {
return 16
}
}
}

View File

@@ -1,6 +1,6 @@
package app.termora.transfer.internal.sftp
import app.termora.SshClients
import app.termora.plugin.internal.ssh.SshClients
import app.termora.protocol.PathHandler
import app.termora.protocol.PathHandlerRequest
import app.termora.protocol.TransferProtocolProvider

View File

@@ -22,7 +22,7 @@ open class S3FileSystem(provider: S3FileSystemProvider) : BaseFileSystem<S3Path>
}
override fun close() {
isOpen.compareAndSet(false, true)
isOpen.compareAndSet(true, false)
}
override fun getRootDirectories(): Iterable<Path> {

View File

@@ -154,6 +154,7 @@ termora.find-everywhere.quick-command.local-terminal=Local Terminal
# Welcome
termora.welcome.my-hosts=My hosts
termora.welcome.toggle-sidebar=Toggle Sidebar
termora.welcome.contextmenu.connect=Connect
termora.welcome.contextmenu.connect-with=Connect with
termora.welcome.contextmenu.open-in-new-window=${termora.tabbed.contextmenu.open-in-new-window}
@@ -196,6 +197,7 @@ termora.new-host.proxy=Proxy
termora.new-host.terminal=${termora.settings.terminal}
termora.new-host.terminal.encoding=Encoding
termora.new-host.terminal.backspace=Backspace
termora.new-host.terminal.heartbeat-interval=Heartbeat Interval
termora.new-host.terminal.startup-commands=Startup Command
termora.new-host.terminal.env=Environment
@@ -257,6 +259,7 @@ termora.tabbed.contextmenu.rename=Rename
termora.tabbed.contextmenu.sftp-command=SFTP Command
termora.tabbed.contextmenu.sftp-not-install=SFTP programme not found, please install and try again
termora.tabbed.contextmenu.clone=Clone
termora.tabbed.contextmenu.clone-session=Clone Session
termora.tabbed.contextmenu.open-in-new-window=Open in New Window
termora.tabbed.contextmenu.close=Close
termora.tabbed.contextmenu.close-other-tabs=Close Other Tabs

View File

@@ -149,6 +149,7 @@ termora.settings.sftp.preserve-time=保留原始文件修改时间
# Welcome
termora.welcome.my-hosts=我的主机
termora.welcome.toggle-sidebar=显示/隐藏侧边栏
termora.welcome.contextmenu.connect=连接
termora.welcome.contextmenu.connect-with=连接到
termora.welcome.contextmenu.copy=${termora.copy}
@@ -187,6 +188,7 @@ termora.new-host.proxy=代理
termora.new-host.terminal=${termora.settings.terminal}
termora.new-host.terminal.encoding=编码
termora.new-host.terminal.backspace=退格键
termora.new-host.terminal.heartbeat-interval=心跳间隔
termora.new-host.terminal.startup-commands=启动命令
termora.new-host.terminal.env=环境
@@ -252,6 +254,7 @@ termora.tabbed.contextmenu.rename=重命名
termora.tabbed.contextmenu.sftp-command=SFTP 终端
termora.tabbed.contextmenu.sftp-not-install=没有找到 SFTP 程序,请安装后重试
termora.tabbed.contextmenu.clone=克隆
termora.tabbed.contextmenu.clone-session=克隆会话
termora.tabbed.contextmenu.open-in-new-window=在新窗口打开
termora.tabbed.contextmenu.close=关闭
termora.tabbed.contextmenu.close-other-tabs=关闭其他标签页

View File

@@ -148,6 +148,7 @@ termora.settings.account.login-failed=登入失敗,請稍後再試
# Welcome
termora.welcome.my-hosts=我的主機
termora.welcome.toggle-sidebar=顯示/隱藏側邊欄
termora.welcome.contextmenu.connect=連接
termora.welcome.contextmenu.connect-with=連接到
termora.welcome.contextmenu.copy=複製
@@ -186,6 +187,7 @@ termora.new-host.proxy=代理
termora.new-host.terminal=${termora.settings.terminal}
termora.new-host.terminal.encoding=編碼
termora.new-host.terminal.backspace=退格鍵
termora.new-host.terminal.startup-commands=啟動命令
termora.new-host.terminal.heartbeat-interval=心跳間隔
termora.new-host.terminal.env=環境
@@ -247,6 +249,7 @@ termora.tabbed.contextmenu.rename=重新命名
termora.tabbed.contextmenu.sftp-command=SFTP 終端
termora.tabbed.contextmenu.sftp-not-install=沒有找到 SFTP 程序,請安裝後重試
termora.tabbed.contextmenu.clone=克隆
termora.tabbed.contextmenu.clone-session=克隆會話
termora.tabbed.contextmenu.open-in-new-window=在新視窗打開
termora.tabbed.contextmenu.close=關閉
termora.tabbed.contextmenu.close-other-tabs=關閉其他標籤頁

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 14C10.866 14 14 10.866 14 7C14 3.13401 10.866 0 7 0C3.13401 0 0 3.13401 0 7C0 10.866 3.13401 14 7 14Z" fill="#E55765"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 14C10.866 14 14 10.866 14 7C14 3.13401 10.866 0 7 0C3.13401 0 0 3.13401 0 7C0 10.866 3.13401 14 7 14Z" fill="#DB5C5C"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

View File

@@ -0,0 +1 @@
<svg t="1751694328543" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1612" width="16" height="16"><path d="M889.5623525 160.37019636v579.50117625H134.4376475V160.31372511h755.124705z m-755.124705-56.47058813a56.47058813 56.47058813 0 0 0-56.47058906 56.47058813v579.50117625a56.47058813 56.47058813 0 0 0 56.47058906 56.47058812h755.124705a56.47058813 56.47058813 0 0 0 56.47058906-56.47058812V160.31372511a56.47058813 56.47058813 0 0 0-56.47058906-56.47058813H134.4376475zM733.53411781 915.26901917H290.46588219v-56.47058813h443.06823562v56.47058813z" p-id="1613" fill="#6C707E"></path><path d="M672.65882375 589.60313698v-278.96470593h43.87764656v241.46823562h118.08v37.49647031h-161.95764656zM445.70352969 589.60313698v-278.96470593h164.66823469v37.10117718H489.6376475v77.59058813h102.21176438V462.43137261h-102.21176438v89.67529406h124.85647031v37.49647031H445.70352969zM270.7576475 589.60313698V347.73960823H189.38352969v-37.10117719h207.81176437v37.10117719H315.36941187v241.86352875h-44.66823562z" p-id="1614" fill="#6C707E"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg t="1751694328543" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1612" width="16" height="16"><path d="M889.5623525 160.37019636v579.50117625H134.4376475V160.31372511h755.124705z m-755.124705-56.47058813a56.47058813 56.47058813 0 0 0-56.47058906 56.47058813v579.50117625a56.47058813 56.47058813 0 0 0 56.47058906 56.47058812h755.124705a56.47058813 56.47058813 0 0 0 56.47058906-56.47058812V160.31372511a56.47058813 56.47058813 0 0 0-56.47058906-56.47058813H134.4376475zM733.53411781 915.26901917H290.46588219v-56.47058813h443.06823562v56.47058813z" p-id="1613" fill="#CED0D6"></path><path d="M672.65882375 589.60313698v-278.96470593h43.87764656v241.46823562h118.08v37.49647031h-161.95764656zM445.70352969 589.60313698v-278.96470593h164.66823469v37.10117718H489.6376475v77.59058813h102.21176438V462.43137261h-102.21176438v89.67529406h124.85647031v37.49647031H445.70352969zM270.7576475 589.60313698V347.73960823H189.38352969v-37.10117719h207.81176437v37.10117719H315.36941187v241.86352875h-44.66823562z" p-id="1614" fill="#CED0D6"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,5 +1,6 @@
package app.termora
import app.termora.plugin.internal.ssh.SshClients
import org.apache.sshd.client.session.ClientSession
import org.testcontainers.containers.GenericContainer
import kotlin.test.AfterTest