refactor: SFTP (#351)

This commit is contained in:
hstyi
2025-03-13 16:33:57 +08:00
committed by GitHub
parent 422e9aac84
commit 79d0a9a348
77 changed files with 4083 additions and 2819 deletions

View File

@@ -45,5 +45,4 @@ jobs:
name: termora-windows-x86-64 name: termora-windows-x86-64
path: | path: |
build/distributions/*.zip build/distributions/*.zip
build/distributions/*.msi
build/distributions/*.exe build/distributions/*.exe

View File

@@ -0,0 +1,38 @@
package app.termora;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.WString;
import com.sun.jna.win32.StdCallLibrary;
interface Kernel32 extends StdCallLibrary {
Kernel32 INSTANCE = Native.load("Kernel32", Kernel32.class);
WString INVARIANT_LOCALE = new WString("");
int CompareStringEx(WString lpLocaleName,
int dwCmpFlags,
WString lpString1,
int cchCount1,
WString lpString2,
int cchCount2,
Pointer lpVersionInformation,
Pointer lpReserved,
int lParam);
default int CompareStringEx(int dwCmpFlags,
String str1,
String str2) {
return Kernel32.INSTANCE
.CompareStringEx(
INVARIANT_LOCALE,
dwCmpFlags,
new WString(str1),
str1.length(),
new WString(str2),
str2.length(),
Pointer.NULL,
Pointer.NULL,
0);
}
}

View File

@@ -150,6 +150,16 @@ object Application {
ProcessBuilder("xdg-open", uri.toString()).start() ProcessBuilder("xdg-open", uri.toString()).start()
} }
} }
fun browseInFolder(file: File) {
if (SystemInfo.isWindows) {
ProcessBuilder("explorer", "/select," + file.absolutePath).start()
} else if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-R", file.absolutePath).start()
} else if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
Desktop.getDesktop().browseFileDirectory(file)
}
}
} }
fun formatBytes(bytes: Long): String { fun formatBytes(bytes: Long): String {
@@ -168,11 +178,33 @@ fun formatSeconds(seconds: Long): String {
val minutes = (seconds % 3600) / 60 val minutes = (seconds % 3600) / 60
val remainingSeconds = seconds % 60 val remainingSeconds = seconds % 60
return when { return when {
days > 0 -> "${days}${hours}小时${minutes}${remainingSeconds}" days > 0 -> I18n.getString(
hours > 0 -> "${hours}小时${minutes}${remainingSeconds}" "termora.transport.jobs.table.estimated-time-days-format",
minutes > 0 -> "${minutes}${remainingSeconds}" days,
else -> "${remainingSeconds}" hours,
minutes,
remainingSeconds
)
hours > 0 -> I18n.getString(
"termora.transport.jobs.table.estimated-time-hours-format",
hours,
minutes,
remainingSeconds
)
minutes > 0 -> I18n.getString(
"termora.transport.jobs.table.estimated-time-minutes-format",
minutes,
remainingSeconds
)
else -> I18n.getString(
"termora.transport.jobs.table.estimated-time-seconds-format",
remainingSeconds
)
} }
} }

View File

@@ -7,6 +7,7 @@ import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatDesktop import com.formdev.flatlaf.extras.FlatDesktop
import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse
import com.formdev.flatlaf.extras.FlatInspector import com.formdev.flatlaf.extras.FlatInspector
import com.formdev.flatlaf.ui.FlatTableCellBorder
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jthemedetecor.OsThemeDetector import com.jthemedetecor.OsThemeDetector
import com.mixpanel.mixpanelapi.ClientDelivery import com.mixpanel.mixpanelapi.ClientDelivery
@@ -29,7 +30,6 @@ import java.nio.channels.FileLock
import java.nio.file.Paths import java.nio.file.Paths
import java.nio.file.StandardOpenOption import java.nio.file.StandardOpenOption
import java.util.* import java.util.*
import java.util.function.Consumer
import javax.swing.* import javax.swing.*
import kotlin.system.exitProcess import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@@ -120,15 +120,12 @@ class ApplicationRunner {
private fun startMainFrame() { private fun startMainFrame() {
TermoraFrameManager.getInstance().createWindow().isVisible = true TermoraFrameManager.getInstance().createWindow().isVisible = true
if (SystemUtils.IS_OS_MAC_OSX) { if (SystemUtils.IS_OS_MAC_OSX) {
SwingUtilities.invokeLater { SwingUtilities.invokeLater {
FlatDesktop.setQuitHandler(object : Consumer<QuitResponse> { FlatDesktop.setQuitHandler { response -> quitHandler(response) }
override fun accept(response: QuitResponse) {
quitHandler(response)
}
})
} }
} }
} }
@@ -172,6 +169,15 @@ class ApplicationRunner {
JDialog.setDefaultLookAndFeelDecorated(true) JDialog.setDefaultLookAndFeelDecorated(true)
} }
UIManager.put(
"FileChooser.${if (SystemInfo.isWindows) "win32" else "other"}.newFolder",
I18n.getString("termora.welcome.contextmenu.new.folder.name")
)
UIManager.put(
"FileChooser.${if (SystemInfo.isWindows) "win32" else "other"}.newFolder.subsequent",
"${I18n.getString("termora.welcome.contextmenu.new.folder.name")}.{0}"
)
val themeManager = ThemeManager.getInstance() val themeManager = ThemeManager.getInstance()
val appearance = Database.getDatabase().appearance val appearance = Database.getDatabase().appearance
var theme = appearance.theme var theme = appearance.theme
@@ -186,6 +192,7 @@ class ApplicationRunner {
themeManager.change(theme, true) themeManager.change(theme, true)
if (Application.isUnknownVersion()) if (Application.isUnknownVersion())
FlatInspector.install("ctrl shift alt X") FlatInspector.install("ctrl shift alt X")
@@ -218,9 +225,8 @@ class ApplicationRunner {
} }
UIManager.put("Table.rowHeight", 24) UIManager.put("Table.rowHeight", 24)
UIManager.put("Table.cellNoFocusBorder", BorderFactory.createEmptyBorder()) UIManager.put("Table.focusCellHighlightBorder", FlatTableCellBorder.Default())
UIManager.put("Table.focusCellHighlightBorder", BorderFactory.createEmptyBorder()) UIManager.put("Table.focusSelectedCellHighlightBorder", FlatTableCellBorder.Default())
UIManager.put("Table.focusSelectedCellHighlightBorder", BorderFactory.createEmptyBorder())
UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc")) UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Tree.rowHeight", 24) UIManager.put("Tree.rowHeight", 24)

View File

@@ -606,12 +606,22 @@ class Database private constructor(private val env: Environment) : Disposable {
*/ */
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY) var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* defaultDirectory
*/
var defaultDirectory by StringPropertyDelegate(StringUtils.EMPTY)
/** /**
* 是否固定在标签栏 * 是否固定在标签栏
*/ */
var pinTab by BooleanPropertyDelegate(false) var pinTab by BooleanPropertyDelegate(false)
/**
* 是否保留原始文件时间
*/
var preserveModificationTime by BooleanPropertyDelegate(false)
} }
/** /**

View File

@@ -47,6 +47,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
serialCommOption.parityComboBox.selectedItem = serialComm.parity serialCommOption.parityComboBox.selectedItem = serialComm.parity
serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits
serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
} }
override fun getHost(): Host { override fun getHost(): Host {

View File

@@ -132,6 +132,11 @@ data class Options(
* 串口配置 * 串口配置
*/ */
val serialComm: SerialComm = SerialComm(), val serialComm: SerialComm = SerialComm(),
/**
* SFTP 默认目录
*/
val sftpDefaultDirectory: String = StringUtils.EMPTY,
) { ) {
companion object { companion object {
val Default = Options() val Default = Options()

View File

@@ -29,6 +29,7 @@ open class HostOptionsPane : OptionsPane() {
protected val terminalOption = TerminalOption() protected val terminalOption = TerminalOption()
protected val jumpHostsOption = JumpHostsOption() protected val jumpHostsOption = JumpHostsOption()
protected val serialCommOption = SerialCommOption() protected val serialCommOption = SerialCommOption()
protected val sftpOption = SFTPOption()
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this) protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init { init {
@@ -38,6 +39,7 @@ open class HostOptionsPane : OptionsPane() {
addOption(jumpHostsOption) addOption(jumpHostsOption)
addOption(terminalOption) addOption(terminalOption)
addOption(serialCommOption) addOption(serialCommOption)
addOption(sftpOption)
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)) setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
} }
@@ -91,7 +93,8 @@ open class HostOptionsPane : OptionsPane() {
startupCommand = terminalOption.startupCommandTextField.text, startupCommand = terminalOption.startupCommandTextField.text,
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int, heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
jumpHosts = jumpHostsOption.jumpHosts.map { it.id }, jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
serialComm = serialComm serialComm = serialComm,
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text
) )
return Host( return Host(
@@ -669,6 +672,54 @@ open class HostOptionsPane : OptionsPane() {
} }
} }
protected inner class SFTPOption : JPanel(BorderLayout()), Option {
val defaultDirectoryField = OutlineTextField(255)
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.folder
}
override fun getTitle(): String {
return "SFTP"
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
.add(defaultDirectoryField).xy(3, rows).apply { rows += step }
.build()
return panel
}
}
protected inner class TunnelingOption : JPanel(BorderLayout()), Option { protected inner class TunnelingOption : JPanel(BorderLayout()), Option {
val tunnelings = mutableListOf<Tunneling>() val tunnelings = mutableListOf<Tunneling>()

View File

@@ -13,6 +13,7 @@ object Icons {
val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") } val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") }
val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") } val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") }
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") } val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
val inspectionsEye by lazy { DynamicIcon("icons/inspectionsEye.svg", "icons/inspectionsEye_dark.svg") }
val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") } val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") } val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") } val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") }
@@ -32,6 +33,7 @@ object Icons {
val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") } val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") }
val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") } val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") } val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
val warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_dark.svg") }
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") } val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") } val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") } val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") }
@@ -50,6 +52,7 @@ object Icons {
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") } val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") }
val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") } val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") } val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") }
val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_dark.svg") }
val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") } val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") }
val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") } val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") }
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") } val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }

View File

@@ -0,0 +1,41 @@
package app.termora
import com.formdev.flatlaf.util.SystemInfo
import de.jangassen.jfa.foundation.Foundation
import de.jangassen.jfa.foundation.Foundation.NSAutoreleasePool
import java.text.Collator
import java.util.*
class NativeStringComparator private constructor() : Comparator<String> {
private val collator by lazy { Collator.getInstance(Locale.getDefault()).apply { strength = Collator.PRIMARY } }
companion object {
fun getInstance(): NativeStringComparator {
return ApplicationScope.forApplicationScope()
.getOrCreate(NativeStringComparator::class) { NativeStringComparator() }
}
private const val SORT_DIGITSASNUMBERS: Int = 0x00000008
}
override fun compare(o1: String, o2: String): Int {
if (SystemInfo.isWindows) {
// CompareStringEx returns 1, 2, 3 respectively instead of -1, 0, 1
return Kernel32.INSTANCE.CompareStringEx(SORT_DIGITSASNUMBERS, o1, o2) - 2
} else if (SystemInfo.isMacOS) {
val pool = NSAutoreleasePool()
try {
val a = Foundation.nsString(o1)
val b = Foundation.nsString(o2)
return Foundation.invoke(a, "localizedStandardCompare:", b).toInt()
} finally {
pool.drain()
}
}
return collator.compare(o1, o2)
}
}

View File

@@ -1,9 +1,8 @@
package app.termora package app.termora
import app.termora.Application.ohMyJson import app.termora.Application.ohMyJson
import app.termora.actions.AnActionEvent
import app.termora.actions.OpenHostAction import app.termora.actions.OpenHostAction
import app.termora.transport.SFTPAction import app.termora.sftp.SFTPActionEvent
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
@@ -46,6 +45,7 @@ class NewHostTree : SimpleTree() {
private val properties get() = Database.getDatabase().properties private val properties get() = Database.getDatabase().properties
private val owner get() = SwingUtilities.getWindowAncestor(this) private val owner get() = SwingUtilities.getWindowAncestor(this)
private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
private val sftpAction get() = ActionManager.getInstance().getAction(app.termora.Actions.SFTP)
private var isShowMoreInfo private var isShowMoreInfo
get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean() get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
set(value) = properties.putString("HostTree.showMoreInfo", value.toString()) set(value) = properties.putString("HostTree.showMoreInfo", value.toString())
@@ -396,10 +396,8 @@ class NewHostTree : SimpleTree() {
val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return if (nodes.isEmpty()) return
val sftpAction = ActionManager.getInstance().getAction(app.termora.Actions.SFTP) as SFTPAction? ?: return
val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return
for (node in nodes) { for (node in nodes) {
sftpAction.connectHost(node, tab) sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt))
} }
} }

View File

@@ -3,11 +3,10 @@ package app.termora
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import java.awt.Dimension import java.awt.Dimension
import java.awt.Window import java.awt.Window
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.function.Function import java.util.function.Function
import javax.swing.BorderFactory import javax.swing.*
import javax.swing.JComponent
import javax.swing.JScrollPane
import javax.swing.UIManager
class NewHostTreeDialog( class NewHostTreeDialog(
owner: Window, owner: Window,
@@ -19,7 +18,7 @@ class NewHostTreeDialog(
private val tree = NewHostTree() private val tree = NewHostTree()
init { init {
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150) size = Dimension(UIManager.getInt("Dialog.width") - 250, UIManager.getInt("Dialog.height") - 150)
isModal = true isModal = true
isResizable = false isResizable = false
controlsVisible = false controlsVisible = false
@@ -29,6 +28,15 @@ class NewHostTreeDialog(
tree.doubleClickConnection = false tree.doubleClickConnection = false
tree.dragEnabled = false tree.dragEnabled = false
tree.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val node = tree.getLastSelectedPathNode() ?: return
if (node.isFolder) return
doOKAction()
}
}
})
init() init()

View File

@@ -33,14 +33,21 @@ class PtyConnectorFactory : Disposable {
if (SystemUtils.IS_OS_UNIX) { if (SystemUtils.IS_OS_UNIX) {
commands.add("-l") commands.add("-l")
} }
return createPtyConnector(commands.toTypedArray(), rows, cols, env, charset) return createPtyConnector(
commands = commands.toTypedArray(),
rows = rows,
cols = cols,
env = env,
charset = charset
)
} }
fun createPtyConnector( fun createPtyConnector(
commands: Array<String>, commands: Array<String>,
rows: Int = 24, cols: Int = 80, rows: Int = 24, cols: Int = 80,
env: Map<String, String> = emptyMap(), env: Map<String, String> = emptyMap(),
charset: Charset = StandardCharsets.UTF_8 directory: String = SystemUtils.USER_HOME,
charset: Charset = StandardCharsets.UTF_8,
): PtyConnector { ): PtyConnector {
val envs = mutableMapOf<String, String>() val envs = mutableMapOf<String, String>()
envs.putAll(System.getenv()) envs.putAll(System.getenv())
@@ -67,7 +74,7 @@ class PtyConnectorFactory : Disposable {
.setInitialRows(rows) .setInitialRows(rows)
.setInitialColumns(cols) .setInitialColumns(cols)
.setConsole(false) .setConsole(false)
.setDirectory(SystemUtils.USER_HOME) .setDirectory(StringUtils.defaultIfBlank(directory, SystemUtils.USER_HOME))
.setCygwin(false) .setCygwin(false)
.setUseWinConPty(SystemUtils.IS_OS_WINDOWS) .setUseWinConPty(SystemUtils.IS_OS_WINDOWS)
.setRedirectErrorStream(false) .setRedirectErrorStream(false)

View File

@@ -0,0 +1,16 @@
package app.termora
import java.awt.Component
import java.awt.KeyboardFocusManager
abstract class RememberFocusTerminalTab : TerminalTab {
private var lastFocusedComponent: Component? = null
override fun onLostFocus() {
lastFocusedComponent = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
}
override fun onGrabFocus() {
lastFocusedComponent?.requestFocusInWindow()
}
}

View File

@@ -8,6 +8,7 @@ import org.apache.commons.io.Charsets
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.sshd.client.ClientBuilder import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession import org.apache.sshd.client.session.ClientSession
@@ -28,6 +29,11 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
private var sshSession: ClientSession? = null private var sshSession: ClientSession? = null
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand
private val defaultDirectory get() = Database.getDatabase().sftp.defaultDirectory
init {
terminalPanel.dropFiles = true
}
companion object { companion object {
val canSupports by lazy { val canSupports by lazy {
@@ -115,16 +121,21 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
if (envs.containsKey("CurrentDir")) { if (envs.containsKey("CurrentDir")) {
val currentDir = envs.getValue("CurrentDir") val currentDir = envs.getValue("CurrentDir")
commands.add("${host.username}@${host.host}:${currentDir}") commands.add("${host.username}@${host.host}:${currentDir}")
} else if (host.options.sftpDefaultDirectory.isNotBlank()) {
commands.add("${host.username}@${host.host}:${host.options.sftpDefaultDirectory.trim()}")
} else { } else {
commands.add("${host.username}@${host.host}") commands.add("${host.username}@${host.host}")
} }
val directory = FileUtils.getFile(StringUtils.defaultIfBlank(defaultDirectory, SystemUtils.USER_HOME))
val winSize = terminalPanel.winSize() val winSize = terminalPanel.winSize()
val ptyConnector = ptyConnectorFactory.createPtyConnector( val ptyConnector = ptyConnectorFactory.createPtyConnector(
commands.toTypedArray(), commands = commands.toTypedArray(),
winSize.rows, winSize.cols, rows = winSize.rows, cols = winSize.cols,
host.options.envs(), env = host.options.envs(),
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8), charset = Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
directory = if (directory.exists()) directory.absolutePath else SystemUtils.USER_HOME
) )
return ptyConnector return ptyConnector

View File

@@ -1,73 +0,0 @@
package app.termora
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import app.termora.transport.TransportDataProviders
import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
private val sftp get() = Database.getDatabase().sftp
private val transportPanel = TransportPanel()
init {
Disposer.register(this, transportPanel)
}
override fun getTitle(): String {
return "SFTP"
}
override fun getIcon(): Icon {
return Icons.folder
}
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
}
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
}
override fun getJComponent(): JComponent {
return transportPanel
}
override fun canClone(): Boolean {
return false
}
override fun canClose(): Boolean {
assertEventDispatchThread()
if (sftp.pinTab) {
return false
}
val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true
if (transportManager.getTransports().isEmpty()) {
return true
}
return OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(getJComponent()),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == TransportDataProviders.TransportPanel) {
return transportPanel as T
}
return null
}
}

View File

@@ -15,6 +15,7 @@ import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro import app.termora.macro.Macro
import app.termora.macro.MacroManager import app.termora.macro.MacroManager
import app.termora.native.FileChooser import app.termora.native.FileChooser
import app.termora.sftp.SFTPTab
import app.termora.snippet.Snippet import app.termora.snippet.Snippet
import app.termora.snippet.SnippetManager import app.termora.snippet.SnippetManager
import app.termora.sync.SyncConfig import app.termora.sync.SyncConfig
@@ -25,7 +26,6 @@ import app.termora.terminal.CursorStyle
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.terminal.panel.FloatingToolbarPanel import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalPanel import app.termora.terminal.panel.TerminalPanel
import app.termora.transport.SFTPAction
import cash.z.ecc.android.bip39.Mnemonics import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.FlatSVGIcon
@@ -46,14 +46,15 @@ import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils import org.apache.commons.lang3.time.DateFormatUtils
import org.jdesktop.swingx.JXEditorPane import org.jdesktop.swingx.JXEditorPane
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
import java.awt.Dimension import java.awt.Dimension
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import java.awt.event.ActionEvent
import java.awt.event.ItemEvent import java.awt.event.ItemEvent
import java.awt.event.ItemListener
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -72,7 +73,6 @@ class SettingsOptionsPane : OptionsPane() {
private val snippetManager get() = SnippetManager.getInstance() private val snippetManager get() = SnippetManager.getInstance()
private val keymapManager get() = KeymapManager.getInstance() private val keymapManager get() = KeymapManager.getInstance()
private val macroManager get() = MacroManager.getInstance() private val macroManager get() = MacroManager.getInstance()
private val actionManager get() = ActionManager.getInstance()
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance() private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
private val keyManager get() = KeyManager.getInstance() private val keyManager get() = KeyManager.getInstance()
@@ -1334,9 +1334,11 @@ class SettingsOptionsPane : OptionsPane() {
private val editCommandField = OutlineTextField(255) private val editCommandField = OutlineTextField(255)
private val sftpCommandField = OutlineTextField(255) private val sftpCommandField = OutlineTextField(255)
private val defaultDirectoryField = OutlineTextField(255)
private val browseDirectoryBtn = JButton(Icons.folder)
private val pinTabComboBox = YesOrNoComboBox() private val pinTabComboBox = YesOrNoComboBox()
private val preserveModificationTimeComboBox = YesOrNoComboBox()
private val sftp get() = database.sftp private val sftp get() = database.sftp
private val sftpAction get() = actionManager.getAction(Actions.SFTP) as SFTPAction
init { init {
initView() initView()
@@ -1358,27 +1360,55 @@ class SettingsOptionsPane : OptionsPane() {
} }
}) })
pinTabComboBox.addItemListener { defaultDirectoryField.document.addDocumentListener(object : DocumentAdaptor() {
if (it.stateChange == ItemEvent.SELECTED) { override fun changedUpdate(e: DocumentEvent) {
sftp.defaultDirectory = defaultDirectoryField.text
}
})
pinTabComboBox.addItemListener(object : ItemListener {
override fun itemStateChanged(e: ItemEvent) {
if (e.stateChange != ItemEvent.SELECTED) return
sftp.pinTab = pinTabComboBox.selectedItem as Boolean sftp.pinTab = pinTabComboBox.selectedItem as Boolean
for (window in TermoraFrameManager.getInstance().getWindows()) { for (window in TermoraFrameManager.getInstance().getWindows()) {
val evt = AnActionEvent(window, StringUtils.EMPTY, EventObject(window)) val evt = AnActionEvent(window, StringUtils.EMPTY, EventObject(window))
if (pinTabComboBox.selectedItem == true) {
sftpAction.openOrCreateSFTPTerminalTab(evt)
}
val tabbed = evt.getData(DataProviders.TabbedPane) ?: continue
val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue
for ((index, tab) in manager.getTerminalTabs().withIndex()) {
if (tab is SFTPTerminalTab) { if (sftp.pinTab) {
tabbed.setTabClosable(index, pinTabComboBox.selectedItem != true) if (manager.getTerminalTabs().none { it is SFTPTab }) {
break manager.addTerminalTab(1, SFTPTab(), false)
} }
} }
// 刷新状态
manager.refreshTerminalTabs()
} }
} }
})
preserveModificationTimeComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
sftp.preserveModificationTime = preserveModificationTimeComboBox.selectedItem as Boolean
} }
} }
browseDirectoryBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val chooser = FileChooser()
chooser.allowsMultiSelection = false
chooser.defaultDirectory = StringUtils.defaultIfBlank(
defaultDirectoryField.text,
SystemUtils.USER_HOME
)
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.showOpenDialog(owner).thenAccept { files ->
if (files.isNotEmpty()) defaultDirectoryField.text = files.first().absolutePath
}
}
})
}
private fun initView() { private fun initView() {
if (SystemInfo.isWindows || SystemInfo.isLinux) { if (SystemInfo.isWindows || SystemInfo.isLinux) {
@@ -1393,9 +1423,14 @@ class SettingsOptionsPane : OptionsPane() {
sftpCommandField.placeholderText = "sftp" sftpCommandField.placeholderText = "sftp"
} }
defaultDirectoryField.placeholderText = SystemUtils.USER_HOME
defaultDirectoryField.trailingComponent = browseDirectoryBtn
defaultDirectoryField.text = sftp.defaultDirectory
editCommandField.text = sftp.editCommand editCommandField.text = sftp.editCommand
sftpCommandField.text = sftp.sftpCommand sftpCommandField.text = sftp.sftpCommand
pinTabComboBox.selectedItem = sftp.pinTab pinTabComboBox.selectedItem = sftp.pinTab
preserveModificationTimeComboBox.selectedItem = sftp.preserveModificationTime
} }
override fun getIcon(isSelected: Boolean): Icon { override fun getIcon(isSelected: Boolean): Icon {
@@ -1416,13 +1451,23 @@ class SettingsOptionsPane : OptionsPane() {
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
) )
val box = Box.createHorizontalBox()
box.add(JLabel("${I18n.getString("termora.settings.sftp.preserve-time")}:"))
box.add(Box.createHorizontalStrut(8))
box.add(preserveModificationTimeComboBox)
var rows = 1
val builder = FormBuilder.create().layout(layout).debug(false) val builder = FormBuilder.create().layout(layout).debug(false)
builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, 1) builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, rows)
builder.add(pinTabComboBox).xy(3, 1) builder.add(pinTabComboBox).xy(3, rows).apply { rows += 2 }
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, 3) builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, rows)
builder.add(editCommandField).xy(3, 3) builder.add(editCommandField).xy(3, rows).apply { rows += 2 }
builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, 5) builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, rows)
builder.add(sftpCommandField).xy(3, 5) builder.add(sftpCommandField).xy(3, rows).apply { rows += 2 }
builder.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
builder.add(defaultDirectoryField).xy(3, rows).apply { rows += 2 }
builder.add(box).xyw(1, rows, 3).apply { rows += 2 }
return builder.build() return builder.build()

View File

@@ -1,7 +1,11 @@
package app.termora package app.termora
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize import app.termora.terminal.TerminalSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.ClientBuilder import org.apache.sshd.client.ClientBuilder
@@ -16,6 +20,7 @@ import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier
import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
import org.apache.sshd.client.keyverifier.ServerKeyVerifier import org.apache.sshd.client.keyverifier.ServerKeyVerifier
import org.apache.sshd.client.session.ClientSession import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.AttributeRepository
import org.apache.sshd.common.SshException import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.PtyChannelConfiguration import org.apache.sshd.common.channel.PtyChannelConfiguration
import org.apache.sshd.common.config.keys.KeyUtils import org.apache.sshd.common.config.keys.KeyUtils
@@ -48,6 +53,9 @@ import javax.swing.SwingUtilities
import kotlin.math.max import kotlin.math.max
object SshClients { object SshClients {
val HOST_KEY = AttributeRepository.AttributeKey<Host>()
private val timeout = Duration.ofSeconds(30) private val timeout = Duration.ofSeconds(30)
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) } private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
@@ -151,7 +159,8 @@ object SshClients {
log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}") log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
} }
// 映射完毕之后修改Host和端口 // 映射完毕之后修改Host和端口
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis()) jumpHosts[i + 1] =
nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
} }
} }
@@ -191,6 +200,8 @@ object SshClients {
throw SshException("Authentication failed") throw SshException("Authentication failed")
} }
session.setAttribute(HOST_KEY, host)
return session return session
} }
@@ -230,6 +241,29 @@ object SshClients {
return sshdSocketAddress return sshdSocketAddress
} }
suspend fun openClient(host: Host, owner: Window): Pair<SshClient, Host> {
val client = openClient(host)
var myHost = host
withContext(Dispatchers.Swing) {
client.userInteraction = TerminalUserInteraction(owner)
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// 弹出授权框
if (host.authentication.type == AuthenticationType.No) {
val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication()
myHost = myHost.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(myHost)
}
}
}
return client to myHost
}
/** /**
* 打开一个客户端 * 打开一个客户端
*/ */

View File

@@ -43,6 +43,8 @@ interface TerminalTab : Disposable, DataProvider {
*/ */
fun canClose(): Boolean = true fun canClose(): Boolean = true
fun willBeClose(): Boolean = true
/** /**
* 是否可以克隆 * 是否可以克隆
*/ */

View File

@@ -6,7 +6,6 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProvider import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult import app.termora.findeverywhere.FindEverywhereResult
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.transport.TransportPanel
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.extras.components.FlatTabbedPane
@@ -121,7 +120,7 @@ class TerminalTabbed(
val results = mutableListOf<FindEverywhereResult>() val results = mutableListOf<FindEverywhereResult>()
for (i in 0 until tabbedPane.tabCount) { for (i in 0 until tabbedPane.tabCount) {
val c = tabbedPane.getComponentAt(i) val c = tabbedPane.getComponentAt(i)
if (c is WelcomePanel || c is TransportPanel) { if (c is JComponent && c.getClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE) != null) {
continue continue
} }
results.add( results.add(
@@ -155,7 +154,7 @@ class TerminalTabbed(
val tab = tabs[index] val tab = tabs[index]
if (disposable) { if (disposable) {
if (!tab.canClose()) { if (!tab.willBeClose()) {
return return
} }
} }
@@ -327,6 +326,13 @@ class TerminalTabbed(
Disposer.register(this, tab) Disposer.register(this, tab)
} }
override fun refreshTerminalTabs() {
for (i in 0 until tabbedPane.tabCount) {
tabbedPane.setTabClosable(i, tabs[i].canClose())
}
}
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) { private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
if (!SFTPPtyTerminalTab.canSupports) { if (!SFTPPtyTerminalTab.canSupports) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(

View File

@@ -7,4 +7,5 @@ interface TerminalTabbedManager {
fun getTerminalTabs(): List<TerminalTab> fun getTerminalTabs(): List<TerminalTab>
fun setSelectedTerminalTab(tab: TerminalTab) fun setSelectedTerminalTab(tab: TerminalTab)
fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true) fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true)
fun refreshTerminalTabs()
} }

View File

@@ -4,6 +4,7 @@ package app.termora
import app.termora.actions.DataProvider import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
import app.termora.sftp.SFTPTab
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
@@ -103,7 +104,7 @@ class TermoraFrame : JFrame(), DataProvider {
// 下一次事件循环检测是否固定 SFTP // 下一次事件循环检测是否固定 SFTP
SwingUtilities.invokeLater { SwingUtilities.invokeLater {
if (sftp.pinTab) { if (sftp.pinTab) {
terminalTabbed.addTerminalTab(SFTPTerminalTab(), false) terminalTabbed.addTerminalTab(SFTPTab(), false)
} }
} }

View File

@@ -47,6 +47,7 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
private fun initView() { private fun initView() {
putClientProperty(FlatClientProperties.TABBED_PANE_TAB_CLOSABLE, false) putClientProperty(FlatClientProperties.TABBED_PANE_TAB_CLOSABLE, false)
putClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE, true)
val panel = JPanel(BorderLayout()) val panel = JPanel(BorderLayout())
panel.add(createSearchPanel(), BorderLayout.NORTH) panel.add(createSearchPanel(), BorderLayout.NORTH)

View File

@@ -6,9 +6,9 @@ import app.termora.findeverywhere.FindEverywhereAction
import app.termora.highlight.KeywordHighlightAction import app.termora.highlight.KeywordHighlightAction
import app.termora.keymgr.KeyManagerAction import app.termora.keymgr.KeyManagerAction
import app.termora.macro.MacroAction import app.termora.macro.MacroAction
import app.termora.sftp.SFTPAction
import app.termora.snippet.SnippetAction import app.termora.snippet.SnippetAction
import app.termora.tlog.TerminalLoggerAction import app.termora.tlog.TerminalLoggerAction
import app.termora.transport.SFTPAction
import javax.swing.Action import javax.swing.Action
class ActionManager : org.jdesktop.swingx.action.ActionManager() { class ActionManager : org.jdesktop.swingx.action.ActionManager() {

View File

@@ -73,6 +73,9 @@ class AppUpdateAction private constructor() : AnAction(
} }
private suspend fun checkUpdate() { private suspend fun checkUpdate() {
if (Application.isUnknownVersion()) {
return
}
val latestVersion = updaterManager.fetchLatestVersion() val latestVersion = updaterManager.fetchLatestVersion()
if (latestVersion.isSelf) { if (latestVersion.isSelf) {
@@ -220,7 +223,10 @@ class AppUpdateAction private constructor() : AnAction(
// 没有安装过 则打开安装向导 // 没有安装过 则打开安装向导
else listOf(file.absolutePath) else listOf(file.absolutePath)
println(commands) if (log.isInfoEnabled) {
log.info("restart {}", commands.joinToString(StringUtils.SPACE))
}
TermoraRestarter.getInstance().scheduleRestart(owner, commands) TermoraRestarter.getInstance().scheduleRestart(owner, commands)
} }

View File

@@ -5,6 +5,9 @@ import app.termora.Scope
interface FindEverywhereProvider { interface FindEverywhereProvider {
companion object { companion object {
const val SKIP_FIND_EVERYWHERE = "SKIP_FIND_EVERYWHERE"
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun getFindEverywhereProviders(scope: Scope): MutableList<FindEverywhereProvider> { fun getFindEverywhereProviders(scope: Scope): MutableList<FindEverywhereProvider> {
var list = scope.getAnyOrNull("FindEverywhereProviders") var list = scope.getAnyOrNull("FindEverywhereProviders")

View File

@@ -1,17 +1,15 @@
package app.termora.macro package app.termora.macro
import app.termora.Actions import app.termora.Actions
import app.termora.terminal.PtyConnector import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate import app.termora.terminal.PtyConnectorDelegate
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
import java.util.*
class MacroPtyConnector(private val connector: PtyConnector) : PtyConnectorDelegate(connector) { class MacroPtyConnector(private val connector: PtyConnector) : PtyConnectorDelegate(connector) {
private val isRecording get() = ActionManager.getInstance().isSelected(Actions.MACRO) private val isRecording get() = ActionManager.getInstance().isSelected(Actions.MACRO)
companion object { companion object {
private val bytes = LinkedList<Byte>() private val bytes = ArrayDeque<Byte>()
fun getRecodingByteArray(): ByteArray { fun getRecodingByteArray(): ByteArray {
val array = bytes.toByteArray() val array = bytes.toByteArray()

View File

@@ -35,6 +35,11 @@ class FileChooser {
} else { } else {
val fileChooser = JnaFileChooser() val fileChooser = JnaFileChooser()
fileChooser.isMultiSelectionEnabled = allowsMultiSelection fileChooser.isMultiSelectionEnabled = allowsMultiSelection
when (fileSelectionMode) {
JFileChooser.DIRECTORIES_ONLY -> fileChooser.mode = JnaFileChooser.Mode.Directories
JFileChooser.FILES_ONLY -> fileChooser.mode = JnaFileChooser.Mode.Files
JFileChooser.FILES_AND_DIRECTORIES -> fileChooser.mode = JnaFileChooser.Mode.FilesAndDirectories
}
fileChooser.setTitle(title) fileChooser.setTitle(title)
if (defaultDirectory.isNotBlank()) { if (defaultDirectory.isNotBlank()) {

View File

@@ -1,15 +1,10 @@
package app.termora.transport package app.termora.sftp
import app.termora.*
import app.termora.Application.ohMyJson import app.termora.Application.ohMyJson
import app.termora.DynamicColor
import app.termora.I18n
import app.termora.Icons
import app.termora.assertEventDispatchThread
import app.termora.Database
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.ui.FlatUIUtils import com.formdev.flatlaf.ui.FlatUIUtils
import kotlinx.serialization.encodeToString
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import java.awt.* import java.awt.*
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
@@ -23,6 +18,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
private val properties by lazy { Database.getDatabase().properties } private val properties by lazy { Database.getDatabase().properties }
private val arrowWidth = 16 private val arrowWidth = 16
private val arrowSize = 6 private val arrowSize = 6
private val button = this
/** /**
* true 表示在书签内 * true 表示在书签内
@@ -49,13 +45,15 @@ class BookmarkButton : JButton(Icons.bookmarks) {
override fun mouseClicked(e: MouseEvent) { override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e)) { if (SwingUtilities.isLeftMouseButton(e)) {
if (e.x < oldWidth) { if (e.x < oldWidth) {
super@BookmarkButton.fireActionPerformed( for (listener in actionListeners) {
listener.actionPerformed(
ActionEvent( ActionEvent(
this@BookmarkButton, button,
ActionEvent.ACTION_PERFORMED, ActionEvent.ACTION_PERFORMED,
StringUtils.EMPTY StringUtils.EMPTY
) )
) )
}
} else { } else {
showBookmarks(e) showBookmarks(e)
} }
@@ -80,9 +78,10 @@ class BookmarkButton : JButton(Icons.bookmarks) {
popupMenu.addSeparator() popupMenu.addSeparator()
for (bookmark in bookmarks) { for (bookmark in bookmarks) {
popupMenu.add(bookmark).addActionListener { popupMenu.add(bookmark).addActionListener {
super@BookmarkButton.fireActionPerformed( for (listener in actionListeners) {
listener.actionPerformed(
ActionEvent( ActionEvent(
this@BookmarkButton, button,
ActionEvent.ACTION_PERFORMED, ActionEvent.ACTION_PERFORMED,
bookmark bookmark
) )
@@ -90,6 +89,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
} }
} }
} }
}
@@ -140,7 +140,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
g.color = if (FlatLaf.isLafDark()) Color(206, 208, 214) else Color(108, 112, 126) g.color = if (FlatLaf.isLafDark()) Color(206, 208, 214) else Color(108, 112, 126)
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
FlatUIUtils.paintArrow( FlatUIUtils.paintArrow(
g2d, x, preferredSize.height / 2 - arrowSize, arrowWidth, arrowWidth, SwingConstants.SOUTH, g2d, x, preferredSize.height / 2 - arrowSize, arrowWidth, arrowWidth, SwingConstants.SOUTH,
false, arrowSize, 0f, 0f, 0f false, arrowSize, 0f, 0f, 0f

View File

@@ -1,4 +1,4 @@
package app.termora.transport package app.termora.sftp
import app.termora.DialogWrapper import app.termora.DialogWrapper
import app.termora.DynamicColor import app.termora.DynamicColor

View File

@@ -0,0 +1,259 @@
package app.termora.sftp
import app.termora.Icons
import app.termora.assertEventDispatchThread
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextField
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Graphics
import java.awt.Point
import java.awt.event.ActionEvent
import java.awt.event.ActionListener
import java.awt.event.ItemEvent
import java.awt.event.ItemListener
import java.nio.file.FileSystem
import java.nio.file.Path
import javax.swing.*
import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener
import javax.swing.filechooser.FileSystemView
import kotlin.io.path.absolutePathString
class FileSystemViewNav(
private val fileSystem: FileSystem,
private val homeDirectory: Path
) : JPanel(BorderLayout()) {
companion object {
private const val PATH = "path"
private val log = LoggerFactory.getLogger(FileSystemViewNav::class.java)
}
private val fileSystemView = FileSystemView.getFileSystemView()
private val textField = MyFlatTextField()
private var popupLastTime = 0L
private val history = linkedSetOf<String>()
private val layeredPane = LayeredPane()
private val downBtn = JButton(Icons.chevronDown)
private val comboBox = object : JComboBox<Path>() {
override fun getLocationOnScreen(): Point {
val point = super.getLocationOnScreen()
point.y -= 1
return point
}
}
init {
initViews()
initEvents()
}
private fun initViews() {
comboBox.isEnabled = false
comboBox.putClientProperty("JComboBox.isTableCellEditor", true)
textField.leadingIcon = NativeFileIcons.getFolderIcon()
textField.trailingComponent = downBtn
textField.text = homeDirectory.absolutePathString()
textField.putClientProperty(PATH, homeDirectory)
downBtn.putClientProperty(
FlatClientProperties.STYLE,
mapOf(
"toolbar.hoverBackground" to UIManager.getColor("Button.background"),
"toolbar.pressedBackground" to UIManager.getColor("Button.background"),
)
)
comboBox.renderer = object : DefaultListCellRenderer() {
private val indentIcon = IndentIcon()
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
val c = super.getListCellRendererComponent(
list,
value,
index,
isSelected,
cellHasFocus
)
indentIcon.depth = 0
indentIcon.icon = NativeFileIcons.getFolderIcon()
icon = indentIcon
return c
}
}
layeredPane.add(comboBox, JLayeredPane.DEFAULT_LAYER as Any)
layeredPane.add(textField, JLayeredPane.PALETTE_LAYER as Any)
add(layeredPane, BorderLayout.CENTER)
if (fileSystem.isWindows()) {
try {
for (root in fileSystemView.roots) {
history.add(root.absolutePath)
}
for (rootDirectory in fileSystem.rootDirectories) {
history.add(rootDirectory.absolutePathString())
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
private fun initEvents() {
val itemListener = ItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
val item = comboBox.selectedItem
if (item is Path) {
changeSelectedPath(item)
}
}
}
comboBox.addPopupMenuListener(object : PopupMenuListener {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
comboBox.addItemListener(itemListener)
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
popupLastTime = System.currentTimeMillis()
comboBox.removeItemListener(itemListener)
comboBox.isEnabled = false
textField.requestFocusInWindow()
}
override fun popupMenuCanceled(e: PopupMenuEvent?) {
}
})
// 监听 Action
addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val text = textField.text.trim()
if (text.isBlank()) return
if (history.contains(text)) return
history.add(text)
}
})
downBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (System.currentTimeMillis() - popupLastTime < 250) return
comboBox.isEnabled = true
comboBox.requestFocusInWindow()
showComboBoxPopup()
}
})
textField.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val name = textField.text.trim()
if (name.isBlank()) return
}
})
}
private fun showComboBoxPopup() {
comboBox.removeAllItems()
for (text in history) {
val path = fileSystem.getPath(text)
comboBox.addItem(path)
if (text == textField.text) {
comboBox.selectedItem = path
}
}
comboBox.showPopup()
}
fun addActionListener(l: ActionListener) {
listenerList.add(ActionListener::class.java, l)
}
class IndentIcon : Icon {
val space = 10
var depth: Int = 0
var icon = NativeFileIcons.getFolderIcon()
override fun paintIcon(c: Component, g: Graphics, x: Int, y: Int) {
if (c.componentOrientation.isLeftToRight) {
icon.paintIcon(c, g, x + depth * space, y)
} else {
icon.paintIcon(c, g, x, y)
}
}
override fun getIconWidth(): Int {
return icon.iconWidth + depth * space
}
override fun getIconHeight(): Int {
return icon.iconHeight
}
}
fun getSelectedPath(): Path {
return textField.getClientProperty(PATH) as Path
}
fun changeSelectedPath(path: Path) {
assertEventDispatchThread()
textField.text = path.absolutePathString()
textField.putClientProperty(PATH, path)
for (listener in listenerList.getListeners(ActionListener::class.java)) {
listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))
}
}
@Suppress("UNNECESSARY_SAFE_CALL")
override fun updateUI() {
super.updateUI()
downBtn?.putClientProperty(
FlatClientProperties.STYLE,
mapOf(
"toolbar.hoverBackground" to UIManager.getColor("Button.background"),
"toolbar.pressedBackground" to UIManager.getColor("Button.background"),
)
)
}
class MyFlatTextField : FlatTextField() {
public override fun fireActionPerformed() {
super.fireActionPerformed()
}
}
private class LayeredPane : JLayeredPane() {
override fun doLayout() {
synchronized(treeLock) {
for (c in components) {
c.setBounds(0, 0, width, height)
}
}
}
}
}

View File

@@ -0,0 +1,468 @@
package app.termora.sftp
import app.termora.*
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import com.formdev.flatlaf.extras.components.FlatToolBar
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.jdesktop.swingx.JXBusyLabel
import java.awt.BorderLayout
import java.awt.event.*
import java.nio.file.FileSystem
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.concurrent.atomic.AtomicBoolean
import java.util.function.Consumer
import javax.swing.*
import kotlin.io.path.absolutePathString
import kotlin.io.path.name
class FileSystemViewPanel(
val host: Host,
val fileSystem: FileSystem,
private val transportManager: TransportManager,
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) : JPanel(BorderLayout()), Disposable, DataProvider {
private val properties get() = Database.getDatabase().properties
private val sftp get() = Database.getDatabase().sftp
private val table = FileSystemViewTable(fileSystem, transportManager, coroutineScope)
private val disposed = AtomicBoolean(false)
private var nextReloadTicks = emptyArray<Consumer<Unit>>()
private val isLoading = AtomicBoolean(false)
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val loadingPanel = LoadingPanel()
private val layeredPane = LayeredPane()
private val homeDirectory = getHomeDirectory()
private val nav = FileSystemViewNav(fileSystem, homeDirectory)
private var workdir = homeDirectory
private val model get() = table.model as FileSystemViewTableModel
private val showHiddenFilesKey = "termora.transport.host.${host.id}.show-hidden-files"
private var useFileHiding: Boolean
get() = properties.getString(showHiddenFilesKey, "true").toBoolean()
set(value) = properties.putString(showHiddenFilesKey, value.toString())
val isDisposed get() = disposed.get()
init {
initViews()
initEvents()
}
private fun initViews() {
val toolbar = FlatToolBar()
toolbar.add(createHomeFolderButton())
toolbar.add(Box.createHorizontalStrut(2))
toolbar.add(nav)
toolbar.add(createBookmarkButton())
toolbar.add(createParentFolderButton())
toolbar.add(createHiddenFilesButton())
toolbar.add(createRefreshButton())
add(toolbar, BorderLayout.NORTH)
add(layeredPane, BorderLayout.CENTER)
val scrollPane = JScrollPane(table)
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
layeredPane.add(scrollPane, JLayeredPane.DEFAULT_LAYER as Any)
layeredPane.add(loadingPanel, JLayeredPane.PALETTE_LAYER as Any)
}
private fun initEvents() {
Disposer.register(this, table)
nav.addActionListener { reload() }
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
enterTableSelectionFolder()
}
}
})
table.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if (e.keyCode == KeyEvent.VK_ENTER) {
enterTableSelectionFolder()
}
}
})
val listener = object : TransportListener, Disposable {
override fun onTransportChanged(transport: Transport) {
val path = transport.target.parent ?: return
if (path.fileSystem != fileSystem) return
if (path.absolutePathString() != workdir.absolutePathString()) return
// 立即刷新
reload(true)
}
override fun dispose() {
transportManager.removeTransportListener(this)
}
}
transportManager.addTransportListener(listener)
Disposer.register(this, listener)
// 变更工作目录
if (SwingUtilities.isEventDispatchThread()) {
changeWorkdir(homeDirectory)
} else {
SwingUtilities.invokeLater { changeWorkdir(homeDirectory) }
}
}
private fun enterTableSelectionFolder(row: Int = table.selectedRow) {
if (row < 0 || isLoading.get()) return
val attr = model.getAttr(row)
if (attr.isFile) return
// 当前工作目录
val workdir = getWorkdir()
// 返回上级之后,选中上级目录
if (attr.name == "..") {
val workdirName = workdir.name
nextReloadTickSelection(workdirName)
}
changeWorkdir(attr.path)
}
private fun createRefreshButton(): JButton {
val button = JButton(Icons.refresh)
button.addActionListener { reload(true) }
return button
}
private fun createHiddenFilesButton(): JButton {
val button = JButton(if (useFileHiding) Icons.eyeClose else Icons.eye)
button.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
useFileHiding = !useFileHiding
button.icon = if (useFileHiding) Icons.eyeClose else Icons.eye
reload(true)
}
})
return button
}
private fun createHomeFolderButton(): JButton {
val button = JButton(Icons.homeFolder)
button.addActionListener { nav.changeSelectedPath(homeDirectory) }
return button
}
private fun createBookmarkButton(): JButton {
val bookmarkBtn = BookmarkButton()
bookmarkBtn.name = "Host.${host.id}.Bookmarks"
bookmarkBtn.addActionListener { e ->
if (e.actionCommand.isNullOrBlank()) {
if (bookmarkBtn.isBookmark) {
bookmarkBtn.deleteBookmark(workdir.toString())
} else {
bookmarkBtn.addBookmark(workdir.toString())
}
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
} else {
changeWorkdir(fileSystem.getPath(e.actionCommand))
}
}
nav.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(nav.getSelectedPath().absolutePathString())
}
})
return bookmarkBtn
}
private fun createParentFolderButton(): AbstractButton {
val button = JButton(Icons.up)
button.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (model.rowCount < 1) return
val attr = model.getAttr(0)
if (attr !is FileSystemViewTableModel.ParentAttr) return
enterTableSelectionFolder(0)
}
})
addPropertyChangeListener("workdir") {
button.isEnabled = model.rowCount > 0 && model.getAttr(0) is FileSystemViewTableModel.ParentAttr
}
return button
}
private fun nextReloadTickSelection(name: String, consumer: Consumer<Int> = Consumer { }) {
// 创建成功之后需要修改和选中
registerNextReloadTick {
for (i in 0 until table.rowCount) {
if (model.getAttr(i).name == name) {
table.addRowSelectionInterval(i, i)
table.scrollRectToVisible(table.getCellRect(i, 0, true))
consumer.accept(i)
break
}
}
}
}
private fun changeWorkdir(workdir: Path) {
assertEventDispatchThread()
nav.changeSelectedPath(workdir)
}
fun renameTo(oldPath: Path, newPath: Path) {
// 新建文件夹
coroutineScope.launch {
if (requestLoading()) {
try {
Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE)
} catch (e: Exception) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
SwingUtilities.getWindowAncestor(owner),
ExceptionUtils.getMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
} finally {
stopLoading()
}
}
// 创建成功之后需要选中
nextReloadTickSelection(newPath.name)
// 立即刷新
reload()
}
}
fun newFolderOrFile(name: String, isFile: Boolean) {
coroutineScope.launch {
if (requestLoading()) {
try {
doNewFolderOrFile(getWorkdir().resolve(name), isFile)
} finally {
stopLoading()
}
}
// 创建成功之后需要修改和选中
nextReloadTickSelection(name)
// 立即刷新
reload()
}
}
private suspend fun doNewFolderOrFile(path: Path, isFile: Boolean) {
if (Files.exists(path)) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.transport.file-already-exists", path.name),
messageType = JOptionPane.ERROR_MESSAGE
)
}
return
}
// 创建文件夹
withContext(Dispatchers.IO) {
runCatching { if (isFile) Files.createFile(path) else Files.createDirectories(path) }.onFailure {
withContext(Dispatchers.Swing) {
if (it is Exception) {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
}
}
}
fun requestLoading(): Boolean {
if (isLoading.compareAndSet(false, true)) {
if (SwingUtilities.isEventDispatchThread()) {
loadingPanel.start()
} else {
SwingUtilities.invokeLater { loadingPanel.start() }
}
return true
}
return false
}
fun stopLoading() {
if (isLoading.compareAndSet(true, false)) {
if (SwingUtilities.isEventDispatchThread()) {
loadingPanel.stop()
} else {
SwingUtilities.invokeLater { loadingPanel.stop() }
}
}
}
fun reload(rememberSelection: Boolean = false) {
if (!requestLoading()) return
if (fileSystem.isSFTP()) loadingPanel.start()
val oldWorkdir = workdir
val path = nav.getSelectedPath()
coroutineScope.launch {
try {
if (rememberSelection) {
withContext(Dispatchers.Swing) {
table.selectedRows.sortedDescending().map { model.getAttr(it).name }
.forEach { nextReloadTickSelection(it) }
}
}
runCatching { model.reload(path, useFileHiding) }.onFailure {
if (it is Exception) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, ExceptionUtils.getMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
}.onSuccess {
withContext(Dispatchers.Swing) {
workdir = path
// 触发工作目录变动
firePropertyChange("workdir", oldWorkdir, workdir)
}
}
withContext(Dispatchers.Swing) {
// 触发
triggerNextReloadTicks()
}
} finally {
stopLoading()
if (fileSystem.isSFTP()) {
withContext(Dispatchers.Swing) { loadingPanel.stop() }
}
}
}
}
private fun getHomeDirectory(): Path {
if (fileSystem.isSFTP()) {
val fs = fileSystem as SftpFileSystem
val host = fs.session.getAttribute(SshClients.HOST_KEY) ?: return fs.defaultDir
val defaultDirectory = host.options.sftpDefaultDirectory
if (defaultDirectory.isNotBlank()) {
return runCatching { fs.getPath(defaultDirectory) }
.getOrElse { fs.defaultDir }
}
return fs.defaultDir
}
if (sftp.defaultDirectory.isNotBlank()) {
return runCatching { fileSystem.getPath(sftp.defaultDirectory) }
.getOrElse { fileSystem.getPath(SystemUtils.USER_HOME) }
}
return fileSystem.getPath(SystemUtils.USER_HOME)
}
fun getWorkdir(): Path {
return workdir
}
private fun registerNextReloadTick(consumer: Consumer<Unit>) {
nextReloadTicks += Consumer<Unit> { t ->
assertEventDispatchThread()
consumer.accept(t)
}
}
private fun triggerNextReloadTicks() {
for (nextReloadTick in nextReloadTicks) {
nextReloadTick.accept(Unit)
}
nextReloadTicks = emptyArray()
}
override fun dispose() {
if (disposed.compareAndSet(false, true)) {
val rootChildren = transportManager.getTransports(0L)
for (child in rootChildren) {
if (child.source.fileSystem == fileSystem ||
child.target.fileSystem == fileSystem
) {
child.changeStatus(TransportStatus.Failed)
}
}
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return if (dataKey == SFTPDataProviders.FileSystemViewTable) table as T else null
}
private class LoadingPanel : JPanel() {
private val busyLabel = JXBusyLabel()
init {
isOpaque = false
border = BorderFactory.createEmptyBorder(50, 0, 0, 0)
add(busyLabel, BorderLayout.CENTER)
addMouseListener(object : MouseAdapter() {})
isVisible = false
}
fun start() {
busyLabel.isBusy = true
isVisible = true
}
fun stop() {
busyLabel.isBusy = false
isVisible = false
}
}
private class LayeredPane : JLayeredPane() {
override fun doLayout() {
synchronized(treeLock) {
val w = width
val h = height
for (c in components) {
c.setBounds(0, 0, w, h)
}
}
}
}
}

View File

@@ -0,0 +1,844 @@
package app.termora.sftp
import app.termora.*
import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.sftp.client.SftpClient
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath
import org.apache.sshd.sftp.client.fs.SftpPosixFileAttributes
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.awt.Component
import java.awt.Insets
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.awt.event.*
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
import java.text.MessageFormat
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import kotlin.collections.ArrayDeque
import kotlin.io.path.*
import kotlin.time.Duration.Companion.milliseconds
@Suppress("DuplicatedCode")
class FileSystemViewTable(
private val fileSystem: FileSystem,
private val transportManager: TransportManager,
private val coroutineScope: CoroutineScope
) : JTable(), Disposable {
companion object {
private val log = LoggerFactory.getLogger(FileSystemViewTable::class.java)
}
private val sftp get() = Database.getDatabase().sftp
private val model = FileSystemViewTableModel()
private val table = this
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val sftpPanel
get() = SwingUtilities.getAncestorOfClass(SFTPPanel::class.java, this)
as SFTPPanel
private val fileSystemViewPanel
get() = SwingUtilities.getAncestorOfClass(FileSystemViewPanel::class.java, this)
as FileSystemViewPanel
private val actionManager get() = ActionManager.getInstance()
private val isDisposed = AtomicBoolean(false)
init {
initViews()
initEvents()
}
private fun initViews() {
super.setModel(model)
super.getTableHeader().setReorderingAllowed(false)
super.setDragEnabled(true)
super.setDropMode(DropMode.ON_OR_INSERT_ROWS)
super.setCellSelectionEnabled(false)
super.setRowSelectionAllowed(true)
super.setRowHeight(UIManager.getInt("Table.rowHeight"))
super.setAutoResizeMode(AUTO_RESIZE_OFF)
super.setFillsViewportHeight(true)
super.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
"cellMargins" to Insets(0, 4, 0, 4),
)
)
setDefaultRenderer(Any::class.java, object : DefaultTableCellRenderer() {
override fun getTableCellRendererComponent(
table: JTable?,
value: Any?,
isSelected: Boolean,
hasFocus: Boolean,
row: Int,
column: Int
): Component {
foreground = null
val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getAttr(row).icon else null
foreground = if (!isSelected && model.getAttr(row).isHidden)
UIManager.getColor("textInactiveText") else foreground
return c
}
})
columnModel.getColumn(FileSystemViewTableModel.COLUMN_NAME).preferredWidth = 250
columnModel.getColumn(FileSystemViewTableModel.COLUMN_LAST_MODIFIED_TIME).preferredWidth = 130
}
private fun initEvents() {
// contextmenu
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isRightMouseButton(e)) {
val r = table.rowAtPoint(e.point)
if (r >= 0 && r < table.rowCount) {
if (!table.isRowSelected(r)) {
table.setRowSelectionInterval(r, r)
}
} else {
table.clearSelection()
}
val rows = table.selectedRows
if (!table.hasFocus()) {
table.requestFocusInWindow()
}
showContextMenu(rows, e)
} else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val row = table.selectedRow
if (row <= 0 || row >= table.rowCount) return
val attr = model.getAttr(row)
if (attr.isDirectory) return
// 传输
transfer(arrayOf(attr))
}
}
})
// Delete key
table.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) {
val rows = selectedRows
if (rows.contains(0)) return
val attrs = rows.map { model.getAttr(it) }.toTypedArray()
val files = attrs.map { it.path }.toTypedArray()
deletePaths(files, false)
} else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) {
fileSystemViewPanel.reload(true)
} else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F2) {
renameSelection()
}
}
})
table.transferHandler = object : TransferHandler() {
override fun canImport(support: TransferSupport): Boolean {
val dropLocation = support.dropLocation as? JTable.DropLocation ?: return false
// 如果不是新增行,并且光标不在第一列,那么不允许
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false
// 如果不是新增行,如果在一个文件上,那么不允许
if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
return data is FileSystemTableRowTransferable && data.source != table
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
return !fileSystem.isLocal()
}
return false
}
override fun importData(support: TransferSupport): Boolean {
val dropLocation = support.dropLocation as? JTable.DropLocation ?: return false
// 如果不是新增行,并且光标不在第一列,那么不允许
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false
// 如果不是新增行,如果在一个文件上,那么不允许
if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false
var targetWorkdir: Path? = null
// 变更工作目录
if (!dropLocation.isInsertRow) {
targetWorkdir = model.getAttr(dropLocation.row).path
}
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
if (data !is FileSystemTableRowTransferable) return false
// 委托源表开始传输
data.source.transfer(data.attrs.toTypedArray(), false, targetWorkdir)
return true
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return false
val paths = files.filterIsInstance<File>()
.map { FileSystemViewTableModel.Attr(it.toPath()) }
.toTypedArray()
if (paths.isEmpty()) return false
val localTarget = sftpPanel.getLocalTarget()
val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false
// 委托最左侧的本地文件系统传输
table.transfer(paths, true, targetWorkdir)
return true
}
return false
}
override fun getSourceActions(c: JComponent?): Int {
return COPY
}
override fun createTransferable(c: JComponent?): Transferable? {
val attrs = table.selectedRows.filter { it != 0 }.map { model.getAttr(it) }
if (attrs.isEmpty()) return null
return FileSystemTableRowTransferable(table, attrs)
}
}
// 快速导航
addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
val c = e.keyChar
val count = model.rowCount
val row = selectedRow + 1
for (i in row until count) if (navigate(i, c)) return
for (i in 0 until count) if (navigate(i, c)) return
}
private fun navigate(row: Int, c: Char): Boolean {
val name = model.getAttr(row).name
if (name.startsWith(c, true)) {
clearSelection()
addRowSelectionInterval(row, row)
table.scrollRectToVisible(table.getCellRect(row, 0, true))
return true
}
return false
}
})
}
override fun dispose() {
if (isDisposed.compareAndSet(false, true)) {
if (!fileSystem.isSFTP()) {
coroutineScope.cancel()
}
}
}
private fun showContextMenu(rows: IntArray, e: MouseEvent) {
val attrs = rows.map { model.getAttr(it) }.toTypedArray()
val files = attrs.map { it.path }.toTypedArray()
val hasParent = rows.contains(0)
val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
// 创建文件夹
val newFolder = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder"))
// 创建文件
val newFile = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file"))
// 传输
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
// 编辑
val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit"))
edit.isEnabled = fileSystem.isSFTP() && attrs.all { it.isFile }
popupMenu.addSeparator()
// 复制路径
val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path"))
// 如果是本地,那么支持打开本地路径
if (fileSystem.isLocal()) {
popupMenu.add(
I18n.getString(
"termora.transport.table.contextmenu.open-in-folder",
if (SystemInfo.isMacOS) I18n.getString("termora.finder")
else if (SystemInfo.isWindows) I18n.getString("termora.explorer")
else I18n.getString("termora.folder")
)
).addActionListener {
Application.browseInFolder(files.last().toFile())
}
}
popupMenu.addSeparator()
// 重命名
val rename = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.rename"))
// 删除
val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete"))
// rm -rf
val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction))
// 只有 SFTP 可以
if (!fileSystem.isSFTP()) {
rmrf.isVisible = false
}
// 修改权限
val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
permission.isEnabled = false
// 如果是本地系统文件,那么不允许修改权限,用户应该自己修改
if (fileSystem.isSFTP() && rows.isNotEmpty()) {
permission.isEnabled = true
}
popupMenu.addSeparator()
// 刷新
val refresh = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.refresh"))
popupMenu.add(refresh)
popupMenu.addSeparator()
// 新建
popupMenu.add(newMenu)
// 新建文件夹
newFolder.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
newFolderOrFile(false)
}
})
// 新建文件
newFile.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
newFolderOrFile(true)
}
})
rename.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
renameSelection()
}
})
delete.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
deletePaths(files, false)
}
})
rmrf.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
deletePaths(files, true)
}
})
copyPath.addActionListener {
val sb = StringBuilder()
attrs.forEach { sb.append(it.path.absolutePathString()).appendLine() }
sb.deleteCharAt(sb.length - 1)
toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null)
}
edit.addActionListener { if (files.isNotEmpty()) editFiles(files) }
permission.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val last = attrs.last()
val dialog = PosixFilePermissionDialog(
SwingUtilities.getWindowAncestor(table),
last.posixFilePermissions
)
val permissions = dialog.open() ?: return
if (fileSystemViewPanel.requestLoading()) {
coroutineScope.launch(Dispatchers.IO) {
val c = runCatching { Files.setPosixFilePermissions(last.path, permissions) }.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
// stop loading
fileSystemViewPanel.stopLoading()
// reload
if (c.isSuccess) {
fileSystemViewPanel.reload(true)
}
}
}
}
})
refresh.addActionListener { fileSystemViewPanel.reload() }
transfer.addActionListener { transfer(attrs) }
if (rows.isEmpty() || hasParent) {
transfer.isEnabled = false
rename.isEnabled = false
delete.isEnabled = false
edit.isEnabled = false
rmrf.isEnabled = false
copyPath.isEnabled = false
permission.isEnabled = false
} else {
transfer.isEnabled = sftpPanel.canTransfer(table)
}
popupMenu.show(table, e.x, e.y)
}
private fun renameSelection() {
val index = selectedRow
if (index < 0) return
val attr = model.getAttr(index)
val dialog = InputDialog(
owner,
title = attr.name,
text = attr.name,
)
val text = dialog.getText() ?: return
if (text.isBlank() || text == attr.name) return
if (model.getPathNames().contains(text)) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.transport.file-already-exists", text),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
fileSystemViewPanel.renameTo(attr.path, attr.path.parent.resolve(text))
}
private fun editFiles(files: Array<Path>) {
if (files.isEmpty()) return
if (SystemInfo.isLinux) {
if (sftp.editCommand.isBlank()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.transport.table.contextmenu.edit-command"),
messageType = JOptionPane.INFORMATION_MESSAGE
)
actionManager.getAction(SettingsAction.SETTING)
?.actionPerformed(AnActionEvent(this, StringUtils.EMPTY, EventObject(this)))
return
}
}
for (file in files) {
val dir = Application.createSubTemporaryDir()
val path = Paths.get(dir.absolutePathString(), file.name)
val newTransport = createTransport(file, false, 0L)
.apply { target = path }
transportManager.addTransportListener(object : TransportListener {
override fun onTransportChanged(transport: Transport) {
if (transport != newTransport) return
if (transport.status != TransportStatus.Done && transport.status != TransportStatus.Failed) return
transportManager.removeTransportListener(this)
if (transport.status != TransportStatus.Done) return
// 监听文件变动
listenFileChange(path, file)
}
})
transportManager.addTransport(newTransport)
}
}
private fun listenFileChange(localPath: Path, remotePath: Path) {
try {
if (sftp.editCommand.isNotBlank()) {
ProcessBuilder(
parseCommand(
MessageFormat.format(
sftp.editCommand,
localPath.absolutePathString()
)
)
).start()
} else if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start()
} else if (SystemInfo.isWindows) {
ProcessBuilder("notepad", localPath.absolutePathString()).start()
} else {
return
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
return
}
var lastModifiedTime = localPath.getLastModifiedTime().toMillis()
coroutineScope.launch(Dispatchers.IO) {
while (coroutineScope.isActive) {
try {
if (isDisposed.get() || !Files.exists(localPath)) break
val nowModifiedTime = localPath.getLastModifiedTime().toMillis()
if (nowModifiedTime != lastModifiedTime) {
lastModifiedTime = nowModifiedTime
if (log.isDebugEnabled) {
log.debug("Listening to file {} changes", localPath.absolutePathString())
}
withContext(Dispatchers.Swing) {
transportManager.addTransport(
createTransport(localPath, false, 0L)
.apply { target = remotePath })
}
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
break
}
delay(500.milliseconds)
}
}
}
private fun parseCommand(command: String): List<String> {
val result = mutableListOf<String>()
val matcher = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(command)
while (matcher.find()) {
if (matcher.group(1) != null) {
result.add(matcher.group(1)) // 处理双引号部分
} else {
result.add(matcher.group(2).replace("\\\\ ", " "))
}
}
return result
}
private fun newFolderOrFile(isFile: Boolean) {
val name = if (isFile) I18n.getString("termora.transport.table.contextmenu.new.file")
else I18n.getString("termora.welcome.contextmenu.new.folder.name")
val dialog = InputDialog(
owner,
title = name,
text = name,
)
val text = dialog.getText() ?: return
if (text.isBlank()) return
if (model.getPathNames().contains(text)) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.transport.file-already-exists", text),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
fileSystemViewPanel.newFolderOrFile(text, isFile)
}
private fun transfer(
attrs: Array<FileSystemViewTableModel.Attr>,
fromLocalSystem: Boolean = false,
targetWorkdir: Path? = null
) {
coroutineScope.launch {
try {
doTransfer(attrs, fromLocalSystem, targetWorkdir)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
private fun deletePaths(paths: Array<Path>, rm: Boolean = false) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"),
messageType = if (rm) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE
) != JOptionPane.YES_OPTION
) {
return
}
if (!fileSystemViewPanel.requestLoading()) {
return
}
coroutineScope.launch {
runCatching {
if (fileSystem.isSFTP()) {
deleteSftpPaths(paths, rm)
} else {
deleteRecursively(paths)
}
}.onFailure {
if (log.isErrorEnabled) {
log.error(it.message, it)
}
}
// 停止加载
fileSystemViewPanel.stopLoading()
// 刷新
fileSystemViewPanel.reload()
}
}
private fun deleteSftpPaths(paths: Array<Path>, rm: Boolean = false) {
val fs = this.fileSystem as SftpFileSystem
if (rm) {
for (path in paths) {
fs.session.executeRemoteCommand(
"rm -rf '${path.absolutePathString()}'",
OutputStream.nullOutputStream(),
Charsets.UTF_8
)
}
} else {
fs.client.use {
for (path in paths) {
deleteRecursivelySFTP(path as SftpPath, it)
}
}
}
}
private fun deleteRecursively(paths: Array<Path>) {
for (path in paths) {
FileUtils.deleteQuietly(path.toFile())
}
}
/**
* 优化删除效率,采用一个连接
*/
private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) {
val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory()
if (isDirectory) {
for (e in sftpClient.readDir(path.toString())) {
if (e.filename == ".." || e.filename == ".") {
continue
}
if (e.attributes.isDirectory) {
deleteRecursivelySFTP(path.resolve(e.filename), sftpClient)
} else {
sftpClient.remove(path.resolve(e.filename).toString())
}
}
sftpClient.rmdir(path.toString())
} else {
sftpClient.remove(path.toString())
}
}
/**
* 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止
*/
private fun doTransfer(
attrs: Array<FileSystemViewTableModel.Attr>,
fromLocalSystem: Boolean,
targetWorkdir: Path?
) {
if (attrs.isEmpty()) return
val sftpPanel = this.sftpPanel
val target = sftpPanel.getTarget(table) ?: return
var isTerminate = false
val queue = ArrayDeque<Transport>()
for (attr in attrs) {
/**
* 定义一个添加器,它可以自动的判断导入/拖拽行为
*/
val adder = object {
fun add(transport: Transport): Boolean {
return addTransport(
sftpPanel,
if (fromLocalSystem) attr.path.parent else null,
target,
targetWorkdir,
transport
)
}
}
if (attr.isFile) {
if (!adder.add(createTransport(attr.path, false, 0).apply { scanned() })) {
isTerminate = true
break
}
continue
}
queue.clear()
try {
walk(attr.path, object : FileVisitor<Path> {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L)
.apply { queue.addLast(this) }
if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true }
}
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS
val transport = createTransport(file, false, queue.last().id).apply { scanned() }
if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true }
}
override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult {
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
// 标记为扫描完毕
queue.removeLast().scanned()
return FileVisitResult.CONTINUE
}
})
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
isTerminate = true
}
if (isTerminate) break
}
if (isTerminate) {
// 把剩余的文件夹标记为扫描完毕
while (queue.isNotEmpty()) queue.removeLast().scanned()
}
}
private fun walk(dir: Path, visitor: FileVisitor<Path>) {
if (fileSystem is SftpFileSystem) {
val attr = SftpPosixFileAttributes(dir, SftpClient.Attributes())
fileSystem.client.use { walkSFTP(dir, attr, visitor, it) }
} else {
Files.walkFileTree(dir, setOf(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, visitor)
}
}
private fun walkSFTP(
dir: Path,
attr: SftpPosixFileAttributes,
visitor: FileVisitor<Path>,
client: SftpClient
): FileVisitResult {
if (visitor.preVisitDirectory(dir, attr) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
val paths = client.readDir(dir.absolutePathString())
for (e in paths) {
if (e.filename == ".." || e.filename == ".") continue
if (e.attributes.isDirectory) {
if (walkSFTP(dir.resolve(e.filename), attr, visitor, client) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
} else {
val result = visitor.visitFile(dir.resolve(e.filename), attr)
if (result == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
} else if (result == FileVisitResult.SKIP_SUBTREE) {
break
}
}
}
if (visitor.postVisitDirectory(dir, null) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
return FileVisitResult.CONTINUE
}
private fun addTransport(
sftpPanel: SFTPPanel,
sourceWorkdir: Path?,
target: FileSystemViewPanel,
targetWorkdir: Path?,
transport: Transport
): Boolean {
return sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport)
}
private fun createTransport(source: Path, isDirectory: Boolean, parentId: Long): Transport {
val transport = Transport(
source = source,
target = source,
parentId = parentId,
isDirectory = isDirectory,
)
if (transport.isFile) {
transport.filesize.addAndGet(source.fileSize())
}
return transport
}
private class FileSystemTableRowTransferable(
val source: FileSystemViewTable,
val attrs: List<FileSystemViewTableModel.Attr>
) : Transferable {
companion object {
val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable")
}
override fun getTransferDataFlavors(): Array<DataFlavor> {
return arrayOf(dataFlavor)
}
override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean {
return flavor == dataFlavor
}
override fun getTransferData(flavor: DataFlavor?): Any {
if (flavor != dataFlavor) {
throw UnsupportedFlavorException(flavor)
}
return this
}
}
}

View File

@@ -0,0 +1,265 @@
package app.termora.sftp
import app.termora.I18n
import app.termora.NativeStringComparator
import app.termora.formatBytes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.apache.sshd.sftp.client.fs.SftpPath
import org.slf4j.LoggerFactory
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.PosixFilePermissions
import java.util.*
import javax.swing.table.DefaultTableModel
import kotlin.io.path.*
class FileSystemViewTableModel : DefaultTableModel() {
companion object {
const val COLUMN_NAME = 0
const val COLUMN_TYPE = 1
const val COLUMN_FILE_SIZE = 2
const val COLUMN_LAST_MODIFIED_TIME = 3
const val COLUMN_ATTRS = 4
const val COLUMN_OWNER = 5
private val log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java)
private fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
val result = mutableSetOf<PosixFilePermission>()
// 将十进制权限转换为八进制字符串
val octalPermissions = sftpPermissions.toString(8)
// 仅取后三位权限部分
if (octalPermissions.length < 3) {
return result
}
val permissionBits = octalPermissions.takeLast(3)
// 解析每一部分的权限
val owner = permissionBits[0].digitToInt()
val group = permissionBits[1].digitToInt()
val others = permissionBits[2].digitToInt()
// 处理所有者权限
if ((owner and 4) != 0) result.add(PosixFilePermission.OWNER_READ)
if ((owner and 2) != 0) result.add(PosixFilePermission.OWNER_WRITE)
if ((owner and 1) != 0) result.add(PosixFilePermission.OWNER_EXECUTE)
// 处理组权限
if ((group and 4) != 0) result.add(PosixFilePermission.GROUP_READ)
if ((group and 2) != 0) result.add(PosixFilePermission.GROUP_WRITE)
if ((group and 1) != 0) result.add(PosixFilePermission.GROUP_EXECUTE)
// 处理其他用户权限
if ((others and 4) != 0) result.add(PosixFilePermission.OTHERS_READ)
if ((others and 2) != 0) result.add(PosixFilePermission.OTHERS_WRITE)
if ((others and 1) != 0) result.add(PosixFilePermission.OTHERS_EXECUTE)
return result
}
}
override fun getValueAt(row: Int, column: Int): Any {
val attr = getAttr(row)
return when (column) {
COLUMN_NAME -> attr.name
COLUMN_FILE_SIZE -> if (attr.isDirectory) StringUtils.EMPTY else formatBytes(attr.size)
COLUMN_TYPE -> attr.type
COLUMN_LAST_MODIFIED_TIME -> if (attr.modified > 0) DateFormatUtils.format(
Date(attr.modified),
"yyyy/MM/dd HH:mm"
) else StringUtils.EMPTY
COLUMN_ATTRS -> attr.permissions
COLUMN_OWNER -> attr.owner
else -> StringUtils.EMPTY
}
}
override fun getDataVector(): Vector<Vector<Any>> {
return super.getDataVector()
}
override fun getColumnCount(): Int {
return 6
}
override fun getColumnClass(columnIndex: Int): Class<*> {
return when (columnIndex) {
COLUMN_NAME -> String::class.java
else -> super.getColumnClass(columnIndex)
}
}
fun getAttr(row: Int): Attr {
return super.getValueAt(row, 0) as Attr
}
fun getPathNames(): Set<String> {
val names = linkedSetOf<String>()
for (i in 0 until rowCount) {
names.add(getAttr(i).name)
}
return names
}
override fun getColumnName(column: Int): String {
return when (column) {
COLUMN_NAME -> I18n.getString("termora.transport.table.filename")
COLUMN_FILE_SIZE -> I18n.getString("termora.transport.table.size")
COLUMN_TYPE -> I18n.getString("termora.transport.table.type")
COLUMN_LAST_MODIFIED_TIME -> I18n.getString("termora.transport.table.modified-time")
COLUMN_ATTRS -> I18n.getString("termora.transport.table.permissions")
COLUMN_OWNER -> I18n.getString("termora.transport.table.owner")
else -> StringUtils.EMPTY
}
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
suspend fun reload(dir: Path, useFileHiding: Boolean) {
if (log.isDebugEnabled) {
log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding)
}
val attrs = mutableListOf<Attr>()
if (dir.parent != null) {
attrs.add(ParentAttr(dir.parent))
}
withContext(Dispatchers.IO) {
Files.list(dir).use { paths ->
for (path in paths) {
val attr = if (path is SftpPath) SftpAttr(path) else Attr(path)
if (useFileHiding && attr.isHidden) continue
attrs.add(attr)
}
}
}
attrs.sortWith(compareBy<Attr> { !it.isDirectory }.thenComparing { a, b ->
NativeStringComparator.getInstance().compare(
a.name,
b.name
)
})
withContext(Dispatchers.Swing) {
while (rowCount > 0) removeRow(0)
attrs.forEach { addRow(arrayOf(it)) }
}
}
open class Attr(val path: Path) {
/**
* 名称
*/
open val name by lazy { path.name }
/**
* 文件类型
*/
open val type by lazy { NativeFileIcons.getIcon(name, isFile).second }
/**
* 大小
*/
open val size by lazy { path.fileSize() }
/**
* 修改时间
*/
open val modified by lazy { path.getLastModifiedTime().toMillis() }
/**
* 获取所有者
*/
open val owner by lazy { StringUtils.EMPTY }
/**
* 获取操作系统图标
*/
open val icon by lazy { NativeFileIcons.getIcon(name, isFile).first }
/**
* 是否是文件夹
*/
open val isDirectory by lazy { path.isDirectory() }
/**
* 是否是文件
*/
open val isFile by lazy { !isDirectory }
/**
* 是否是文件夹
*/
open val isHidden by lazy { path.isHidden() }
/**
* 获取权限
*/
open val permissions: String by lazy {
posixFilePermissions.let {
if (it.isNotEmpty()) PosixFilePermissions.toString(
it
) else StringUtils.EMPTY
}
}
open val posixFilePermissions by lazy { if (path.fileSystem.isUnix()) path.getPosixFilePermissions() else emptySet() }
open fun toFile(): File {
if (path.fileSystem.isSFTP()) {
return File(path.absolutePathString())
}
return path.toFile()
}
}
class ParentAttr(path: Path) : Attr(path) {
override val name by lazy { ".." }
override val isDirectory = true
override val isFile = false
override val isHidden = false
override val permissions = StringUtils.EMPTY
override val modified = 0L
override val type = StringUtils.EMPTY
override val icon by lazy { NativeFileIcons.getFolderIcon() }
}
class SftpAttr(sftpPath: SftpPath) : Attr(sftpPath) {
private val attributes = sftpPath.attributes
override val isDirectory = attributes.isDirectory
override val isHidden = name.startsWith(".")
override val size = attributes.size
override val owner: String = StringUtils.defaultString(attributes.owner)
override val modified = attributes.modifyTime.toMillis()
override val permissions: String = PosixFilePermissions.toString(fromSftpPermissions(attributes.permissions))
override val posixFilePermissions = fromSftpPermissions(attributes.permissions)
override fun toFile(): File {
return File(path.absolutePathString())
}
}
}

View File

@@ -0,0 +1,81 @@
package app.termora.sftp
import app.termora.Application
import app.termora.I18n
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeLeafIcon
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.FileUtils
import org.apache.commons.io.FilenameUtils
import org.apache.commons.lang3.SystemUtils
import org.eclipse.jgit.util.LRUMap
import java.util.*
import javax.swing.Icon
import javax.swing.filechooser.FileSystemView.getFileSystemView
object NativeFileIcons {
/**
* key: filename , value: <icon,description>
*/
private val cache = LRUMap<String, Pair<Icon, String>>(16, 512)
private val folderIcon = FlatTreeClosedIcon()
private val fileIcon = FlatTreeLeafIcon()
init {
if (SystemUtils.IS_OS_UNIX) {
cache[SystemUtils.USER_HOME] = Pair(FlatTreeClosedIcon(), I18n.getString("termora.folder"))
}
}
fun getFolderIcon(): Icon {
return getIcon(UUID.randomUUID().toString(), false).first
}
fun getFileIcon(filename: String): Icon {
return getIcon(filename, true).first
}
fun getIcon(filename: String, isFile: Boolean = true): Pair<Icon, String> {
if (isFile) {
val extension = FilenameUtils.getExtension(filename)
if (cache.containsKey(extension)) {
return cache.getValue(extension)
}
} else {
if (cache.containsKey(SystemUtils.USER_HOME)) {
return cache.getValue(SystemUtils.USER_HOME)
}
}
val isDirectory = !isFile
if (SystemInfo.isWindows) {
val file = if (isDirectory) FileUtils.getFile(SystemUtils.USER_HOME) else
FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}.${filename}")
if (isFile && !file.exists()) {
file.createNewFile()
}
val icon = getFileSystemView().getSystemIcon(file, 16, 16)
val description = getFileSystemView().getSystemTypeDescription(file)
val pair = icon to description
if (isDirectory) {
cache[SystemUtils.USER_HOME] = pair
} else {
cache[FilenameUtils.getExtension(file.name)] = pair
}
if (isFile) FileUtils.deleteQuietly(file)
return pair
}
return Pair(
if (isDirectory) folderIcon else fileIcon,
if (isDirectory) I18n.getString("termora.folder") else FilenameUtils.getExtension(filename)
)
}
}

View File

@@ -1,4 +1,4 @@
package app.termora.transport package app.termora.sftp
import app.termora.DialogWrapper import app.termora.DialogWrapper
import app.termora.I18n import app.termora.I18n

View File

@@ -0,0 +1,62 @@
package app.termora.sftp
import app.termora.HostManager
import app.termora.HostTerminalTab
import app.termora.Icons
import app.termora.Protocol
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import org.apache.commons.lang3.StringUtils
class SFTPAction : AnAction("SFTP", Icons.folder) {
private val hostManager get() = HostManager.getInstance()
override fun actionPerformed(evt: AnActionEvent) {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
var sftpTab: SFTPTab? = null
for (tab in terminalTabbedManager.getTerminalTabs()) {
if (tab is SFTPTab) {
sftpTab = tab
break
}
}
// 创建一个新的
if (sftpTab == null) {
sftpTab = SFTPTab()
terminalTabbedManager.addTerminalTab(sftpTab, false)
}
var hostId = if (evt is SFTPActionEvent) evt.hostId else StringUtils.EMPTY
// 如果不是特定事件那么尝试获取选中的Tab如果是一个 Host 并且是 SSH 协议那么直接打开
if (hostId.isBlank()) {
val tab = terminalTabbedManager.getSelectedTerminalTab()
if (tab is HostTerminalTab) {
if (tab.host.protocol == Protocol.SSH || tab.host.protocol == Protocol.SFTPPty) {
hostId = tab.host.id
}
}
}
terminalTabbedManager.setSelectedTerminalTab(sftpTab)
if (hostId.isBlank()) return
val tabbed = sftpTab.getData(SFTPDataProviders.RightSFTPTabbed) ?: return
// 如果已经打开了 那么直接选中
for (i in 0 until tabbed.tabCount) {
val fileSystemViewPanel = tabbed.getFileSystemViewPanel(i) ?: continue
if (fileSystemViewPanel.host.id == hostId) {
tabbed.selectedIndex = i
return
}
}
val host = hostManager.getHost(hostId) ?: return
tabbed.addSFTPFileSystemViewPanelTab(host)
}
}

View File

@@ -0,0 +1,11 @@
package app.termora.sftp
import app.termora.actions.AnActionEvent
import org.apache.commons.lang3.StringUtils
import java.util.*
class SFTPActionEvent(
source: Any,
val hostId: String,
event: EventObject
) : AnActionEvent(source, StringUtils.EMPTY, event)

View File

@@ -0,0 +1,12 @@
package app.termora.sftp
import app.termora.terminal.DataKey
object SFTPDataProviders {
val TransportManager = DataKey(app.termora.sftp.TransportManager::class)
val FileSystemViewPanel = DataKey(app.termora.sftp.FileSystemViewPanel::class)
val CoroutineScope = DataKey(kotlinx.coroutines.CoroutineScope::class)
val FileSystemViewTable = DataKey(app.termora.sftp.FileSystemViewTable::class)
val LeftSFTPTabbed = DataKey(SFTPTabbed::class)
val RightSFTPTabbed = DataKey(SFTPTabbed::class)
}

View File

@@ -1,11 +1,9 @@
package app.termora.transport package app.termora.sftp
import app.termora.* import app.termora.*
import app.termora.actions.AnAction import app.termora.actions.DataProvider
import app.termora.actions.AnActionEvent import app.termora.terminal.DataKey
import app.termora.keyboardinteractive.TerminalUserInteraction
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -23,15 +21,18 @@ import org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.CardLayout import java.awt.CardLayout
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.* import javax.swing.*
class SftpFileSystemPanel( class SFTPFileSystemViewPanel(
var host: Host? = null var host: Host? = null,
) : JPanel(BorderLayout()), Disposable { private val transportManager: TransportManager,
) : JPanel(BorderLayout()), Disposable, DataProvider {
companion object { companion object {
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java) private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java)
private enum class State { private enum class State {
Initialized, Initialized,
@@ -50,11 +51,14 @@ class SftpFileSystemPanel(
private val selectHostPanel = SelectHostPanel() private val selectHostPanel = SelectHostPanel()
private val connectFailedPanel = ConnectFailedPanel() private val connectFailedPanel = ConnectFailedPanel()
private val isDisposed = AtomicBoolean(false) private val isDisposed = AtomicBoolean(false)
private val that = this
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val properties get() = Database.getDatabase().properties
private var client: SshClient? = null private var client: SshClient? = null
private var session: ClientSession? = null private var session: ClientSession? = null
private var fileSystem: SftpFileSystem? = null private var fileSystem: SftpFileSystem? = null
var fileSystemPanel: FileSystemPanel? = null private var fileSystemPanel: FileSystemViewPanel? = null
init { init {
@@ -71,12 +75,11 @@ class SftpFileSystemPanel(
} }
private fun initEvents() { private fun initEvents() {
Disposer.register(this, selectHostPanel)
} }
@OptIn(DelicateCoroutinesApi::class)
fun connect() { fun connect() {
GlobalScope.launch(Dispatchers.IO) { coroutineScope.launch {
if (state != State.Connecting) { if (state != State.Connecting) {
state = State.Connecting state = State.Connecting
@@ -100,42 +103,17 @@ class SftpFileSystemPanel(
connectingPanel.stop() connectingPanel.stop()
} }
} }
} }
} }
private suspend fun doConnect() { private suspend fun doConnect() {
val thisHost = this.host ?: return val thisHost = this.host ?: return
var host = thisHost.copy(authentication = thisHost.authentication.copy(), updateDate = System.currentTimeMillis())
closeIO() closeIO()
try { try {
val client = SshClients.openClient(host).apply { client = this } val (client, host) = SshClients.openClient(thisHost, SwingUtilities.getWindowAncestor(that))
withContext(Dispatchers.Swing) { this.client = client
val owner = SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel)
client.userInteraction = TerminalUserInteraction(owner)
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// 弹出授权框
if (host.authentication.type == AuthenticationType.No) {
val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication()
host = host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(
host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
}
}
val session = SshClients.openSession(host, client).apply { session = this } val session = SshClients.openSession(host, client).apply { session = this }
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session) fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
session.addCloseFutureListener { onClose() } session.addCloseFutureListener { onClose() }
@@ -152,18 +130,10 @@ class SftpFileSystemPanel(
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
state = State.Connected state = State.Connected
val fileSystemPanel = FileSystemViewPanel(thisHost, fileSystem, transportManager, coroutineScope)
val fileSystemPanel = FileSystemPanel(fileSystem, host)
cardPanel.add(fileSystemPanel, State.Connected.name) cardPanel.add(fileSystemPanel, State.Connected.name)
cardLayout.show(cardPanel, State.Connected.name) cardLayout.show(cardPanel, State.Connected.name)
that.fileSystemPanel = fileSystemPanel
firePropertyChange("TabName", StringUtils.EMPTY, host.name)
this@SftpFileSystemPanel.fileSystemPanel = fileSystemPanel
// 立即加载
fileSystemPanel.reload()
} }
} }
@@ -199,6 +169,7 @@ class SftpFileSystemPanel(
override fun dispose() { override fun dispose() {
if (isDisposed.compareAndSet(false, true)) { if (isDisposed.compareAndSet(false, true)) {
closeIO() closeIO()
coroutineScope.cancel()
} }
} }
@@ -269,7 +240,7 @@ class SftpFileSystemPanel(
AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) { AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) {
state = State.Initialized state = State.Initialized
this@SftpFileSystemPanel.firePropertyChange("TabName", StringUtils.SPACE, StringUtils.EMPTY) that.setTabTitle(I18n.getString("termora.transport.sftp.select-host"))
cardLayout.show(cardPanel, State.Initialized.name) cardLayout.show(cardPanel, State.Initialized.name)
} }
}).apply { }).apply {
@@ -281,44 +252,65 @@ class SftpFileSystemPanel(
} }
} }
private inner class SelectHostPanel : JPanel(BorderLayout()) { private inner class SelectHostPanel : JPanel(BorderLayout()), Disposable {
private val tree = NewHostTree()
init { init {
initView() initView()
initEvents()
} }
private fun initView() { private fun initView() {
val formMargin = "4dlu" tree.contextmenu = false
val layout = FormLayout( tree.dragEnabled = false
"default:grow, pref, default:grow", tree.doubleClickConnection = false
"40dlu, pref, $formMargin, pref, $formMargin, pref"
)
val scrollPane = JScrollPane(tree)
scrollPane.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
add(scrollPane, BorderLayout.CENTER)
val errorInfo = JLabel(I18n.getString("termora.transport.sftp.connect-a-host")) TreeUtils.loadExpansionState(tree, properties.getString("SFTPTabbed.Tree.state", StringUtils.EMPTY))
errorInfo.horizontalAlignment = SwingConstants.CENTER
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(FlatOptionPaneInformationIcon()).xy(2, 2)
builder.add(errorInfo).xyw(1, 4, 3, "fill, center")
builder.add(JXHyperlink(object : AnAction(I18n.getString("termora.transport.sftp.select-host")) {
override fun actionPerformed(evt: AnActionEvent) {
val dialog = NewHostTreeDialog(evt.window)
dialog.setFilter { it.host.protocol == Protocol.SSH }
dialog.setTreeName("SftpFileSystemPanel.SelectHostTree")
dialog.allowMulti = false
dialog.setLocationRelativeTo(this@SelectHostPanel)
dialog.isVisible = true
this@SftpFileSystemPanel.host = dialog.hosts.firstOrNull() ?: return
connect()
} }
}).apply {
horizontalAlignment = SwingConstants.CENTER private fun initEvents() {
verticalAlignment = SwingConstants.CENTER tree.addMouseListener(object : MouseAdapter() {
isFocusable = false override fun mouseClicked(e: MouseEvent) {
}).xy(2, 6) if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
add(builder.build(), BorderLayout.CENTER) val node = tree.getLastSelectedPathNode() ?: return
if (node.isFolder) return
val host = node.data as Host
that.setTabTitle(host.name)
that.host = host
that.connect()
}
}
})
}
override fun dispose() {
properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree))
} }
} }
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return when (dataKey) {
SFTPDataProviders.FileSystemViewPanel -> fileSystemPanel as T?
SFTPDataProviders.CoroutineScope -> coroutineScope as T?
else -> null
}
}
private fun setTabTitle(title: String) {
val tabbed = SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, that)
if (tabbed is JTabbedPane) {
for (i in 0 until tabbed.tabCount) {
if (tabbed.getComponentAt(i) == that) {
tabbed.setTitleAt(i, title)
break
}
}
}
}
} }

View File

@@ -0,0 +1,215 @@
package app.termora.sftp
import app.termora.*
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.terminal.DataKey
import okio.withLock
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import java.awt.BorderLayout
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.nio.file.FileSystem
import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.*
import kotlin.io.path.absolutePathString
fun FileSystem.isSFTP() = this is SftpFileSystem
fun FileSystem.isUnix() = isLocal() && SystemUtils.IS_OS_UNIX
fun FileSystem.isWindows() = isLocal() && SystemUtils.IS_OS_WINDOWS
fun FileSystem.isLocal() = StringUtils.startsWithAny(javaClass.name, "java", "sun")
class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
private val transportTable = TransportTable()
private val transportManager get() = transportTable.model
private val dataProviderSupport = DataProviderSupport()
private val leftComponent = SFTPTabbed(transportManager)
private val rightComponent = SFTPTabbed(transportManager)
init {
initViews()
initEvents()
FileSystems.getDefault()
}
private fun initViews() {
putClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE, true)
val splitPane = JSplitPane()
splitPane.resizeWeight = 0.5
splitPane.leftComponent = leftComponent
splitPane.rightComponent = rightComponent
splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
splitPane.addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
removeComponentListener(this)
splitPane.setDividerLocation(splitPane.resizeWeight)
}
})
leftComponent.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
rightComponent.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor)
val rootSplitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT)
val scrollPane = JScrollPane(transportTable)
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
rootSplitPane.resizeWeight = 0.7
rootSplitPane.topComponent = splitPane
rootSplitPane.bottomComponent = scrollPane
rootSplitPane.addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
removeComponentListener(this)
rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight)
}
})
add(rootSplitPane, BorderLayout.CENTER)
}
private fun initEvents() {
Disposer.register(this, leftComponent)
Disposer.register(this, rightComponent)
Disposer.register(this, transportTable)
dataProviderSupport.addData(SFTPDataProviders.TransportManager, transportManager)
dataProviderSupport.addData(SFTPDataProviders.LeftSFTPTabbed, leftComponent)
dataProviderSupport.addData(SFTPDataProviders.RightSFTPTabbed, rightComponent)
// default tab
leftComponent.addTab(
I18n.getString("termora.transport.local"),
FileSystemViewPanel(
Host(
id = "local",
name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local,
), FileSystems.getDefault(), transportManager
)
)
leftComponent.setTabClosable(0, false)
// default tab
rightComponent.addTab(
I18n.getString("termora.transport.sftp.select-host"),
SFTPFileSystemViewPanel(transportManager = transportManager)
)
rightComponent.addChangeListener {
if (rightComponent.tabCount == 0 && !rightComponent.isDisposed) {
rightComponent.addTab(
I18n.getString("termora.transport.sftp.select-host"),
SFTPFileSystemViewPanel(transportManager = transportManager)
)
}
}
leftComponent.setTabCloseCallback { _, index -> tabCloseCallback(leftComponent, index) }
rightComponent.setTabCloseCallback { _, index -> tabCloseCallback(rightComponent, index) }
}
private fun tabCloseCallback(tabbed: SFTPTabbed, index: Int) {
assertEventDispatchThread()
val c = tabbed.getFileSystemViewPanel(index)
if (c == null) {
tabbed.removeTabAt(index)
return
}
val fs = c.fileSystem
val root = transportManager.root
transportManager.lock.withLock {
val deletedIds = mutableListOf<Long>()
for (i in 0 until root.childCount) {
val child = root.getChildAt(i) as? TransportTreeTableNode ?: continue
if (child.transport.source.fileSystem == fs ||
child.transport.target.fileSystem == fs
) {
deletedIds.add(child.transport.id)
}
}
if (deletedIds.isNotEmpty()) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) != JOptionPane.OK_OPTION
) {
return
}
}
deletedIds.forEach { transportManager.removeTransport(it) }
}
// 删除并销毁
tabbed.removeTabAt(index)
}
/**
* 返回失败表示没有创建成功
*/
fun addTransport(
source: JComponent,
sourceWorkdir: Path?,
target: FileSystemViewPanel,
targetWorkdir: Path?,
transport: Transport
): Boolean {
val sourcePanel = SwingUtilities.getAncestorOfClass(FileSystemViewPanel::class.java, source)
as? FileSystemViewPanel ?: return false
val targetPanel = target as? FileSystemViewPanel ?: return false
if (sourcePanel.isDisposed || targetPanel.isDisposed) return false
val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir()).absolutePathString()
val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir()).absolutePathString()
val targetFileSystem = targetPanel.fileSystem
val sourcePath = transport.source.absolutePathString()
transport.target = targetFileSystem.getPath(
myTargetWorkdir,
StringUtils.removeStart(sourcePath, mySourceWorkdir)
)
return transportManager.addTransport(transport)
}
fun canTransfer(source: JComponent): Boolean {
return getTarget(source) != null
}
fun getTarget(source: JComponent): FileSystemViewPanel? {
val sourceTabbed = SwingUtilities.getAncestorOfClass(SFTPTabbed::class.java, source)
as? SFTPTabbed ?: return null
val isLeft = sourceTabbed == leftComponent
val targetTabbed = if (isLeft) rightComponent else leftComponent
return targetTabbed.getSelectedFileSystemViewPanel()
}
/**
* 获取本地文件系统面板
*/
fun getLocalTarget(): FileSystemViewPanel {
return leftComponent.getFileSystemViewPanel(0) as FileSystemViewPanel
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey)
}
}

View File

@@ -0,0 +1,82 @@
package app.termora.sftp
import app.termora.*
import app.termora.terminal.DataKey
import java.beans.PropertyChangeListener
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
class SFTPTab : RememberFocusTerminalTab() {
private val sftpPanel = SFTPPanel()
private val sftp get() = Database.getDatabase().sftp
init {
Disposer.register(this, sftpPanel)
}
override fun getTitle(): String {
return "SFTP"
}
override fun getIcon(): Icon {
return Icons.folder
}
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
}
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
}
override fun canClose(): Boolean {
return !sftp.pinTab
}
override fun willBeClose(): Boolean {
if (!canClose()) return false
val transportManager = sftpPanel.getData(SFTPDataProviders.TransportManager) ?: return true
if (transportManager.getTransportCount() > 0) {
return OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(getJComponent()),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}
val leftTabbed = sftpPanel.getData(SFTPDataProviders.LeftSFTPTabbed) ?: return true
val rightTabbed = sftpPanel.getData(SFTPDataProviders.RightSFTPTabbed) ?: return true
if (hasActiveTab(leftTabbed) || hasActiveTab(rightTabbed)) {
return OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(getJComponent()),
I18n.getString("termora.transport.sftp.close-tab-has-active-session"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}
return true
}
private fun hasActiveTab(tabbed: SFTPTabbed): Boolean {
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getFileSystemViewPanel(i) ?: continue
if (c.host.id != "local") {
return true
}
}
return false
}
override fun getJComponent(): JComponent {
return sftpPanel
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return sftpPanel.getData(dataKey)
}
}

View File

@@ -0,0 +1,116 @@
package app.termora.sftp
import app.termora.*
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import java.awt.Point
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JButton
import javax.swing.JToolBar
import javax.swing.SwingUtilities
import javax.swing.UIManager
import kotlin.math.max
@Suppress("DuplicatedCode")
class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPane(), Disposable {
private val addBtn = JButton(Icons.add)
private val tabbed = this
private val disposed = AtomicBoolean(false)
val isDisposed get() = disposed.get()
init {
initViews()
initEvents()
}
private fun initViews() {
super.setTabLayoutPolicy(SCROLL_TAB_LAYOUT)
super.setTabsClosable(true)
super.setTabType(TabType.underlined)
super.setStyleMap(
mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
"hoverColor" to UIManager.getColor("TabbedPane.background"),
"tabHeight" to 30
)
)
val toolbar = JToolBar()
toolbar.add(addBtn)
super.setTrailingComponent(toolbar)
}
private fun initEvents() {
addBtn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(tabbed))
dialog.location = Point(
max(0, addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2),
addBtn.locationOnScreen.y + max(tabHeight, addBtn.height)
)
dialog.setFilter { it.host.protocol == Protocol.SSH }
dialog.setTreeName("SFTPTabbed.Tree")
dialog.allowMulti = true
dialog.isVisible = true
val hosts = dialog.hosts
if (hosts.isEmpty()) return
for (host in hosts) {
addSFTPFileSystemViewPanelTab(host)
}
}
})
}
fun addSFTPFileSystemViewPanelTab(host: Host) {
val panel = SFTPFileSystemViewPanel(host, transportManager)
addTab(host.name, panel)
panel.connect()
selectedIndex = tabCount - 1
}
/**
* 获取当前的 FileSystemViewPanel
*/
fun getSelectedFileSystemViewPanel(): FileSystemViewPanel? {
return getFileSystemViewPanel(selectedIndex)
}
fun getFileSystemViewPanel(index: Int): FileSystemViewPanel? {
if (tabCount < 1 || index < 0) return null
val c = getComponentAt(index)
if (c is FileSystemViewPanel) {
return c
}
if (c is SFTPFileSystemViewPanel) {
return c.getData(SFTPDataProviders.FileSystemViewPanel)
}
return null
}
override fun removeTabAt(index: Int) {
val c = getComponentAt(index)
if (c is Disposable) {
Disposer.dispose(c)
}
super.removeTabAt(index)
}
override fun dispose() {
if (disposed.compareAndSet(false, true)) {
while (tabCount > 0) removeTabAt(0)
}
}
}

View File

@@ -0,0 +1,56 @@
package app.termora.sftp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
class SpeedReporter(private val coroutineScope: CoroutineScope) {
companion object {
val millis = TimeUnit.MILLISECONDS.toMillis(500)
}
private val events = ConcurrentLinkedQueue<Triple<Transport, Long, Long>>()
init {
collect()
}
fun report(transport: Transport, bytes: Long, time: Long) {
events.add(Triple(transport, bytes, time))
}
private fun collect() {
// 异步上报数据
coroutineScope.launch {
while (coroutineScope.isActive) {
val time = System.currentTimeMillis()
val map = linkedMapOf<Transport, Long>()
// 收集
while (events.isNotEmpty() && events.peek().second < time) {
val (a, b) = events.poll()
map[a] = map.computeIfAbsent(a) { 0 } + b
}
if (map.isNotEmpty()) {
for ((a, b) in map) {
if (b > 0) {
reportTransferredFilesize(a, b, time)
}
}
}
delay(millis.milliseconds)
}
}
}
private fun reportTransferredFilesize(transport: Transport, bytes: Long, time: Long) {
transport.reportTransferredFilesize(bytes, time)
}
}

View File

@@ -0,0 +1,267 @@
package app.termora.sftp
import app.termora.Database
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.apache.commons.io.IOUtils
import org.apache.commons.net.io.Util
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.BasicFileAttributeView
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.name
enum class TransportStatus {
Ready,
Processing,
Failed,
Done,
}
/**
* 传输单位:单个文件
*/
class Transport(
/**
* 唯一 ID
*/
val id: Long = idGenerator.incrementAndGet(),
/**
* 是否是文件夹
*/
val isDirectory: Boolean = false,
/**
* 父
*/
val parentId: Long = 0,
/**
* 源
*/
val source: Path,
/**
* 目标
*/
var target: Path,
) {
companion object {
val idGenerator = AtomicLong(0)
private val log = LoggerFactory.getLogger(Transport::class.java)
private val isPreserveModificationTime get() = Database.getDatabase().sftp.preserveModificationTime
}
private val scanned by lazy { AtomicBoolean(false) }
/**
* 计数器
*/
private val counter by lazy { SlidingWindowByteCounter() }
/**
* 父
*/
var parent: Transport? = null
set(value) {
if (field != null) throw IllegalStateException("parent already exists")
field = value
// 上报大小
reportFilesize(filesize.get())
}
/**
* 文件大小,对于文件夹来说,文件大小是不确定的,它取决于文件夹下的文件
*/
val filesize = AtomicLong(0)
/**
* 已经传输完成的文件大小
*/
val transferredFilesize = AtomicLong(0)
/**
* 如果是文件夹,是否已经扫描完毕。如果已经扫描完毕,那么该文件夹传输完成后可以立即删除
*/
val isScanned get() = scanned.get()
val isFile = !isDirectory
val isRoot = parentId == 0L
/**
* 获取最近一秒内的速度
*/
val speed get() = counter.getLastSecondBytes()
/**
* 状态
*/
@Volatile
var status: TransportStatus = TransportStatus.Ready
private set
fun scanned() {
scanned.compareAndSet(false, true)
}
fun changeStatus(status: TransportStatus): Boolean {
synchronized(this) {
if (status == TransportStatus.Processing) {
if (this.status != TransportStatus.Ready) {
return false
}
} else if (status == TransportStatus.Failed || status == TransportStatus.Done) {
if (this.status != TransportStatus.Ready && this.status != TransportStatus.Processing) {
return false
}
} else if (status == TransportStatus.Ready) {
if (this.status != TransportStatus.Ready) {
return false
}
}
this.status = status
return true
}
}
private val c = AtomicLong(0)
/**
* 开始传输
*/
suspend fun transport(reporter: SpeedReporter) {
if (isDirectory) {
withContext(Dispatchers.IO) {
try {
if (!target.exists()) {
target.createDirectories()
}
} catch (e: FileAlreadyExistsException) {
if (log.isWarnEnabled) {
log.warn("Directory ${target.name} already exists")
}
} catch (e: Exception) {
throw e
}
}
return
}
withContext(Dispatchers.IO) {
val input = Files.newInputStream(source)
val output = Files.newOutputStream(target)
try {
val buff = ByteArray(Util.DEFAULT_COPY_BUFFER_SIZE)
var len: Int
while (input.read(buff).also { len = it } != -1 && this.isActive) {
// 写入
output.write(buff, 0, len)
val size = len.toLong()
val now = System.currentTimeMillis()
// 上报传输的字节数量
reporter.report(this@Transport, size, now)
// 如果状态错误,那么可能已经取消了
if (status != TransportStatus.Processing) {
throw TransportStatusException("status is $status")
}
}
} finally {
IOUtils.closeQuietly(input, output)
}
// 尝试修改时间
preserveModificationTime()
}
}
private fun preserveModificationTime() {
// 设置修改时间
if (isPreserveModificationTime) {
Files.getFileAttributeView(target, BasicFileAttributeView::class.java)
.setTimes(source.getLastModifiedTime(), source.getLastModifiedTime(), null)
}
}
/**
* 一层层上报文件大小
*/
fun reportFilesize(bytes: Long) {
val p = parent ?: return
if (isRoot) return
// 父状态不正常
if (p.status == TransportStatus.Failed) return
// 父的文件大小就是自己的文件大小
p.filesize.addAndGet(bytes)
// 递归上报
p.reportFilesize(bytes)
}
/**
* 一层层上报传输大小
*/
fun reportTransferredFilesize(bytes: Long, time: Long) {
var p = this as Transport?
while (p != null) {
// 记录上报的数量,用于统计速度
if (bytes > 0) p.counter.addBytes(bytes, time)
// 状态不正常
if (p.status == TransportStatus.Failed) return
// 父的传输文件大小就是自己的传输文件大小
p.transferredFilesize.addAndGet(bytes)
p = p.parent
c.incrementAndGet()
}
}
}
private class SlidingWindowByteCounter {
private val events = ConcurrentLinkedQueue<Pair<Long, Long>>()
private val oneSecondInMillis = TimeUnit.SECONDS.toMillis(1)
fun addBytes(bytes: Long, time: Long) {
// 添加当前事件
events.add(time to bytes)
// 移除过期事件(超过 1 秒的记录)
while (events.isNotEmpty() && events.peek().first < time - oneSecondInMillis) {
events.poll()
}
}
fun getLastSecondBytes(): Long {
val currentTime = System.currentTimeMillis()
// 累加最近 1 秒内的字节数
return events.filter { it.first >= currentTime - oneSecondInMillis }
.sumOf { it.second }
}
}

View File

@@ -0,0 +1,10 @@
package app.termora.sftp
import java.util.*
interface TransportListener : EventListener {
/**
* 状态变化
*/
fun onTransportChanged(transport: Transport) {}
}

View File

@@ -0,0 +1,11 @@
package app.termora.sftp
interface TransportManager {
fun addTransport(transport: Transport): Boolean
fun getTransport(id: Long): Transport?
fun getTransports(pId: Long): List<Transport>
fun getTransportCount(): Int
fun removeTransport(id: Long)
fun addTransportListener(listener: TransportListener)
fun removeTransportListener(listener: TransportListener)
}

View File

@@ -0,0 +1,3 @@
package app.termora.sftp
class TransportStatusException(message: String) : RuntimeException(message)

View File

@@ -0,0 +1,261 @@
package app.termora.sftp
import app.termora.Disposable
import app.termora.Disposer
import app.termora.I18n
import app.termora.OptionPane
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.jdesktop.swingx.JXTreeTable
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import java.awt.Component
import java.awt.Graphics
import java.awt.Insets
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.*
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.tree.DefaultTreeCellRenderer
import kotlin.math.floor
import kotlin.time.Duration.Companion.milliseconds
@Suppress("DuplicatedCode")
class TransportTable : JXTreeTable(), Disposable {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val model = TransportTableModel(coroutineScope)
private val table = this
private val transportManager = model as TransportManager
init {
initViews()
initEvents()
}
private fun initViews() {
super.getTableHeader().setReorderingAllowed(false)
super.setTreeTableModel(model)
super.setRowHeight(UIManager.getInt("Table.rowHeight"))
super.setAutoResizeMode(JTable.AUTO_RESIZE_OFF)
super.setFillsViewportHeight(true)
super.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"cellMargins" to Insets(0, 4, 0, 4),
"selectionArc" to 0,
)
)
super.setTreeCellRenderer(object : DefaultTreeCellRenderer() {
override fun getTreeCellRendererComponent(
tree: JTree?,
value: Any?,
sel: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean
): Component {
val node = value as DefaultMutableTreeTableNode
val transport = node.userObject as? Transport
val text = Objects.toString(node.getValueAt(TransportTableModel.COLUMN_NAME))
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
icon = if (transport?.isDirectory == true) NativeFileIcons.getFolderIcon()
else NativeFileIcons.getFileIcon(text)
return c
}
})
columnModel.getColumn(TransportTableModel.COLUMN_NAME).preferredWidth = 300
columnModel.getColumn(TransportTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200
columnModel.getColumn(TransportTableModel.COLUMN_TARGET_PATH).preferredWidth = 200
columnModel.getColumn(TransportTableModel.COLUMN_STATUS).preferredWidth = 100
columnModel.getColumn(TransportTableModel.COLUMN_PROGRESS).preferredWidth = 150
columnModel.getColumn(TransportTableModel.COLUMN_SIZE).preferredWidth = 140
columnModel.getColumn(TransportTableModel.COLUMN_SPEED).preferredWidth = 80
val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }
columnModel.getColumn(TransportTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer
columnModel.getColumn(TransportTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer
columnModel.getColumn(TransportTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer
columnModel.getColumn(TransportTableModel.COLUMN_ESTIMATED_TIME).cellRenderer = centerTableCellRenderer
columnModel.getColumn(TransportTableModel.COLUMN_PROGRESS).cellRenderer =
object : DefaultTableCellRenderer() {
private var progress = 0.0
private var progressInt = 0
private val padding = 4
init {
horizontalAlignment = SwingConstants.CENTER
}
override fun getTableCellRendererComponent(
table: JTable?,
value: Any?,
isSelected: Boolean,
hasFocus: Boolean,
row: Int,
column: Int
): Component {
this.progress = 0.0
this.progressInt = 0
if (value is Transport) {
if (value.status == TransportStatus.Processing) {
this.progress = value.transferredFilesize.get() * 1.0 / value.filesize.get()
this.progressInt = floor(progress * 100.0).toInt()
// 因为有一些 0B 大小的文件所以如果在进行中那么最大就是99
if (this.progress >= 1 && value.status == TransportStatus.Processing) {
this.progress = 0.99
this.progressInt = floor(progress * 100.0).toInt()
}
}
}
return super.getTableCellRendererComponent(
table,
"${progressInt}%",
isSelected,
hasFocus,
row,
column
)
}
override fun paintComponent(g: Graphics) {
// 原始背景
g.color = background
g.fillRect(0, 0, width, height)
// 进度条背景
g.color = UIManager.getColor("Table.selectionInactiveBackground")
g.fillRect(0, padding, width, height - padding * 2)
// 进度条颜色
g.color = UIManager.getColor("ProgressBar.foreground")
g.fillRect(0, padding, (width * progress).toInt(), height - padding * 2)
// 大于某个阀值的时候,就要改变颜色
if (progress >= 0.45) {
foreground = selectionForeground
}
// 绘制文字
ui.paint(g, this)
}
}
}
private fun initEvents() {
// contextmenu
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isRightMouseButton(e)) {
val r = table.rowAtPoint(e.point)
if (r >= 0 && r < table.rowCount) {
if (!table.isRowSelected(r)) {
table.setRowSelectionInterval(r, r)
}
} else {
table.clearSelection()
}
val rows = table.selectedRows
if (!table.hasFocus()) {
table.requestFocusInWindow()
}
showContextMenu(rows, e)
}
}
})
// 刷新状态
coroutineScope.launch(Dispatchers.Swing) { refreshView() }
// Delete key
table.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) {
val transports = selectedRows.map { getPathForRow(it).lastPathComponent }
.filterIsInstance<TransportTreeTableNode>().map { it.transport }
if (transports.isEmpty()) return
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(table),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
transports.forEach { transportManager.removeTransport(it.id) }
}
}
}
})
Disposer.register(this, model)
}
private fun showContextMenu(rows: IntArray, e: MouseEvent) {
val transports = rows.map { getPathForRow(it).lastPathComponent }
.filterIsInstance<TransportTreeTableNode>().map { it.transport }
val popupMenu = FlatPopupMenu()
val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete"))
val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all"))
delete.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
for (transport in transports) {
transportManager.removeTransport(transport.id)
}
}
}
deleteAll.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
transportManager.removeTransport(0)
}
}
delete.isEnabled = transports.isNotEmpty()
popupMenu.show(this, e.x, e.y)
}
private suspend fun refreshView() {
while (coroutineScope.isActive) {
for (row in 0 until rowCount) {
val treePath = getPathForRow(row) ?: continue
val node = treePath.lastPathComponent as? TransportTreeTableNode ?: continue
model.valueForPathChanged(treePath, node.transport)
}
delay(SpeedReporter.millis.milliseconds)
}
}
override fun dispose() {
coroutineScope.cancel()
}
}

View File

@@ -0,0 +1,443 @@
package app.termora.sftp
import app.termora.Disposable
import app.termora.I18n
import app.termora.assertEventDispatchThread
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import okio.withLock
import org.apache.commons.lang3.ArrayUtils
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import org.jdesktop.swingx.treetable.DefaultTreeTableModel
import org.jdesktop.swingx.treetable.MutableTreeTableNode
import org.slf4j.LoggerFactory
import java.util.concurrent.locks.ReentrantLock
import javax.swing.SwingUtilities
import kotlin.io.path.name
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
class TransportTableModel(private val coroutineScope: CoroutineScope) :
DefaultTreeTableModel(DefaultMutableTreeTableNode()), TransportManager, Disposable {
val lock = ReentrantLock()
private val transports = linkedMapOf<Long, TransportTreeTableNode>()
private val reporter = SpeedReporter(coroutineScope)
private var listeners = emptyArray<TransportListener>()
private val activeTransports = linkedMapOf<Long, Job>()
/**
* 最多的平行任务
*/
private val maxParallels = max(min(Runtime.getRuntime().availableProcessors(), 4), 1)
companion object {
private val log = LoggerFactory.getLogger(TransportTableModel::class.java)
const val COLUMN_COUNT = 8
const val COLUMN_NAME = 0
const val COLUMN_STATUS = 1
const val COLUMN_PROGRESS = 2
const val COLUMN_SIZE = 3
const val COLUMN_SOURCE_PATH = 4
const val COLUMN_TARGET_PATH = 5
const val COLUMN_SPEED = 6
const val COLUMN_ESTIMATED_TIME = 7
}
init {
setColumnIdentifiers(
listOf(
I18n.getString("termora.transport.jobs.table.name"),
I18n.getString("termora.transport.jobs.table.status"),
I18n.getString("termora.transport.jobs.table.progress"),
I18n.getString("termora.transport.jobs.table.size"),
I18n.getString("termora.transport.jobs.table.source-path"),
I18n.getString("termora.transport.jobs.table.target-path"),
I18n.getString("termora.transport.jobs.table.speed"),
I18n.getString("termora.transport.jobs.table.estimated-time")
)
)
coroutineScope.launch { run() }
}
override fun getRoot(): DefaultMutableTreeTableNode {
return super.getRoot() as DefaultMutableTreeTableNode
}
override fun isCellEditable(node: Any?, column: Int): Boolean {
return false
}
override fun addTransport(transport: Transport): Boolean {
return lock.withLock {
if (!transport.isRoot) {
// 判断父是否存在
if (!transports.containsKey(transport.parentId)) {
return@withLock false
}
// 检测状态
if (!validGrandfatherStatus(transport)) {
changeStatus(transport, TransportStatus.Failed)
}
}
val newNode = TransportTreeTableNode(transport)
val parentId = transport.parentId
val root = getRoot()
val p = if (parentId == 0L || !transports.contains(parentId)) {
root
} else {
transports.getValue(transport.parentId).apply { transport.parent = this.transport }
}
transports[transport.id] = newNode
if ((transports.containsKey(parentId) || p == root) && transports.containsKey(transport.id)) {
// 同步加入节点
SwingUtilities.invokeLater { insertNodeInto(newNode, p, p.childCount) }
}
return@withLock true
}
}
override fun getTransport(id: Long): Transport? {
return transports[id]?.transport
}
override fun getTransports(pId: Long): List<Transport> {
lock.withLock {
if (pId == 0L) {
return getRoot().children().toList().filterIsInstance<TransportTreeTableNode>()
.map { it.transport }
}
val p = transports[pId] ?: return emptyList()
return p.children().toList().filterIsInstance<TransportTreeTableNode>()
.map { it.transport }
}
}
override fun getTransportCount(): Int {
return transports.size
}
/**
* 获取祖先的状态,如果祖先状态不正常,那么子直接定义为失败
*
* @return true 正常
*/
private fun validGrandfatherStatus(transport: Transport): Boolean {
lock.withLock {
// 如果自己/父不正常,那么失败
if (transport.isRoot) return transport.status != TransportStatus.Failed
// 父不存在,那么直接定义失败
val p = transports[transport.parentId] ?: return false
// 父状态不正常,那么失败
if (p.transport.status == TransportStatus.Failed) return false
return validGrandfatherStatus(p.transport)
}
}
override fun removeTransport(id: Long) {
assertEventDispatchThread()
lock.withLock {
// ID 为空就是清空
if (id <= 0) {
// 定义为失败
transports.forEach { changeStatus(it.value.transport, TransportStatus.Failed) }
// 清除所有任务
transports.clear()
// 取消任务
activeTransports.forEach { it.value.cancel() }
activeTransports.clear()
val root = getRoot()
while (root.childCount > 0) {
val c = root.getChildAt(0)
if (c is MutableTreeTableNode) {
removeNodeFromParent(c)
}
}
return
}
val n = transports[id] ?: return
val deletedIds = mutableListOf<Long>()
n.visit { deletedIds.add(it.transport.id) }
deletedIds.add(id)
for (deletedId in deletedIds) {
val node = transports[deletedId] ?: continue
// 定义为失败
changeStatus(node.transport, TransportStatus.Failed)
if (deletedId == id) {
val p = if (node.transport.isRoot) root else transports[node.transport.parentId]
if (p != null) {
removeNodeFromParent(node)
}
}
// 尝试取消
activeTransports[deletedId]?.cancel()
transports.remove(deletedId)
}
// 如果不是成功,那么就是人工手动删除
if (n.transport.status != TransportStatus.Done) {
// 文件大小减去尚未传输的
n.transport.reportFilesize(-abs((n.transport.filesize.get() - n.transport.transferredFilesize.get())))
}
}
}
override fun addTransportListener(listener: TransportListener) {
listeners += listener
}
override fun removeTransportListener(listener: TransportListener) {
listeners = ArrayUtils.removeElement(listeners, listener)
}
private suspend fun run() {
while (coroutineScope.isActive) {
val nodes = getReadyTransport()
if (nodes.isEmpty()) {
delay((Random.nextInt(100, 250)).milliseconds)
continue
}
// pre process
val readyNodes = mutableListOf<TransportTreeTableNode>()
for (node in nodes) {
val transport = node.transport
// 因为有可能返回刚刚清理的 Transport如果不返回清理的 Transport 那么就只能返回 null返回null就要等待 N 毫秒
if (transport.status != TransportStatus.Ready) continue
// 如果祖先状态异常,那么直接定义为失败
if (!validGrandfatherStatus(transport)) {
changeStatus(transport, TransportStatus.Failed)
continue
}
// 进行中
if (!changeStatus(transport, TransportStatus.Processing)) continue
// 能走到这里表示准备好的任务
readyNodes.add(node)
}
// 如果没有准备好的节点,那么跳过
if (readyNodes.isEmpty()) continue
// 激活中的任务
val activeTransports = mutableMapOf<Long, Job>()
// 同步传输
for (node in readyNodes) {
val transport = node.transport
activeTransports[transport.id] = coroutineScope.launch { doTransport(node) }
}
// 设置为全局的
lock.withLock {
this.activeTransports.forEach { it.value.cancel() }
this.activeTransports.clear()
this.activeTransports.putAll(activeTransports)
}
try {
// 等待所有任务
activeTransports.values.joinAll()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
private suspend fun doTransport(node: TransportTreeTableNode) {
val transport = node.transport
try {
// 传输
transport.transport(reporter)
// 变更状态,文件夹不需要变更状态,因为当文件夹下所有文件都成功时,文件夹自然会成功
if (transport.isFile) {
changeStatus(transport, TransportStatus.Done)
}
} catch (e: Exception) {
if (e is TransportStatusException) {
if (log.isWarnEnabled) {
log.warn("{}: {}", transport.source.name, e.message)
}
} else if (log.isErrorEnabled) {
log.error(e.message, e)
}
// 定义为失败
changeStatus(transport, TransportStatus.Failed)
} finally {
// 从激活中移除
if (lock.tryLock()) {
try {
activeTransports.remove(transport.id)
} finally {
lock.unlock()
}
}
// 安全删除
if (transport.status == TransportStatus.Done) {
safeRemoveTransport(node)
}
}
}
private fun fireTransportEvent(transport: Transport) {
for (listener in listeners) {
listener.onTransportChanged(transport)
}
}
private suspend fun safeRemoveTransport(node: TransportTreeTableNode) {
withContext(Dispatchers.Swing) {
lock.withLock {
var n = node as TransportTreeTableNode?
while (n != null) {
// 如果还有子,跳过
if (n.childCount != 0) break
// 如果文件夹还没扫描完,那么不处理
if (n.transport.isDirectory && !n.transport.isScanned) break
// 提前保存一下父
val p = n.parent as? TransportTreeTableNode
// 设置成功
changeStatus(n.transport, TransportStatus.Done)
// 删除
removeTransport(n.transport.id)
// 继续向上查找
n = p
}
}
}
}
private suspend fun getReadyTransport(): List<TransportTreeTableNode> {
val nodes = mutableListOf<TransportTreeTableNode>()
val removeNodes = mutableListOf<TransportTreeTableNode>()
lock.withLock {
val stack = ArrayDeque<TransportTreeTableNode>()
val root = getRoot()
for (i in root.childCount - 1 downTo 0) {
val child = root.getChildAt(i)
if (child is TransportTreeTableNode) {
stack.addLast(child)
}
}
while (stack.isNotEmpty()) {
val node = stack.removeLast()
val transport = node.transport
// 如果父已经失败,那么自己也定义为失败,之所以定义失败要走下去是因为它的子也要定义为失败
if (transport.parent?.status == TransportStatus.Failed) {
changeStatus(transport, TransportStatus.Failed)
}
// 这是一个比较特殊的情况,因为传输任务和文件夹扫描并不是一个线程。
// 如果该文件夹最后一个任务传输任务完成后(已经尝试清理)这时候
// 因为还没有“定义为扫描完毕”那么清理任务就会认为还在扫描,但是已经
// 扫描完了,所以这里要执行一次清理。
if (transport.isDirectory && transport.status == TransportStatus.Processing) {
if (node.childCount == 0 && transport.isScanned) {
removeNodes.add(node)
break
}
}
if (transport.status == TransportStatus.Ready) {
if (transport.isDirectory) {
// 文件夹不允许和文件作为并行任务
if (nodes.isNotEmpty()) break
// 加入任务立即退出
nodes.add(node)
break
} else if (transport.isFile) {
// 如果要准备加入的并行任务不是一个父,那么不允许
if (nodes.isNotEmpty() && nodes.last().transport.parentId != transport.parentId) break
// 加入任务
nodes.add(node)
// 如果超出了最大
if (nodes.size >= maxParallels) break
}
}
// 文件不可能有子
if (transport.isFile) {
continue
}
for (i in node.childCount - 1 downTo 0) {
val child = node.getChildAt(i)
if (child is TransportTreeTableNode) {
stack.addLast(child)
}
}
}
}
// 如果有要清理的节点,那么直接返回清理的节点
if (removeNodes.isNotEmpty()) {
removeNodes.forEach { safeRemoveTransport(it) }
return removeNodes
}
return nodes
}
private fun changeStatus(transport: Transport, status: TransportStatus): Boolean {
return transport.changeStatus(status).apply { if (this) fireTransportEvent(transport) }
}
override fun dispose() {
lock.withLock {
// remove all
removeTransport(0L)
coroutineScope.cancel()
}
}
}

View File

@@ -0,0 +1,72 @@
package app.termora.sftp
import app.termora.I18n
import app.termora.formatBytes
import app.termora.formatSeconds
import org.apache.commons.io.file.PathUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import java.nio.file.Path
import kotlin.io.path.absolutePathString
class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) {
val transport get() = userObject as Transport
override fun getValueAt(column: Int): Any {
val isProcessing = transport.status == TransportStatus.Processing
val speed = if (isProcessing) transport.speed else 0
val estimatedTime = if (isProcessing && speed > 0)
(transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0
return when (column) {
TransportTableModel.COLUMN_NAME -> PathUtils.getFileNameString(transport.source)
TransportTableModel.COLUMN_STATUS -> formatStatus(transport.status)
TransportTableModel.COLUMN_SIZE -> size()
TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-"
TransportTableModel.COLUMN_ESTIMATED_TIME -> if (isProcessing) formatSeconds(estimatedTime) else "-"
TransportTableModel.COLUMN_SOURCE_PATH -> formatPath(transport.source)
TransportTableModel.COLUMN_TARGET_PATH -> formatPath(transport.target)
else -> super.getValueAt(column)
}
}
private fun formatPath(path: Path): String {
if (path.fileSystem.isSFTP()) {
val hostname = ((path.fileSystem as SftpFileSystem).session as JGitClientSession).hostConfigEntry.hostName
return hostname + ":" + path.absolutePathString()
}
return path.toUri().scheme + ":" + path.absolutePathString()
}
private fun formatStatus(status: TransportStatus): String {
return when (status) {
TransportStatus.Processing -> I18n.getString("termora.transport.sftp.status.transporting")
TransportStatus.Ready -> I18n.getString("termora.transport.sftp.status.waiting")
TransportStatus.Done -> I18n.getString("termora.transport.sftp.status.done")
TransportStatus.Failed -> I18n.getString("termora.transport.sftp.status.failed")
}
}
private fun size(): String {
val transferredFilesize = transport.transferredFilesize.get()
val filesize = transport.filesize.get()
if (transferredFilesize <= 0) return formatBytes(filesize)
return "${formatBytes(transferredFilesize)}/${formatBytes(filesize)}"
}
override fun getColumnCount(): Int {
return TransportTableModel.COLUMN_COUNT
}
fun visit(consumer: (TransportTreeTableNode) -> Unit) {
if (childCount == 0) return
for (child in children()) {
if (child is TransportTreeTableNode) {
child.visit(consumer)
consumer.invoke(child)
}
}
}
}

View File

@@ -4,7 +4,7 @@ import java.util.*
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
class TerminalReader { class TerminalReader {
private val buffer = LinkedList<Char>() private val buffer = ArrayDeque<Char>()
fun addLast(char: Char) { fun addLast(char: Char) {
@@ -12,7 +12,9 @@ class TerminalReader {
} }
fun addFirst(chars: List<Char>) { fun addFirst(chars: List<Char>) {
buffer.addAll(0, chars) for (i in chars.size - 1 downTo 0) {
addFirst(chars[i])
}
} }
@@ -25,7 +27,7 @@ class TerminalReader {
} }
fun addLast(text: String) { fun addLast(text: String) {
text.toCharArray().forEach { addLast(it) } text.forEach { addLast(it) }
} }
fun read(): Char { fun read(): Char {

File diff suppressed because it is too large Load Diff

View File

@@ -1,186 +0,0 @@
package app.termora.transport
import app.termora.*
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
import java.awt.Component
import java.awt.Point
import java.nio.file.FileSystems
import javax.swing.*
import kotlin.math.max
class FileSystemTabbed(
private val transportManager: TransportManager,
private val isLeft: Boolean = false
) : FlatTabbedPane(), Disposable {
private val addBtn = JButton(Icons.add)
init {
initView()
initEvents()
}
private fun initView() {
tabLayoutPolicy = SCROLL_TAB_LAYOUT
isTabsClosable = true
tabType = TabType.underlined
styleMap = mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
)
val toolbar = JToolBar()
toolbar.add(addBtn)
trailingComponent = toolbar
if (isLeft) {
addTab(
I18n.getString("termora.transport.local"), FileSystemPanel(
FileSystems.getDefault(),
host = Host(
id = "local",
name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local,
)
).apply { reload() })
setTabClosable(0, false)
} else {
addTab(
I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel()
)
}
}
private fun initEvents() {
addBtn.addActionListener {
val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(this))
dialog.location = Point(
max(0, addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2),
addBtn.locationOnScreen.y + max(tabHeight, addBtn.height)
)
dialog.setFilter { it.host.protocol == Protocol.SSH }
dialog.setTreeName("FileSystemTabbed.Tree")
dialog.isVisible = true
for (host in dialog.hosts) {
val panel = SftpFileSystemPanel(host)
addTab(host.name, panel)
panel.connect()
}
}
setTabCloseCallback { _, index ->
removeTabAt(index)
}
}
override fun removeTabAt(index: Int) {
val fileSystemPanel = getFileSystemPanel(index)
// 取消进行中的任务
if (fileSystemPanel != null) {
val transports = mutableListOf<Transport>()
for (transport in transportManager.getTransports()) {
if (transport.targetHolder == fileSystemPanel || transport.sourceHolder == fileSystemPanel) {
transports.add(transport)
}
}
if (transports.isNotEmpty()) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.WARNING_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) != JOptionPane.OK_OPTION
) {
return
}
transports.sortedBy { it.state == TransportState.Waiting }
.forEach { transportManager.removeTransport(it) }
}
}
val c = getComponentAt(index)
if (c is Disposable) {
Disposer.dispose(c)
}
super.removeTabAt(index)
if (tabCount == 0) {
if (!isLeft) {
addTab(
I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel()
)
}
}
}
override fun addTab(title: String, component: Component) {
super.addTab(title, component)
selectedIndex = tabCount - 1
if (component is SftpFileSystemPanel) {
component.addPropertyChangeListener("TabName") { e ->
SwingUtilities.invokeLater {
val name = StringUtils.defaultIfEmpty(
e.newValue.toString(),
I18n.getString("termora.transport.sftp.select-host")
)
for (i in 0 until tabCount) {
if (getComponentAt(i) == component) {
setTitleAt(i, name)
break
}
}
}
}
}
}
fun getSelectedFileSystemPanel(): FileSystemPanel? {
return getFileSystemPanel(selectedIndex)
}
fun getFileSystemPanel(index: Int): FileSystemPanel? {
if (index < 0) return null
val c = getComponentAt(index)
if (c is SftpFileSystemPanel) {
val p = c.fileSystemPanel
if (p != null) {
return p
}
}
if (c is FileSystemPanel) {
return c
}
return null
}
override fun dispose() {
while (tabCount > 0) {
val c = getComponentAt(0)
if (c is Disposable) {
Disposer.dispose(c)
}
super.removeTabAt(0)
}
}
}

View File

@@ -1,255 +0,0 @@
package app.termora.transport
import app.termora.I18n
import app.termora.formatBytes
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath
import org.slf4j.LoggerFactory
import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener
import java.nio.file.FileSystem
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.PosixFilePermissions
import java.util.*
import javax.swing.SwingUtilities
import javax.swing.table.DefaultTableModel
import kotlin.io.path.*
class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableModel() {
companion object {
const val COLUMN_NAME = 0
const val COLUMN_TYPE = 1
const val COLUMN_FILE_SIZE = 2
const val COLUMN_LAST_MODIFIED_TIME = 3
const val COLUMN_ATTRS = 4
const val COLUMN_OWNER = 5
}
private val root = fileSystem.rootDirectories.first()
var workdir: Path = if (fileSystem is SftpFileSystem) fileSystem.defaultDir
else fileSystem.getPath(SystemUtils.USER_HOME)
private set
@Volatile
private var files: MutableList<CacheablePath>? = null
private val propertyChangeListeners = mutableListOf<PropertyChangeListener>()
val isLocalFileSystem by lazy { FileSystems.getDefault() == fileSystem }
var isShowHiddenFiles = false
set(value) {
field = value
fireTableDataChanged()
}
override fun getRowCount(): Int {
return getShownFiles().size
}
override fun getValueAt(row: Int, column: Int): Any {
val path = getShownFiles()[row]
if (path.fileName == ".." && column != 0) {
return StringUtils.EMPTY
}
return try {
when (column) {
COLUMN_NAME -> path
COLUMN_FILE_SIZE -> if (path.isDirectory) StringUtils.EMPTY else formatBytes(path.fileSize)
COLUMN_TYPE -> if (path.isDirectory) I18n.getString("termora.transport.table.type.folder")
else if (path.isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
else path.extension
COLUMN_LAST_MODIFIED_TIME -> DateFormatUtils.format(Date(path.lastModifiedTime), "yyyy/MM/dd HH:mm")
// 如果是本地的并且还是Windows系统
COLUMN_ATTRS -> if (isLocalFileSystem && SystemUtils.IS_OS_WINDOWS) StringUtils.EMPTY else PosixFilePermissions.toString(
path.posixFilePermissions
)
COLUMN_OWNER -> path.owner
else -> StringUtils.EMPTY
}
} catch (e: Exception) {
StringUtils.EMPTY
}
}
override fun getColumnCount(): Int {
return 6
}
override fun getColumnName(column: Int): String {
return when (column) {
COLUMN_NAME -> I18n.getString("termora.transport.table.filename")
COLUMN_FILE_SIZE -> I18n.getString("termora.transport.table.size")
COLUMN_TYPE -> I18n.getString("termora.transport.table.type")
COLUMN_LAST_MODIFIED_TIME -> I18n.getString("termora.transport.table.modified-time")
COLUMN_ATTRS -> I18n.getString("termora.transport.table.permissions")
COLUMN_OWNER -> I18n.getString("termora.transport.table.owner")
else -> StringUtils.EMPTY
}
}
fun getPath(index: Int): Path {
return getCacheablePath(index).path
}
fun getCacheablePath(index: Int): CacheablePath {
return getShownFiles()[index]
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
override fun removeRow(row: Int) {
val e = getShownFiles()[row]
files?.removeIf { it == e }
fireTableRowsDeleted(row, row)
}
fun reload() {
val files = mutableListOf<CacheablePath>()
if (root != workdir) {
files.add(CacheablePath(workdir.resolve("..")))
}
Files.list(workdir).use {
for (path in it) {
if (path is SftpPath) {
files.add(SftpCacheablePath(path))
} else {
files.add(CacheablePath(path))
}
}
}
files.sortWith(compareBy({ !it.isDirectory }, { it.fileName }))
SwingUtilities.invokeLater {
this.files = files
fireTableDataChanged()
}
}
fun workdir(absolutePath: String) {
workdir(fileSystem.getPath(absolutePath))
}
fun workdir(path: Path) {
this.workdir = path.toAbsolutePath().normalize()
propertyChangeListeners.forEach {
it.propertyChange(
PropertyChangeEvent(
this,
"workdir",
this.workdir,
this.workdir
)
)
}
}
fun addPropertyChangeListener(propertyChangeListener: PropertyChangeListener) {
propertyChangeListeners.add(propertyChangeListener)
}
private fun getShownFiles(): List<CacheablePath> {
if (isShowHiddenFiles) {
return files ?: emptyList()
}
return files?.filter { !it.isHidden } ?: emptyList()
}
open class CacheablePath(val path: Path) {
val fileName by lazy { path.fileName.toString() }
val extension by lazy { path.extension }
open val isDirectory by lazy { path.isDirectory() }
open val isSymbolicLink by lazy { path.isSymbolicLink() }
open val isHidden by lazy { fileName != ".." && path.isHidden() }
open val fileSize by lazy { path.fileSize() }
open val lastModifiedTime by lazy { Files.getLastModifiedTime(path).toMillis() }
open val owner by lazy { path.getOwner().toString() }
open val posixFilePermissions by lazy {
kotlin.runCatching { path.getPosixFilePermissions() }.getOrElse { emptySet() }
}
}
class SftpCacheablePath(sftpPath: SftpPath) : CacheablePath(sftpPath) {
private val attributes = sftpPath.attributes
companion object {
private val log = LoggerFactory.getLogger(SftpCacheablePath::class.java)
private fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
val result = mutableSetOf<PosixFilePermission>()
// 将十进制权限转换为八进制字符串
val octalPermissions = sftpPermissions.toString(8)
// 仅取后三位权限部分
if (octalPermissions.length < 3) {
if (log.isErrorEnabled) {
log.error("Invalid permission value: {}", sftpPermissions)
return result
}
}
val permissionBits = octalPermissions.takeLast(3)
// 解析每一部分的权限
val owner = permissionBits[0].digitToInt()
val group = permissionBits[1].digitToInt()
val others = permissionBits[2].digitToInt()
// 处理所有者权限
if ((owner and 4) != 0) result.add(PosixFilePermission.OWNER_READ)
if ((owner and 2) != 0) result.add(PosixFilePermission.OWNER_WRITE)
if ((owner and 1) != 0) result.add(PosixFilePermission.OWNER_EXECUTE)
// 处理组权限
if ((group and 4) != 0) result.add(PosixFilePermission.GROUP_READ)
if ((group and 2) != 0) result.add(PosixFilePermission.GROUP_WRITE)
if ((group and 1) != 0) result.add(PosixFilePermission.GROUP_EXECUTE)
// 处理其他用户权限
if ((others and 4) != 0) result.add(PosixFilePermission.OTHERS_READ)
if ((others and 2) != 0) result.add(PosixFilePermission.OTHERS_WRITE)
if ((others and 1) != 0) result.add(PosixFilePermission.OTHERS_EXECUTE)
return result
}
}
override val isDirectory by lazy { attributes.isDirectory || (isSymbolicLink && Files.isDirectory(path)) }
override val isSymbolicLink: Boolean
get() = attributes.isSymbolicLink
override val isHidden: Boolean
get() = fileName != ".." && fileName.startsWith(".")
override val fileSize: Long
get() = attributes.size
override val lastModifiedTime: Long
by lazy { attributes.modifyTime.toMillis() }
override val owner: String
get() = attributes.owner
override val posixFilePermissions: Set<PosixFilePermission>
by lazy { fromSftpPermissions(attributes.permissions) }
}
}

View File

@@ -1,174 +0,0 @@
package app.termora.transport
import app.termora.Disposable
import app.termora.I18n
import app.termora.OptionPane
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Graphics
import java.awt.Insets
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
class FileTransportPanel(
private val transportManager: TransportManager
) : JPanel(BorderLayout()), Disposable {
private val tableModel = FileTransportTableModel(transportManager)
private val table = JTable(tableModel)
init {
initView()
initEvents()
}
private fun initView() {
table.fillsViewportHeight = true
table.autoResizeMode = JTable.AUTO_RESIZE_OFF
table.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
"cellMargins" to Insets(2, 2, 2, 2)
)
)
table.columnModel.getColumn(FileTransportTableModel.COLUMN_NAME).preferredWidth = 200
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200
table.columnModel.getColumn(FileTransportTableModel.COLUMN_TARGET_PATH).preferredWidth = 200
table.columnModel.getColumn(FileTransportTableModel.COLUMN_STATUS).preferredWidth = 100
table.columnModel.getColumn(FileTransportTableModel.COLUMN_PROGRESS).preferredWidth = 150
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SIZE).preferredWidth = 140
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SPEED).preferredWidth = 80
val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }
table.columnModel.getColumn(FileTransportTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer
table.columnModel.getColumn(FileTransportTableModel.COLUMN_ESTIMATED_TIME).cellRenderer =
centerTableCellRenderer
table.columnModel.getColumn(FileTransportTableModel.COLUMN_PROGRESS).cellRenderer =
object : DefaultTableCellRenderer() {
init {
horizontalAlignment = SwingConstants.CENTER
}
private var lastRow = -1
override fun getTableCellRendererComponent(
table: JTable?,
value: Any?,
isSelected: Boolean,
hasFocus: Boolean,
row: Int,
column: Int
): Component {
lastRow = row
return super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
}
override fun paintComponent(g: Graphics) {
if (lastRow != -1) {
val row = tableModel.getTransport(lastRow)
if (row.state == TransportState.Transporting) {
g.color = UIManager.getColor("textHighlight")
g.fillRect(0, 0, (width * row.progress).toInt(), height)
}
}
super.paintComponent(g)
}
}
add(JScrollPane(table).apply { border = BorderFactory.createEmptyBorder() }, BorderLayout.CENTER)
}
private fun initEvents() {
// contextmenu
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isRightMouseButton(e)) {
val r = table.rowAtPoint(e.point)
if (r >= 0 && r < table.rowCount) {
if (!table.isRowSelected(r)) {
table.setRowSelectionInterval(r, r)
}
} else {
table.clearSelection()
}
val rows = table.selectedRows
if (!table.hasFocus()) {
table.requestFocusInWindow()
}
showContextMenu(kotlin.runCatching {
rows.map { tableModel.getTransport(it) }
}.getOrElse { emptyList() }, e)
}
}
})
}
private fun showContextMenu(transports: List<Transport>, event: MouseEvent) {
val popupMenu = FlatPopupMenu()
val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete")).apply {
addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
for (transport in transports) {
transportManager.removeTransport(transport)
}
}
}
}
val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all"))
deleteAll.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
transportManager.removeAllTransports()
}
}
if (transports.isEmpty()) {
delete.isEnabled = false
deleteAll.isEnabled = transportManager.getTransports().isNotEmpty()
}
popupMenu.addSeparator()
popupMenu.add(I18n.getString("termora.transport.jobs.table.status")).addActionListener {
val last = transports.last()
OptionPane.showMessageDialog(
SwingUtilities.getWindowAncestor(this),
if (last.state == TransportState.Failed && last.stateText.isNotBlank()) last.stateText
else tableModel.formatStatus(last.state),
messageType = if (last.state == TransportState.Failed) JOptionPane.ERROR_MESSAGE else JOptionPane.INFORMATION_MESSAGE
)
}
popupMenu.show(table, event.x, event.y)
}
}

View File

@@ -1,126 +0,0 @@
package app.termora.transport
import app.termora.I18n
import app.termora.formatBytes
import app.termora.formatSeconds
import org.apache.commons.lang3.StringUtils
import javax.swing.SwingUtilities
import javax.swing.table.DefaultTableModel
class FileTransportTableModel(transportManager: TransportManager) : DefaultTableModel() {
private var isInitialized = false
private inline fun invokeLater(crossinline block: () -> Unit) {
if (SwingUtilities.isEventDispatchThread()) {
block.invoke()
} else {
SwingUtilities.invokeLater { block.invoke() }
}
}
init {
transportManager.addTransportListener(object : TransportListener {
override fun onTransportAdded(transport: Transport) {
invokeLater { addRow(arrayOf(transport)) }
}
override fun onTransportRemoved(transport: Transport) {
invokeLater {
val index = getDataVector().indexOfFirst { it.firstOrNull() == transport }
if (index >= 0) {
removeRow(index)
}
}
}
override fun onTransportChanged(transport: Transport) {
invokeLater {
for ((index, vector) in getDataVector().withIndex()) {
if (vector.firstOrNull() == transport) {
fireTableRowsUpdated(index, index)
}
}
}
}
})
isInitialized = true
}
companion object {
const val COLUMN_NAME = 0
const val COLUMN_STATUS = 1
const val COLUMN_PROGRESS = 2
const val COLUMN_SIZE = 3
const val COLUMN_SOURCE_PATH = 4
const val COLUMN_TARGET_PATH = 5
const val COLUMN_SPEED = 6
const val COLUMN_ESTIMATED_TIME = 7
}
override fun getColumnCount(): Int {
return 8
}
fun getTransport(row: Int): Transport {
return super.getValueAt(row, COLUMN_NAME) as Transport
}
override fun getValueAt(row: Int, column: Int): Any {
val transport = getTransport(row)
val isTransporting = transport.state == TransportState.Transporting
val speed = if (isTransporting) transport.speed else 0
val estimatedTime = if (isTransporting && speed > 0)
(transport.size - transport.transferredSize) / speed else 0
val progress = transport.progress * 100.0
return when (column) {
COLUMN_NAME -> " ${transport.name}"
COLUMN_STATUS -> formatStatus(transport.state)
// 如果进度已经完成但是状态还是传输中那么进度显示99%
COLUMN_PROGRESS -> String.format("%.0f%%", if (progress >= 100.0 && isTransporting) 99.0 else progress)
// 大小
COLUMN_SIZE -> if (transport.size < 0) "-"
else if (isTransporting) "${formatBytes(transport.transferredSize)}/${formatBytes(transport.size)}"
else formatBytes(transport.size)
COLUMN_SOURCE_PATH -> " ${transport.getSourcePath}"
COLUMN_TARGET_PATH -> " ${transport.getTargetPath}"
COLUMN_SPEED -> if (isTransporting) formatBytes(speed) else "-"
COLUMN_ESTIMATED_TIME -> if (isTransporting && speed > 0) formatSeconds(estimatedTime) else "-"
else -> StringUtils.EMPTY
}
}
fun formatStatus(state: TransportState): String {
return when (state) {
TransportState.Transporting -> I18n.getString("termora.transport.sftp.status.transporting")
TransportState.Waiting -> I18n.getString("termora.transport.sftp.status.waiting")
TransportState.Done -> I18n.getString("termora.transport.sftp.status.done")
TransportState.Failed -> I18n.getString("termora.transport.sftp.status.failed")
TransportState.Cancelled -> I18n.getString("termora.transport.sftp.status.cancelled")
}
}
override fun getColumnName(column: Int): String {
return when (column) {
COLUMN_NAME -> I18n.getString("termora.transport.jobs.table.name")
COLUMN_STATUS -> I18n.getString("termora.transport.jobs.table.status")
COLUMN_PROGRESS -> I18n.getString("termora.transport.jobs.table.progress")
COLUMN_SIZE -> I18n.getString("termora.transport.jobs.table.size")
COLUMN_SOURCE_PATH -> I18n.getString("termora.transport.jobs.table.source-path")
COLUMN_TARGET_PATH -> I18n.getString("termora.transport.jobs.table.target-path")
COLUMN_SPEED -> I18n.getString("termora.transport.jobs.table.speed")
COLUMN_ESTIMATED_TIME -> I18n.getString("termora.transport.jobs.table.estimated-time")
else -> StringUtils.EMPTY
}
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
}

View File

@@ -1,80 +0,0 @@
package app.termora.transport
import app.termora.*
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
class SFTPAction : AnAction("SFTP", Icons.folder) {
override fun actionPerformed(evt: AnActionEvent) {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val selectedTerminalTab = terminalTabbedManager.getSelectedTerminalTab()
val host = if (selectedTerminalTab is SSHTerminalTab || selectedTerminalTab is SFTPPtyTerminalTab)
selectedTerminalTab.host else null
val tab = openOrCreateSFTPTerminalTab(evt) ?: return
if (host != null) {
connectHost(host.copy(protocol = Protocol.SSH, updateDate = System.currentTimeMillis()), tab)
}
}
/**
* 打开一个已经存在或者创建一个 SFTP Tab
*
* @return null 表示当前条件下无法创建
*/
fun openOrCreateSFTPTerminalTab(evt: AnActionEvent, selected: Boolean = true): SFTPTerminalTab? {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return null
val tabs = terminalTabbedManager.getTerminalTabs()
for (tab in tabs) {
if (tab is SFTPTerminalTab) {
if (selected) {
terminalTabbedManager.setSelectedTerminalTab(tab)
}
return tab
}
}
// 创建一个新的
val tab = SFTPTerminalTab()
terminalTabbedManager.addTerminalTab(tab, selected)
return tab
}
/**
* 如果当前选中的是 SSH 服务器 Tab那么直接打开 SFTP 通道
*/
fun connectHost(host: Host, tab: SFTPTerminalTab) {
val tabbed = tab.getData(TransportDataProviders.TransportPanel)
?.getData(TransportDataProviders.RightFileSystemTabbed) ?: return
// 如果已经有对应的连接
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getComponentAt(i)
if (c is SftpFileSystemPanel) {
if (c.host == host) {
tabbed.selectedIndex = i
return
}
}
}
// 寻找空的 Tab如果有则占用
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getComponentAt(i)
if (c is SftpFileSystemPanel) {
if (c.host == null) {
c.host = host
c.connect()
tabbed.selectedIndex = i
return
}
}
}
// 开启一个新的
tabbed.addTab(host.name, SftpFileSystemPanel(host).apply { connect() })
}
}

View File

@@ -1,287 +0,0 @@
package app.termora.transport
import app.termora.Disposable
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.ObjectUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.net.io.CopyStreamEvent
import org.apache.commons.net.io.CopyStreamListener
import org.apache.commons.net.io.Util
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.slf4j.LoggerFactory
import java.nio.file.FileSystem
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import kotlin.io.path.exists
enum class TransportState {
Waiting,
Transporting,
Done,
Failed,
Cancelled,
}
abstract class Transport(
val name: String,
// 源路径
val source: Path,
// 目标路径
val target: Path,
val sourceHolder: Disposable,
val targetHolder: Disposable,
val listener: TransportListener = TransportListener.EMPTY
) : Disposable, Runnable {
private val listeners = ArrayList<TransportListener>()
init {
listeners.add(listener)
}
@Volatile
var state = TransportState.Waiting
protected set(value) {
field = value
listeners.forEach { it.onTransportChanged(this) }
}
var stateText: String = StringUtils.EMPTY
// 0 - 1
var progress = 0.0
protected set(value) {
field = value
listeners.forEach { it.onTransportChanged(this) }
}
/**
* 要传输的大小
*/
var size = -1L
protected set
/**
* 已经传输的大小
*/
var transferredSize = 0L
protected set
/**
* 传输速度
*/
open val speed get() = 0L
open val getSourcePath by lazy {
getFileSystemName(source.fileSystem) + ":" + source.toAbsolutePath().normalize().toString()
}
open val getTargetPath by lazy {
getFileSystemName(target.fileSystem) + ":" + target.toAbsolutePath().normalize().toString()
}
fun addTransportListener(listener: TransportListener) {
listeners.add(listener)
}
fun removeTransportListener(listener: TransportListener) {
listeners.remove(listener)
}
override fun run() {
if (state != TransportState.Waiting) {
throw IllegalStateException("$name has already been started")
}
state = TransportState.Transporting
}
open fun stop() {
if (state == TransportState.Waiting || state == TransportState.Transporting) {
state = TransportState.Cancelled
}
}
private fun getFileSystemName(fileSystem: FileSystem): String {
if (fileSystem is SftpFileSystem) {
val clientSession = fileSystem.session
if (clientSession is JGitClientSession) {
return ObjectUtils.defaultIfNull(
clientSession.hostConfigEntry.host,
clientSession.hostConfigEntry.hostName
)
}
}
return "file"
}
}
private class SlidingWindowByteCounter {
private val events = ConcurrentLinkedQueue<Pair<Long, Long>>()
private val oneSecondInMillis = TimeUnit.SECONDS.toMillis(1)
fun addBytes(bytes: Long, time: Long) {
// 添加当前事件
events.add(time to bytes)
// 移除过期事件(超过 1 秒的记录)
while (events.isNotEmpty() && events.peek().first < time - oneSecondInMillis) {
events.poll()
}
}
fun getLastSecondBytes(): Long {
val currentTime = System.currentTimeMillis()
// 累加最近 1 秒内的字节数
return events.filter { it.first >= currentTime - oneSecondInMillis }
.sumOf { it.second }
}
fun clear() {
events.clear()
}
}
/**
* 文件传输
*/
class FileTransport(
name: String, source: Path, target: Path,
sourceHolder: Disposable, targetHolder: Disposable, listener: TransportListener = TransportListener.EMPTY
) : Transport(
name, source, target, sourceHolder, targetHolder, listener
), CopyStreamListener {
companion object {
private val log = LoggerFactory.getLogger(FileTransport::class.java)
}
private var lastVisitTime = 0L
private val input by lazy { Files.newInputStream(source) }
private val output by lazy { Files.newOutputStream(target) }
private val counter = SlidingWindowByteCounter()
override val speed: Long
get() = counter.getLastSecondBytes()
override fun run() {
try {
super.run()
doTransport()
state = TransportState.Done
} catch (e: Exception) {
if (state == TransportState.Cancelled) {
if (log.isWarnEnabled) {
log.warn("Transport $name is canceled")
}
return
}
if (log.isErrorEnabled) {
log.error(e.message, e)
}
state = TransportState.Failed
stateText = ExceptionUtils.getRootCauseMessage(e)
} finally {
counter.clear()
}
}
override fun stop() {
// 如果在传输中,那么直接关闭流
if (state == TransportState.Transporting) {
runCatching { IOUtils.closeQuietly(input) }
runCatching { IOUtils.closeQuietly(output) }
}
super.stop()
counter.clear()
}
private fun doTransport() {
size = Files.size(source)
try {
Util.copyStream(
input,
output,
Util.DEFAULT_COPY_BUFFER_SIZE * 8,
size,
this
)
} finally {
IOUtils.closeQuietly(input, output)
}
}
override fun bytesTransferred(event: CopyStreamEvent?) {
throw UnsupportedOperationException()
}
override fun bytesTransferred(totalBytesTransferred: Long, bytesTransferred: Int, streamSize: Long) {
if (state == TransportState.Cancelled) {
throw IllegalStateException("$name has already been cancelled")
}
val now = System.currentTimeMillis()
val progress = totalBytesTransferred * 1.0 / streamSize
counter.addBytes(bytesTransferred.toLong(), now)
if (now - lastVisitTime < 750) {
if (progress < 1.0) {
return
}
}
this.transferredSize = totalBytesTransferred
this.progress = progress
lastVisitTime = now
}
}
/**
* 创建文件夹
*/
class DirectoryTransport(
name: String, source: Path, target: Path,
sourceHolder: Disposable,
targetHolder: Disposable,
) : Transport(name, source, target, sourceHolder, targetHolder) {
companion object {
private val log = LoggerFactory.getLogger(DirectoryTransport::class.java)
}
override fun run() {
try {
super.run()
if (!target.exists()) {
Files.createDirectory(target)
}
state = TransportState.Done
} catch (e: FileAlreadyExistsException) {
if (log.isWarnEnabled) {
log.warn("Directory $name already exists")
}
state = TransportState.Done
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
state = TransportState.Failed
}
}
}

View File

@@ -1,17 +0,0 @@
package app.termora.transport
import app.termora.terminal.DataKey
object TransportDataProviders {
val LeftFileSystemPanel = DataKey(FileSystemPanel::class)
val RightFileSystemPanel = DataKey(FileSystemPanel::class)
val LeftFileSystemTabbed = DataKey(FileSystemTabbed::class)
val RightFileSystemTabbed = DataKey(FileSystemTabbed::class)
val TransportManager = DataKey(app.termora.transport.TransportManager::class)
val TransportPanel = DataKey(app.termora.transport.TransportPanel::class)
}

View File

@@ -1,27 +0,0 @@
package app.termora.transport
import java.nio.file.Path
data class TransportJob(
/**
* 发起方
*/
val fileSystemPanel: FileSystemPanel,
/**
* 发起方工作目录
*/
val workdir: Path,
/**
* 要传输的文件是否是文件夹
*/
val isDirectory: Boolean,
/**
* 要传输的文件/文件夹
*/
val path: Path,
/**
* 监听
*/
val listener: TransportListener? = null
)

View File

@@ -1,35 +0,0 @@
package app.termora.transport
import java.util.*
interface TransportListener : EventListener {
companion object {
val EMPTY = object : TransportListener {
override fun onTransportAdded(transport: Transport) {
}
override fun onTransportRemoved(transport: Transport) {
}
override fun onTransportChanged(transport: Transport) {
}
}
}
/**
* Added
*/
fun onTransportAdded(transport: Transport){}
/**
* Removed
*/
fun onTransportRemoved(transport: Transport){}
/**
* 状态变化
*/
fun onTransportChanged(transport: Transport){}
}

View File

@@ -1,130 +0,0 @@
package app.termora.transport
import app.termora.Disposable
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.milliseconds
class TransportManager : Disposable {
private val transports = Collections.synchronizedList(mutableListOf<Transport>())
private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }
private val isProcessing = AtomicBoolean(false)
private val listeners = mutableListOf<TransportListener>()
private val listener = object : TransportListener {
override fun onTransportAdded(transport: Transport) {
listeners.forEach { it.onTransportAdded(transport) }
}
override fun onTransportRemoved(transport: Transport) {
listeners.forEach { it.onTransportRemoved(transport) }
}
override fun onTransportChanged(transport: Transport) {
listeners.forEach { it.onTransportChanged(transport) }
}
}
companion object {
private val log = LoggerFactory.getLogger(TransportManager::class.java)
}
fun getTransports(): List<Transport> = transports
fun addTransport(transport: Transport) {
synchronized(transports) {
transport.addTransportListener(listener)
if (transports.add(transport)) {
listeners.forEach { it.onTransportAdded(transport) }
if (isProcessing.compareAndSet(false, true)) {
coroutineScope.launch(Dispatchers.IO) { process() }
}
}
}
}
fun removeTransport(transport: Transport) {
synchronized(transports) {
transport.stop()
if (transports.remove(transport)) {
listeners.forEach { it.onTransportRemoved(transport) }
}
}
}
fun removeAllTransports() {
synchronized(transports) {
while (transports.isNotEmpty()) {
removeTransport(transports.last())
}
}
}
fun addTransportListener(listener: TransportListener) {
listeners.add(listener)
}
fun removeTransportListener(listener: TransportListener) {
listeners.remove(listener)
}
private suspend fun process() {
var needDelay = false
while (coroutineScope.isActive) {
try {
// 如果为空或者其中一个正在传输中那么挑过
if (needDelay || transports.isEmpty()) {
needDelay = false
delay(250.milliseconds)
continue
}
val transport = synchronized(transports) {
var transport: Transport? = null
for (e in transports) {
if (e.state != TransportState.Waiting) {
continue
}
// 遇到传输中,那么直接跳过
if (e.state == TransportState.Transporting) {
needDelay = true
break
}
transport = e
break
}
return@synchronized transport
}
if (transport == null) {
needDelay = true
continue
}
transport.run()
// 成功之后 删除
if (transport.state == TransportState.Done) {
// remove
removeTransport(transport)
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
override fun dispose() {
transports.clear()
coroutineScope.cancel()
}
}

View File

@@ -1,174 +0,0 @@
package app.termora.transport
import app.termora.Disposable
import app.termora.Disposer
import app.termora.DynamicColor
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.terminal.DataKey
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.io.File
import java.nio.file.Path
import javax.swing.BorderFactory
import javax.swing.JPanel
import javax.swing.JSplitPane
/**
* 传输面板
*/
class TransportPanel : JPanel(BorderLayout()), Disposable, DataProvider {
companion object {
private val log = LoggerFactory.getLogger(TransportPanel::class.java)
}
private val dataProviderSupport = DataProviderSupport()
private val transportManager = TransportManager()
private val leftFileSystemTabbed = FileSystemTabbed(transportManager, true)
private val rightFileSystemTabbed = FileSystemTabbed(transportManager, false)
private val fileTransportPanel = FileTransportPanel(transportManager)
init {
initView()
initEvents()
}
private fun initView() {
Disposer.register(this, transportManager)
Disposer.register(this, leftFileSystemTabbed)
Disposer.register(this, rightFileSystemTabbed)
Disposer.register(this, fileTransportPanel)
dataProviderSupport.addData(TransportDataProviders.LeftFileSystemTabbed, leftFileSystemTabbed)
dataProviderSupport.addData(TransportDataProviders.RightFileSystemTabbed, rightFileSystemTabbed)
dataProviderSupport.addData(TransportDataProviders.TransportManager, transportManager)
dataProviderSupport.addData(TransportDataProviders.TransportPanel, this)
leftFileSystemTabbed.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
rightFileSystemTabbed.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor)
val splitPane = JSplitPane()
splitPane.leftComponent = leftFileSystemTabbed
splitPane.rightComponent = rightFileSystemTabbed
splitPane.resizeWeight = 0.5
splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
splitPane.addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
removeComponentListener(this)
splitPane.setDividerLocation(splitPane.resizeWeight)
}
})
fileTransportPanel.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
val rootSplitPane = JSplitPane()
rootSplitPane.orientation = JSplitPane.VERTICAL_SPLIT
rootSplitPane.topComponent = splitPane
rootSplitPane.bottomComponent = fileTransportPanel
rootSplitPane.resizeWeight = 0.75
rootSplitPane.addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
removeComponentListener(this)
rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight)
}
})
add(rootSplitPane, BorderLayout.CENTER)
}
@Suppress("DuplicatedCode")
private fun initEvents() {
transportManager.addTransportListener(object : TransportListener {
override fun onTransportAdded(transport: Transport) {
}
override fun onTransportRemoved(transport: Transport) {
}
override fun onTransportChanged(transport: Transport) {
if (transport.state == TransportState.Done) {
val targetHolder = transport.targetHolder
if (targetHolder is FileSystemPanel) {
if (transport.target.parent == targetHolder.workdir) {
targetHolder.reload()
}
}
}
}
})
}
fun transport(
sourceWorkdir: Path,
targetWorkdir: Path,
isSourceDirectory: Boolean,
sourcePath: Path,
sourceHolder: Disposable,
targetHolder: Disposable
) {
val relativizePath = sourceWorkdir.relativize(sourcePath).toString()
if (StringUtils.isEmpty(relativizePath) || relativizePath == File.separator ||
relativizePath == sourceWorkdir.fileSystem.separator ||
relativizePath == targetWorkdir.fileSystem.separator
) {
return
}
val transport: Transport
if (isSourceDirectory) {
transport = DirectoryTransport(
name = sourcePath.fileName.toString(),
source = sourcePath,
target = targetWorkdir.resolve(relativizePath),
sourceHolder = sourceHolder,
targetHolder = targetHolder,
)
} else {
transport = FileTransport(
name = sourcePath.fileName.toString(),
source = sourcePath,
target = targetWorkdir.resolve(relativizePath),
sourceHolder = sourceHolder,
targetHolder = targetHolder,
)
}
transportManager.addTransport(transport)
}
override fun dispose() {
if (log.isInfoEnabled) {
log.info("Transport is disposed")
}
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == TransportDataProviders.LeftFileSystemPanel ||
dataKey == TransportDataProviders.RightFileSystemPanel
) {
dataProviderSupport.removeData(dataKey)
if (dataKey == TransportDataProviders.LeftFileSystemPanel) {
leftFileSystemTabbed.getSelectedFileSystemPanel()?.let {
dataProviderSupport.addData(dataKey, it)
}
} else {
rightFileSystemTabbed.getSelectedFileSystemPanel()?.let {
dataProviderSupport.addData(dataKey, it)
}
}
}
return dataProviderSupport.getData(dataKey)
}
}

View File

@@ -111,6 +111,8 @@ termora.settings.keymap.already-exists=The shortcut [{0}] is already in use by [
termora.settings.sftp.edit-command=Edit Command termora.settings.sftp.edit-command=Edit Command
termora.settings.sftp.fixed-tab=Fixed tab termora.settings.sftp.fixed-tab=Fixed tab
termora.settings.sftp.default-directory=Default Directory
termora.settings.sftp.preserve-time=Preserve original file modification time
termora.settings.restart.title=Restart termora.settings.restart.title=Restart
@@ -306,15 +308,14 @@ termora.transport.permissions.others=Others
termora.transport.sftp.retry=Retry termora.transport.sftp.retry=Retry
termora.transport.sftp.select-another-host=Select another host termora.transport.sftp.select-another-host=Select another host
termora.transport.sftp.select-host=Select host termora.transport.sftp.select-host=Select host
termora.transport.sftp.connect-a-host=Connect to a Host
termora.transport.sftp.connecting=Connecting... termora.transport.sftp.connecting=Connecting...
termora.transport.sftp.closed=The connection has been closed termora.transport.sftp.closed=The connection has been closed
termora.transport.sftp.close-tab=Transfer is still in activated status. Are you sure you want to remove all jobs and close this session? termora.transport.sftp.close-tab=Transfer is still in activated status. Are you sure you want to remove all jobs and close this session?
termora.transport.sftp.status.transporting=Transporting termora.transport.sftp.close-tab-has-active-session=Session is still active. Do you want to close all sessions?
termora.transport.sftp.status.transporting=In progress
termora.transport.sftp.status.waiting=Waiting termora.transport.sftp.status.waiting=Waiting
termora.transport.sftp.status.done=Done termora.transport.sftp.status.done=Done
termora.transport.sftp.status.failed=Failed termora.transport.sftp.status.failed=Failed
termora.transport.sftp.status.cancelled=Cancelled
# transport job # transport job
@@ -326,6 +327,10 @@ termora.transport.jobs.table.source-path=Source Path
termora.transport.jobs.table.target-path=Target Path termora.transport.jobs.table.target-path=Target Path
termora.transport.jobs.table.speed=Speed termora.transport.jobs.table.speed=Speed
termora.transport.jobs.table.estimated-time=Estimated time termora.transport.jobs.table.estimated-time=Estimated time
termora.transport.jobs.table.estimated-time-days-format={0}d {1}h {2}m {3}s
termora.transport.jobs.table.estimated-time-hours-format={0}h {1}m {2}s
termora.transport.jobs.table.estimated-time-minutes-format={0}m {1}s
termora.transport.jobs.table.estimated-time-seconds-format={0}s
termora.transport.jobs.contextmenu.delete=${termora.remove} termora.transport.jobs.contextmenu.delete=${termora.remove}
termora.transport.jobs.contextmenu.delete-all=Delete All termora.transport.jobs.contextmenu.delete-all=Delete All

View File

@@ -114,7 +114,8 @@ termora.settings.keymap.already-exists=快捷键 [{0}] 已经被 [{1}] 占用
termora.settings.sftp.edit-command=编辑命令 termora.settings.sftp.edit-command=编辑命令
termora.settings.sftp.fixed-tab=固定标签 termora.settings.sftp.fixed-tab=固定标签
termora.settings.sftp.default-directory=默认目录
termora.settings.sftp.preserve-time=保留原始文件修改时间
# Welcome # Welcome
termora.welcome.my-hosts=我的主机 termora.welcome.my-hosts=我的主机
@@ -283,16 +284,15 @@ termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令删除文件
termora.transport.sftp.retry=重试 termora.transport.sftp.retry=重试
termora.transport.sftp.select-another-host=选择其他主机 termora.transport.sftp.select-another-host=选择其他主机
termora.transport.sftp.select-host=选择主机 termora.transport.sftp.select-host=选择主机
termora.transport.sftp.connect-a-host=连接一个主机
termora.transport.sftp.connecting=连接中... termora.transport.sftp.connecting=连接中...
termora.transport.sftp.closed=连接已经关闭 termora.transport.sftp.closed=连接已经关闭
termora.transport.sftp.close-tab=传输还处于活动状态,是否删除所有传输任务并关闭此会话? termora.transport.sftp.close-tab=传输还处于活动状态,是否删除所有传输任务并关闭此会话?
termora.transport.sftp.close-tab-has-active-session=会话还处于活动状态,是否关闭所有会话?
termora.transport.sftp.status.transporting=传输中 termora.transport.sftp.status.transporting=传输中
termora.transport.sftp.status.waiting=等待中 termora.transport.sftp.status.waiting=等待中
termora.transport.sftp.status.done=已完成 termora.transport.sftp.status.done=已完成
termora.transport.sftp.status.failed=已失败 termora.transport.sftp.status.failed=已失败
termora.transport.sftp.status.cancelled=已取消
# Permission # Permission
@@ -314,6 +314,10 @@ termora.transport.jobs.table.source-path=源路径
termora.transport.jobs.table.target-path=目标路径 termora.transport.jobs.table.target-path=目标路径
termora.transport.jobs.table.speed=速度 termora.transport.jobs.table.speed=速度
termora.transport.jobs.table.estimated-time=剩余时间 termora.transport.jobs.table.estimated-time=剩余时间
termora.transport.jobs.table.estimated-time-days-format={0}天{1}小时{2}分{3}秒
termora.transport.jobs.table.estimated-time-hours-format={0}小时{1}分{2}秒
termora.transport.jobs.table.estimated-time-minutes-format={0}分{1}秒
termora.transport.jobs.table.estimated-time-seconds-format={0}秒
termora.transport.jobs.contextmenu.delete-all=删除所有 termora.transport.jobs.contextmenu.delete-all=删除所有

View File

@@ -64,6 +64,8 @@ termora.settings.keymap.already-exists=快捷鍵 [{0}] 已經被 [{1}] 占用
termora.settings.sftp.edit-command=編輯命令 termora.settings.sftp.edit-command=編輯命令
termora.settings.sftp.fixed-tab=固定標籤 termora.settings.sftp.fixed-tab=固定標籤
termora.settings.sftp.default-directory=預設目錄
termora.settings.sftp.preserve-time=保留原始文件修改時間
# Find everywhere # Find everywhere
@@ -278,15 +280,14 @@ termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令刪除資料
termora.transport.sftp.retry=重試 termora.transport.sftp.retry=重試
termora.transport.sftp.select-another-host=選擇其他主機 termora.transport.sftp.select-another-host=選擇其他主機
termora.transport.sftp.select-host=選擇主機 termora.transport.sftp.select-host=選擇主機
termora.transport.sftp.connect-a-host=連接一個主機
termora.transport.sftp.connecting=連接中... termora.transport.sftp.connecting=連接中...
termora.transport.sftp.closed=連線已經關閉 termora.transport.sftp.closed=連線已經關閉
termora.transport.sftp.close-tab=傳輸仍處於活動狀態,是否刪除所有傳輸任務並關閉此會話? termora.transport.sftp.close-tab=傳輸仍處於活動狀態,是否刪除所有傳輸任務並關閉此會話?
termora.transport.sftp.close-tab-has-active-session=會話仍處於活動狀態,是否關閉所有會話?
termora.transport.sftp.status.transporting=傳輸中 termora.transport.sftp.status.transporting=傳輸中
termora.transport.sftp.status.waiting=等待中 termora.transport.sftp.status.waiting=等待中
termora.transport.sftp.status.done=已完成 termora.transport.sftp.status.done=已完成
termora.transport.sftp.status.failed=已失敗 termora.transport.sftp.status.failed=已失敗
termora.transport.sftp.status.cancelled=已取消
# transport job # transport job
termora.transport.jobs.table.name=名稱 termora.transport.jobs.table.name=名稱
@@ -297,6 +298,10 @@ termora.transport.jobs.table.source-path=來源路徑
termora.transport.jobs.table.target-path=目標路徑 termora.transport.jobs.table.target-path=目標路徑
termora.transport.jobs.table.speed=速度 termora.transport.jobs.table.speed=速度
termora.transport.jobs.table.estimated-time=剩餘時間 termora.transport.jobs.table.estimated-time=剩餘時間
termora.transport.jobs.table.estimated-time-days-format={0}天{1}小時{2}分{3}秒
termora.transport.jobs.table.estimated-time-hours-format={0}小時{1}分{2}秒
termora.transport.jobs.table.estimated-time-minutes-format={0}分{1}秒
termora.transport.jobs.table.estimated-time-seconds-format={0}秒
termora.transport.jobs.contextmenu.delete-all=刪除所有 termora.transport.jobs.contextmenu.delete-all=刪除所有

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 11.5L9.5 8L6 4.5" stroke="#818594" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 11.5L9.5 8L6 4.5" stroke="#B4B8BF" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -1,7 +1 @@
<svg t="1736928517310" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="877" <svg t="1741768073368" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="937" width="16" height="16"><path d="M827.68472747 367.27138987C739.32581547 286.94500693 631.30064213 238.93333333 512 238.93333333c-119.30064213 0-227.32581547 48.0116736-315.68472747 128.33805654-61.94899627 56.31726933-108.01861973 127.95904-109.06719573 144.18684586 0.14636373-2.27628373 1.15452587 0.78861653 3.71370667 6.14946134 4.87150933 10.19521707 12.5796352 22.98129067 22.55530666 37.06497706 23.34938453 32.964608 55.8792704 68.73524907 93.17471574 101.16573867C299.45582933 736.5033984 406.1233152 785.06666667 512 785.06666667c105.8766848 0 212.54526293-48.56436053 305.30819413-129.2271616 37.29544533-32.43158187 69.8253312-68.20222293 93.17471574-101.16573867 9.97567147-14.0836864 17.68379733-26.87085227 22.55530666-37.06606933 2.3658496-4.95561387 3.40568747-7.9495168 3.66455467-6.6879488-1.98464853-17.21521493-47.7298688-87.932928-109.01804373-143.6483584zM512 178.2513664c303.40765013 0 485.4513664 273.06666667 485.4513664 333.7486336C997.4513664 572.68196693 785.06666667 845.7486336 512 845.7486336c-273.06666667 0-485.4513664-273.06666667-485.4513664-333.7486336 0-60.68196693 182.04371627-333.7486336 485.4513664-333.7486336z m0 151.70491733c-100.53986987 0-182.04480853 81.5038464-182.04480853 182.04371627S411.46013013 694.04480853 512 694.04480853 694.04480853 612.53986987 694.04480853 512 612.53986987 329.95519147 512 329.95519147z m0 60.68087467c67.026944 0 121.3628416 54.3358976 121.3628416 121.3628416S579.026944 633.3628416 512 633.3628416 390.6371584 579.026944 390.6371584 512 444.973056 390.6371584 512 390.6371584z" fill="#6C707E" p-id="938"></path></svg>
width="16" height="16">
<path d="M1001.472 482.64533333C893.61066667 255.43111111 730.56711111 141.08444445 512 141.08444445c-218.68088889 0-381.61066667 114.34666667-489.472 341.67466666-8.76088889 18.432-8.76088889 40.04977778 0 58.59555556C130.38933333 768.56888889 293.43288889 882.91555555 512 882.91555555c218.68088889 0 381.61066667-114.34666667 489.472-341.67466666 8.76088889-18.432 8.76088889-39.82222222 0-58.59555556zM512 800.99555555c-183.52355555 0-317.89511111-93.07022222-412.672-288.99555555C194.10488889 316.07466667 328.47644445 223.00444445 512 223.00444445c183.52355555 0 317.89511111 93.07022222 412.672 288.99555555C830.00888889 707.92533333 695.63733333 800.99555555 512 800.99555555z"
p-id="878" fill="#6C707E"></path>
<path d="M507.73333333 324.26666667c-103.68 0-187.73333333 84.05333333-187.73333333 187.73333333s84.05333333 187.73333333 187.73333333 187.73333333 187.73333333-84.05333333 187.73333334-187.73333333-84.05333333-187.73333333-187.73333334-187.73333333z m0 307.2c-66.02666667 0-119.46666667-53.44-119.46666666-119.46666667s53.44-119.46666667 119.46666666-119.46666667 119.46666667 53.44 119.46666667 119.46666667-53.44 119.46666667-119.46666667 119.46666667z"
p-id="879" fill="#6C707E"></path>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +1 @@
<svg t="1736928586708" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="907" width="16" height="16"><path d="M1001.58577778 482.87288889l-0.11377778-0.11377778-0.11377778-0.11377778c-41.41511111-87.26755555-91.02222222-157.80977778-148.70755555-211.62666666L794.96533333 328.81777778c49.72088889 45.73866667 92.72888889 106.60977778 129.82044445 183.06844444C830.00888889 707.92533333 695.63733333 800.99555555 512 800.99555555c-58.368 0-111.84355555-9.44355555-160.65422222-28.55822222l-62.23644445 62.23644445C355.66933333 866.75911111 429.85244445 882.91555555 512 882.91555555c218.68088889 0 381.61066667-114.34666667 489.472-341.67466666 8.76088889-18.432 8.76088889-39.82222222 0.11377778-58.368zM928.768 104.90311111l-48.24177778-48.24177778c-3.52711111-3.52711111-9.32977778-3.52711111-12.85688889 0L734.77688889 189.44C668.33066667 157.24088889 594.14755555 141.08444445 512 141.08444445c-218.68088889 0-381.61066667 114.34666667-489.472 341.67466666v0.11377778c-8.76088889 18.432-8.76088889 40.04977778 0 58.59555556 41.41511111 87.26755555 91.02222222 157.80977778 148.70755555 211.74044444L56.66133333 867.55555555c-3.52711111 3.52711111-3.52711111 9.32977778 0 12.8568889l48.24177778 48.24177777c3.52711111 3.52711111 9.32977778 3.52711111 12.85688889 0l811.008-811.008c3.52711111-3.41333333 3.52711111-9.216 0-12.74311111zM383.31733333 540.89955555c-2.16177778-9.32977778-3.29955555-19.00088889-3.29955555-28.89955555 0-70.42844445 57.00266667-127.43111111 127.43111111-127.43111111 9.89866667 0 19.68355555 1.13777778 28.89955556 3.29955556L383.31733333 540.89955555z m209.92-209.92C567.18222222 318.69155555 538.16888889 311.75111111 507.44888889 311.75111111c-110.592 0-200.24888889 89.65688889-200.24888889 200.24888889 0 30.72 6.94044445 59.73333333 19.22844445 85.78844445L229.03466667 695.18222222c-49.72088889-45.73866667-92.72888889-106.60977778-129.82044445-183.06844444C194.10488889 316.07466667 328.47644445 223.00444445 512 223.00444445c58.368 0 111.84355555 9.44355555 160.65422222 28.55822222l-79.41688889 79.41688888z" p-id="908" fill="#6C707E"></path><path d="M507.44888889 639.43111111c-7.28177778 0-14.44977778-0.56888889-21.39022222-1.82044444l-58.14044445 58.14044444c24.34844445 10.58133333 51.31377778 16.384 79.53066667 16.384 110.592 0 200.24888889-89.65688889 200.24888889-200.24888889 0-28.21688889-5.80266667-55.18222222-16.384-79.53066667l-58.14044445 58.14044445c1.13777778 6.94044445 1.82044445 14.10844445 1.82044445 21.39022222C634.88 582.42844445 577.87733333 639.43111111 507.44888889 639.43111111z" p-id="909" fill="#6C707E"></path></svg> <svg t="1741768152921" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="967" width="16" height="16"><path d="M105.44960853 295.50400853c63.52186027 67.96957013 151.54435413 121.14766507 237.95575467 151.0998016 217.83947947 75.49528747 427.50989653-0.5636096 595.92103253-151.0998016 31.735808-28.3639808 78.39197867 18.91150507 46.44645547 47.4546176-21.81911893 19.50569813-44.4366848 38.00978773-67.76203947 55.32002987 19.57997227 38.486016 39.15011413 76.95018667 58.73117867 115.41435733 19.5231744 38.3582208-37.18075733 72.29494613-56.737792 33.8690048-21.200896-41.7202176-42.4378368-83.37926827-63.64747093-125.06453333l6.82011306 13.39774293c-38.91090773 24.559616-79.4427392 45.70262187-121.21757013 62.652416l47.50923093 123.80187307c15.33323947 39.94965333-46.81127253 57.3014016-62.87305386 19.0185472l-0.47295147-1.17746347-46.03904-119.94944853c-43.4372608 12.84396373-87.96132693 21.0010112-133.1789824 23.66614187l-4.88133973 0.26651306 0.04150613 0.81046187c0.01092267 0.2752512 0.0196608 0.55268693 0.02512213 0.83012267l0.00873814 0.84322986v140.9286144c0 42.8474368-64.38693547 43.27560533-65.67471787 1.28559787l-0.0196608-1.28559787V546.65762133c0-1.06605227 0.0393216-2.10589013 0.11687253-3.1195136-44.0205312-3.0736384-88.49872213-11.6064256-133.08177066-26.30396586-1.03000747-0.33860267-2.0611072-0.6815744-3.09111467-1.02673067l-50.15688533 130.70936747c-15.12461653 39.41771947-77.5585792 22.84148053-63.78728107-16.6395904l0.44127573-1.20367787 50.348032-131.20088747a39.14902187 39.14902187 0 0 1 2.27191467-4.9020928c-38.50130773-16.56203947-76.3199488-36.52539733-111.85902933-59.76337066-25.75346347 34.10384213-51.50365013 68.21205333-77.2603904 102.2984192-10.911744 14.47253333-27.99479467 22.18612053-44.93585067 12.04770133-13.82700373-8.27938133-22.577152-30.93408427-12.10886827-45.46996907l0.32331094-0.43690666 80.89217706-107.10439254c-19.7525504-16.03556693-38.37678933-33.23876693-55.5220992-51.5833856-29.2388864-31.28251733 17.14858667-78.80485547 46.4551936-47.4546176z m436.57352534 248.659968v0.0098304-0.01092266z" fill="#6C707E" p-id="968"></path></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1 +1 @@
<svg t="1736928586708" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="907" width="16" height="16"><path d="M1001.58577778 482.87288889l-0.11377778-0.11377778-0.11377778-0.11377778c-41.41511111-87.26755555-91.02222222-157.80977778-148.70755555-211.62666666L794.96533333 328.81777778c49.72088889 45.73866667 92.72888889 106.60977778 129.82044445 183.06844444C830.00888889 707.92533333 695.63733333 800.99555555 512 800.99555555c-58.368 0-111.84355555-9.44355555-160.65422222-28.55822222l-62.23644445 62.23644445C355.66933333 866.75911111 429.85244445 882.91555555 512 882.91555555c218.68088889 0 381.61066667-114.34666667 489.472-341.67466666 8.76088889-18.432 8.76088889-39.82222222 0.11377778-58.368zM928.768 104.90311111l-48.24177778-48.24177778c-3.52711111-3.52711111-9.32977778-3.52711111-12.85688889 0L734.77688889 189.44C668.33066667 157.24088889 594.14755555 141.08444445 512 141.08444445c-218.68088889 0-381.61066667 114.34666667-489.472 341.67466666v0.11377778c-8.76088889 18.432-8.76088889 40.04977778 0 58.59555556 41.41511111 87.26755555 91.02222222 157.80977778 148.70755555 211.74044444L56.66133333 867.55555555c-3.52711111 3.52711111-3.52711111 9.32977778 0 12.8568889l48.24177778 48.24177777c3.52711111 3.52711111 9.32977778 3.52711111 12.85688889 0l811.008-811.008c3.52711111-3.41333333 3.52711111-9.216 0-12.74311111zM383.31733333 540.89955555c-2.16177778-9.32977778-3.29955555-19.00088889-3.29955555-28.89955555 0-70.42844445 57.00266667-127.43111111 127.43111111-127.43111111 9.89866667 0 19.68355555 1.13777778 28.89955556 3.29955556L383.31733333 540.89955555z m209.92-209.92C567.18222222 318.69155555 538.16888889 311.75111111 507.44888889 311.75111111c-110.592 0-200.24888889 89.65688889-200.24888889 200.24888889 0 30.72 6.94044445 59.73333333 19.22844445 85.78844445L229.03466667 695.18222222c-49.72088889-45.73866667-92.72888889-106.60977778-129.82044445-183.06844444C194.10488889 316.07466667 328.47644445 223.00444445 512 223.00444445c58.368 0 111.84355555 9.44355555 160.65422222 28.55822222l-79.41688889 79.41688888z" p-id="908" fill="#CED0D6"></path><path d="M507.44888889 639.43111111c-7.28177778 0-14.44977778-0.56888889-21.39022222-1.82044444l-58.14044445 58.14044444c24.34844445 10.58133333 51.31377778 16.384 79.53066667 16.384 110.592 0 200.24888889-89.65688889 200.24888889-200.24888889 0-28.21688889-5.80266667-55.18222222-16.384-79.53066667l-58.14044445 58.14044445c1.13777778 6.94044445 1.82044445 14.10844445 1.82044445 21.39022222C634.88 582.42844445 577.87733333 639.43111111 507.44888889 639.43111111z" p-id="909" fill="#CED0D6"></path></svg> <svg t="1741768152921" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="967" width="16" height="16"><path d="M105.44960853 295.50400853c63.52186027 67.96957013 151.54435413 121.14766507 237.95575467 151.0998016 217.83947947 75.49528747 427.50989653-0.5636096 595.92103253-151.0998016 31.735808-28.3639808 78.39197867 18.91150507 46.44645547 47.4546176-21.81911893 19.50569813-44.4366848 38.00978773-67.76203947 55.32002987 19.57997227 38.486016 39.15011413 76.95018667 58.73117867 115.41435733 19.5231744 38.3582208-37.18075733 72.29494613-56.737792 33.8690048-21.200896-41.7202176-42.4378368-83.37926827-63.64747093-125.06453333l6.82011306 13.39774293c-38.91090773 24.559616-79.4427392 45.70262187-121.21757013 62.652416l47.50923093 123.80187307c15.33323947 39.94965333-46.81127253 57.3014016-62.87305386 19.0185472l-0.47295147-1.17746347-46.03904-119.94944853c-43.4372608 12.84396373-87.96132693 21.0010112-133.1789824 23.66614187l-4.88133973 0.26651306 0.04150613 0.81046187c0.01092267 0.2752512 0.0196608 0.55268693 0.02512213 0.83012267l0.00873814 0.84322986v140.9286144c0 42.8474368-64.38693547 43.27560533-65.67471787 1.28559787l-0.0196608-1.28559787V546.65762133c0-1.06605227 0.0393216-2.10589013 0.11687253-3.1195136-44.0205312-3.0736384-88.49872213-11.6064256-133.08177066-26.30396586-1.03000747-0.33860267-2.0611072-0.6815744-3.09111467-1.02673067l-50.15688533 130.70936747c-15.12461653 39.41771947-77.5585792 22.84148053-63.78728107-16.6395904l0.44127573-1.20367787 50.348032-131.20088747a39.14902187 39.14902187 0 0 1 2.27191467-4.9020928c-38.50130773-16.56203947-76.3199488-36.52539733-111.85902933-59.76337066-25.75346347 34.10384213-51.50365013 68.21205333-77.2603904 102.2984192-10.911744 14.47253333-27.99479467 22.18612053-44.93585067 12.04770133-13.82700373-8.27938133-22.577152-30.93408427-12.10886827-45.46996907l0.32331094-0.43690666 80.89217706-107.10439254c-19.7525504-16.03556693-38.37678933-33.23876693-55.5220992-51.5833856-29.2388864-31.28251733 17.14858667-78.80485547 46.4551936-47.4546176z m436.57352534 248.659968v0.0098304-0.01092266z" fill="#CED0D6" p-id="968"></path></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,7 +1 @@
<svg t="1736928517310" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="877" <svg t="1741768073368" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="937" width="16" height="16"><path d="M827.68472747 367.27138987C739.32581547 286.94500693 631.30064213 238.93333333 512 238.93333333c-119.30064213 0-227.32581547 48.0116736-315.68472747 128.33805654-61.94899627 56.31726933-108.01861973 127.95904-109.06719573 144.18684586 0.14636373-2.27628373 1.15452587 0.78861653 3.71370667 6.14946134 4.87150933 10.19521707 12.5796352 22.98129067 22.55530666 37.06497706 23.34938453 32.964608 55.8792704 68.73524907 93.17471574 101.16573867C299.45582933 736.5033984 406.1233152 785.06666667 512 785.06666667c105.8766848 0 212.54526293-48.56436053 305.30819413-129.2271616 37.29544533-32.43158187 69.8253312-68.20222293 93.17471574-101.16573867 9.97567147-14.0836864 17.68379733-26.87085227 22.55530666-37.06606933 2.3658496-4.95561387 3.40568747-7.9495168 3.66455467-6.6879488-1.98464853-17.21521493-47.7298688-87.932928-109.01804373-143.6483584zM512 178.2513664c303.40765013 0 485.4513664 273.06666667 485.4513664 333.7486336C997.4513664 572.68196693 785.06666667 845.7486336 512 845.7486336c-273.06666667 0-485.4513664-273.06666667-485.4513664-333.7486336 0-60.68196693 182.04371627-333.7486336 485.4513664-333.7486336z m0 151.70491733c-100.53986987 0-182.04480853 81.5038464-182.04480853 182.04371627S411.46013013 694.04480853 512 694.04480853 694.04480853 612.53986987 694.04480853 512 612.53986987 329.95519147 512 329.95519147z m0 60.68087467c67.026944 0 121.3628416 54.3358976 121.3628416 121.3628416S579.026944 633.3628416 512 633.3628416 390.6371584 579.026944 390.6371584 512 444.973056 390.6371584 512 390.6371584z" fill="#CED0D6" p-id="938"></path></svg>
width="16" height="16">
<path d="M1001.472 482.64533333C893.61066667 255.43111111 730.56711111 141.08444445 512 141.08444445c-218.68088889 0-381.61066667 114.34666667-489.472 341.67466666-8.76088889 18.432-8.76088889 40.04977778 0 58.59555556C130.38933333 768.56888889 293.43288889 882.91555555 512 882.91555555c218.68088889 0 381.61066667-114.34666667 489.472-341.67466666 8.76088889-18.432 8.76088889-39.82222222 0-58.59555556zM512 800.99555555c-183.52355555 0-317.89511111-93.07022222-412.672-288.99555555C194.10488889 316.07466667 328.47644445 223.00444445 512 223.00444445c183.52355555 0 317.89511111 93.07022222 412.672 288.99555555C830.00888889 707.92533333 695.63733333 800.99555555 512 800.99555555z"
p-id="878" fill="#CED0D6"></path>
<path d="M507.73333333 324.26666667c-103.68 0-187.73333333 84.05333333-187.73333333 187.73333333s84.05333333 187.73333333 187.73333333 187.73333333 187.73333333-84.05333333 187.73333334-187.73333333-84.05333333-187.73333333-187.73333334-187.73333333z m0 307.2c-66.02666667 0-119.46666667-53.44-119.46666666-119.46666667s53.44-119.46666667 119.46666666-119.46666667 119.46666667 53.44 119.46666667 119.46666667-53.44 119.46666667-119.46666667 119.46666667z"
p-id="879" fill="#CED0D6"></path>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,5 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="1.5" stroke="#6C707E"/>
<path d="M8 11.5C5.62351 11.5 3.2737 9.94494 2.53088 8C3.2737 6.05506 5.62351 4.5 8 4.5C10.3765 4.5 12.7263 6.05506 13.4691 8C12.7263 9.94494 10.3765 11.5 8 11.5Z" stroke="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 462 B

View File

@@ -0,0 +1,5 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="1.5" stroke="#CED0D6"/>
<path d="M8 11.5C5.62351 11.5 3.2737 9.94494 2.53088 8C3.2737 6.05506 5.62351 4.5 8 4.5C10.3765 4.5 12.7263 6.05506 13.4691 8C12.7263 9.94494 10.3765 11.5 8 11.5Z" stroke="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 462 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.56602 1.76749C7.75797 1.43159 8.24232 1.43159 8.43426 1.7675L14.711 12.7519C14.9014 13.0853 14.6607 13.5 14.2768 13.5H1.7232C1.33928 13.5 1.0986 13.0853 1.28908 12.7519L7.56602 1.76749Z" stroke="#C27D04"/>
<path d="M7.9999 5.00159C8.33127 5.00159 8.5999 5.27022 8.5999 5.60159L8.5999 9.00002C8.5999 9.3314 8.33127 9.60002 7.9999 9.60002C7.66853 9.60002 7.3999 9.3314 7.3999 9.00002L7.3999 5.60159C7.3999 5.27022 7.66853 5.00159 7.9999 5.00159Z" fill="#C27D04"/>
<path d="M8.8002 11.2C8.8002 11.6419 8.44202 12 8.0002 12C7.55837 12 7.2002 11.6419 7.2002 11.2C7.2002 10.7582 7.55837 10.4 8.0002 10.4C8.44202 10.4 8.8002 10.7582 8.8002 11.2Z" fill="#C27D04"/>
</svg>

After

Width:  |  Height:  |  Size: 900 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.56602 1.76749C7.75797 1.43159 8.24232 1.43159 8.43426 1.7675L14.711 12.7519C14.9014 13.0853 14.6607 13.5 14.2768 13.5H1.7232C1.33928 13.5 1.0986 13.0853 1.28908 12.7519L7.56602 1.76749Z" stroke="#D6AE58"/>
<path d="M7.9999 5.00159C8.33127 5.00159 8.5999 5.27022 8.5999 5.60159L8.5999 9.00002C8.5999 9.3314 8.33127 9.60002 7.9999 9.60002C7.66853 9.60002 7.3999 9.3314 7.3999 9.00002L7.3999 5.60159C7.3999 5.27022 7.66853 5.00159 7.9999 5.00159Z" fill="#D6AE58"/>
<path d="M8.8002 11.2C8.8002 11.6419 8.44202 12 8.0002 12C7.55837 12 7.2002 11.6419 7.2002 11.2C7.2002 10.7582 7.55837 10.4 8.0002 10.4C8.44202 10.4 8.8002 10.7582 8.8002 11.2Z" fill="#D6AE58"/>
</svg>

After

Width:  |  Height:  |  Size: 900 B