refactor: SFTP (#351)
1
.github/workflows/windows-x86-64.yml
vendored
@@ -45,5 +45,4 @@ jobs:
|
||||
name: termora-windows-x86-64
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.msi
|
||||
build/distributions/*.exe
|
||||
|
||||
38
src/main/java/app/termora/Kernel32.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,16 @@ object Application {
|
||||
ProcessBuilder("xdg-open", uri.toString()).start()
|
||||
}
|
||||
}
|
||||
|
||||
fun browseInFolder(file: File) {
|
||||
if (SystemInfo.isWindows) {
|
||||
ProcessBuilder("explorer", "/select," + file.absolutePath).start()
|
||||
} else if (SystemInfo.isMacOS) {
|
||||
ProcessBuilder("open", "-R", file.absolutePath).start()
|
||||
} else if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
|
||||
Desktop.getDesktop().browseFileDirectory(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long): String {
|
||||
@@ -159,7 +169,7 @@ fun formatBytes(bytes: Long): String {
|
||||
val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt()
|
||||
val value = bytes / 1024.0.pow(exp.toDouble())
|
||||
|
||||
return String.format("%.2f %s", value, units[exp])
|
||||
return String.format("%.2f%s", value, units[exp])
|
||||
}
|
||||
|
||||
fun formatSeconds(seconds: Long): String {
|
||||
@@ -168,11 +178,33 @@ fun formatSeconds(seconds: Long): String {
|
||||
val minutes = (seconds % 3600) / 60
|
||||
val remainingSeconds = seconds % 60
|
||||
|
||||
|
||||
return when {
|
||||
days > 0 -> "${days}天${hours}小时${minutes}分${remainingSeconds}秒"
|
||||
hours > 0 -> "${hours}小时${minutes}分${remainingSeconds}秒"
|
||||
minutes > 0 -> "${minutes}分${remainingSeconds}秒"
|
||||
else -> "${remainingSeconds}秒"
|
||||
days > 0 -> I18n.getString(
|
||||
"termora.transport.jobs.table.estimated-time-days-format",
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
remainingSeconds
|
||||
)
|
||||
|
||||
hours > 0 -> I18n.getString(
|
||||
"termora.transport.jobs.table.estimated-time-hours-format",
|
||||
hours,
|
||||
minutes,
|
||||
remainingSeconds
|
||||
)
|
||||
|
||||
minutes > 0 -> I18n.getString(
|
||||
"termora.transport.jobs.table.estimated-time-minutes-format",
|
||||
minutes,
|
||||
remainingSeconds
|
||||
)
|
||||
|
||||
else -> I18n.getString(
|
||||
"termora.transport.jobs.table.estimated-time-seconds-format",
|
||||
remainingSeconds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.formdev.flatlaf.FlatSystemProperties
|
||||
import com.formdev.flatlaf.extras.FlatDesktop
|
||||
import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse
|
||||
import com.formdev.flatlaf.extras.FlatInspector
|
||||
import com.formdev.flatlaf.ui.FlatTableCellBorder
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jthemedetecor.OsThemeDetector
|
||||
import com.mixpanel.mixpanelapi.ClientDelivery
|
||||
@@ -29,7 +30,6 @@ import java.nio.channels.FileLock
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.*
|
||||
import java.util.function.Consumer
|
||||
import javax.swing.*
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
@@ -120,15 +120,12 @@ class ApplicationRunner {
|
||||
|
||||
private fun startMainFrame() {
|
||||
|
||||
|
||||
TermoraFrameManager.getInstance().createWindow().isVisible = true
|
||||
|
||||
if (SystemUtils.IS_OS_MAC_OSX) {
|
||||
SwingUtilities.invokeLater {
|
||||
FlatDesktop.setQuitHandler(object : Consumer<QuitResponse> {
|
||||
override fun accept(response: QuitResponse) {
|
||||
quitHandler(response)
|
||||
}
|
||||
})
|
||||
FlatDesktop.setQuitHandler { response -> quitHandler(response) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,6 +169,15 @@ class ApplicationRunner {
|
||||
JDialog.setDefaultLookAndFeelDecorated(true)
|
||||
}
|
||||
|
||||
UIManager.put(
|
||||
"FileChooser.${if (SystemInfo.isWindows) "win32" else "other"}.newFolder",
|
||||
I18n.getString("termora.welcome.contextmenu.new.folder.name")
|
||||
)
|
||||
UIManager.put(
|
||||
"FileChooser.${if (SystemInfo.isWindows) "win32" else "other"}.newFolder.subsequent",
|
||||
"${I18n.getString("termora.welcome.contextmenu.new.folder.name")}.{0}"
|
||||
)
|
||||
|
||||
val themeManager = ThemeManager.getInstance()
|
||||
val appearance = Database.getDatabase().appearance
|
||||
var theme = appearance.theme
|
||||
@@ -186,6 +192,7 @@ class ApplicationRunner {
|
||||
|
||||
themeManager.change(theme, true)
|
||||
|
||||
|
||||
if (Application.isUnknownVersion())
|
||||
FlatInspector.install("ctrl shift alt X")
|
||||
|
||||
@@ -218,9 +225,8 @@ class ApplicationRunner {
|
||||
}
|
||||
|
||||
UIManager.put("Table.rowHeight", 24)
|
||||
UIManager.put("Table.cellNoFocusBorder", BorderFactory.createEmptyBorder())
|
||||
UIManager.put("Table.focusCellHighlightBorder", BorderFactory.createEmptyBorder())
|
||||
UIManager.put("Table.focusSelectedCellHighlightBorder", BorderFactory.createEmptyBorder())
|
||||
UIManager.put("Table.focusCellHighlightBorder", FlatTableCellBorder.Default())
|
||||
UIManager.put("Table.focusSelectedCellHighlightBorder", FlatTableCellBorder.Default())
|
||||
UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc"))
|
||||
|
||||
UIManager.put("Tree.rowHeight", 24)
|
||||
|
||||
@@ -606,12 +606,22 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
*/
|
||||
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* defaultDirectory
|
||||
*/
|
||||
var defaultDirectory by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
|
||||
/**
|
||||
* 是否固定在标签栏
|
||||
*/
|
||||
var pinTab by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 是否保留原始文件时间
|
||||
*/
|
||||
var preserveModificationTime by BooleanPropertyDelegate(false)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
||||
serialCommOption.parityComboBox.selectedItem = serialComm.parity
|
||||
serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits
|
||||
serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl
|
||||
|
||||
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
|
||||
}
|
||||
|
||||
override fun getHost(): Host {
|
||||
|
||||
@@ -132,6 +132,11 @@ data class Options(
|
||||
* 串口配置
|
||||
*/
|
||||
val serialComm: SerialComm = SerialComm(),
|
||||
|
||||
/**
|
||||
* SFTP 默认目录
|
||||
*/
|
||||
val sftpDefaultDirectory: String = StringUtils.EMPTY,
|
||||
) {
|
||||
companion object {
|
||||
val Default = Options()
|
||||
|
||||
@@ -29,6 +29,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
protected val terminalOption = TerminalOption()
|
||||
protected val jumpHostsOption = JumpHostsOption()
|
||||
protected val serialCommOption = SerialCommOption()
|
||||
protected val sftpOption = SFTPOption()
|
||||
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
|
||||
|
||||
init {
|
||||
@@ -38,6 +39,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
addOption(jumpHostsOption)
|
||||
addOption(terminalOption)
|
||||
addOption(serialCommOption)
|
||||
addOption(sftpOption)
|
||||
|
||||
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
|
||||
}
|
||||
@@ -91,7 +93,8 @@ open class HostOptionsPane : OptionsPane() {
|
||||
startupCommand = terminalOption.startupCommandTextField.text,
|
||||
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
|
||||
jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
|
||||
serialComm = serialComm
|
||||
serialComm = serialComm,
|
||||
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text
|
||||
)
|
||||
|
||||
return Host(
|
||||
@@ -669,6 +672,54 @@ open class HostOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class SFTPOption : JPanel(BorderLayout()), Option {
|
||||
val defaultDirectoryField = OutlineTextField(255)
|
||||
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
add(getCenterComponent(), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
return Icons.folder
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
return "SFTP"
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, $formMargin",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val panel = FormBuilder.create().layout(layout)
|
||||
.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
|
||||
.add(defaultDirectoryField).xy(3, rows).apply { rows += step }
|
||||
.build()
|
||||
|
||||
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class TunnelingOption : JPanel(BorderLayout()), Option {
|
||||
val tunnelings = mutableListOf<Tunneling>()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ object Icons {
|
||||
val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") }
|
||||
val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") }
|
||||
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
|
||||
val inspectionsEye by lazy { DynamicIcon("icons/inspectionsEye.svg", "icons/inspectionsEye_dark.svg") }
|
||||
val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
|
||||
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
|
||||
val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") }
|
||||
@@ -32,6 +33,7 @@ object Icons {
|
||||
val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") }
|
||||
val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") }
|
||||
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
|
||||
val warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_dark.svg") }
|
||||
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
|
||||
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
|
||||
val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") }
|
||||
@@ -50,6 +52,7 @@ object Icons {
|
||||
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") }
|
||||
val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
|
||||
val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") }
|
||||
val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_dark.svg") }
|
||||
val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") }
|
||||
val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") }
|
||||
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
|
||||
|
||||
41
src/main/kotlin/app/termora/NativeStringComparator.kt
Normal 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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.OpenHostAction
|
||||
import app.termora.transport.SFTPAction
|
||||
import app.termora.sftp.SFTPActionEvent
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.*
|
||||
@@ -46,6 +45,7 @@ class NewHostTree : SimpleTree() {
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||
private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
|
||||
private val sftpAction get() = ActionManager.getInstance().getAction(app.termora.Actions.SFTP)
|
||||
private var isShowMoreInfo
|
||||
get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
|
||||
set(value) = properties.putString("HostTree.showMoreInfo", value.toString())
|
||||
@@ -396,10 +396,8 @@ class NewHostTree : SimpleTree() {
|
||||
val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH }
|
||||
if (nodes.isEmpty()) return
|
||||
|
||||
val sftpAction = ActionManager.getInstance().getAction(app.termora.Actions.SFTP) as SFTPAction? ?: return
|
||||
val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return
|
||||
for (node in nodes) {
|
||||
sftpAction.connectHost(node, tab)
|
||||
sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@ package app.termora
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.function.Function
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JScrollPane
|
||||
import javax.swing.UIManager
|
||||
import javax.swing.*
|
||||
|
||||
class NewHostTreeDialog(
|
||||
owner: Window,
|
||||
@@ -19,7 +18,7 @@ class NewHostTreeDialog(
|
||||
private val tree = NewHostTree()
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 250, UIManager.getInt("Dialog.height") - 150)
|
||||
isModal = true
|
||||
isResizable = false
|
||||
controlsVisible = false
|
||||
@@ -29,6 +28,15 @@ class NewHostTreeDialog(
|
||||
tree.doubleClickConnection = false
|
||||
tree.dragEnabled = false
|
||||
|
||||
tree.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val node = tree.getLastSelectedPathNode() ?: return
|
||||
if (node.isFolder) return
|
||||
doOKAction()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
init()
|
||||
|
||||
@@ -33,14 +33,21 @@ class PtyConnectorFactory : Disposable {
|
||||
if (SystemUtils.IS_OS_UNIX) {
|
||||
commands.add("-l")
|
||||
}
|
||||
return createPtyConnector(commands.toTypedArray(), rows, cols, env, charset)
|
||||
return createPtyConnector(
|
||||
commands = commands.toTypedArray(),
|
||||
rows = rows,
|
||||
cols = cols,
|
||||
env = env,
|
||||
charset = charset
|
||||
)
|
||||
}
|
||||
|
||||
fun createPtyConnector(
|
||||
commands: Array<String>,
|
||||
rows: Int = 24, cols: Int = 80,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
charset: Charset = StandardCharsets.UTF_8
|
||||
directory: String = SystemUtils.USER_HOME,
|
||||
charset: Charset = StandardCharsets.UTF_8,
|
||||
): PtyConnector {
|
||||
val envs = mutableMapOf<String, String>()
|
||||
envs.putAll(System.getenv())
|
||||
@@ -67,7 +74,7 @@ class PtyConnectorFactory : Disposable {
|
||||
.setInitialRows(rows)
|
||||
.setInitialColumns(cols)
|
||||
.setConsole(false)
|
||||
.setDirectory(SystemUtils.USER_HOME)
|
||||
.setDirectory(StringUtils.defaultIfBlank(directory, SystemUtils.USER_HOME))
|
||||
.setCygwin(false)
|
||||
.setUseWinConPty(SystemUtils.IS_OS_WINDOWS)
|
||||
.setRedirectErrorStream(false)
|
||||
|
||||
16
src/main/kotlin/app/termora/RememberFocusTerminalTab.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.apache.commons.io.Charsets
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.sshd.client.ClientBuilder
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
@@ -28,6 +29,11 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
|
||||
private var sshSession: ClientSession? = null
|
||||
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
|
||||
private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand
|
||||
private val defaultDirectory get() = Database.getDatabase().sftp.defaultDirectory
|
||||
|
||||
init {
|
||||
terminalPanel.dropFiles = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
val canSupports by lazy {
|
||||
@@ -115,16 +121,21 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
|
||||
if (envs.containsKey("CurrentDir")) {
|
||||
val currentDir = envs.getValue("CurrentDir")
|
||||
commands.add("${host.username}@${host.host}:${currentDir}")
|
||||
} else if (host.options.sftpDefaultDirectory.isNotBlank()) {
|
||||
commands.add("${host.username}@${host.host}:${host.options.sftpDefaultDirectory.trim()}")
|
||||
} else {
|
||||
commands.add("${host.username}@${host.host}")
|
||||
}
|
||||
|
||||
val directory = FileUtils.getFile(StringUtils.defaultIfBlank(defaultDirectory, SystemUtils.USER_HOME))
|
||||
|
||||
val winSize = terminalPanel.winSize()
|
||||
val ptyConnector = ptyConnectorFactory.createPtyConnector(
|
||||
commands.toTypedArray(),
|
||||
winSize.rows, winSize.cols,
|
||||
host.options.envs(),
|
||||
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
|
||||
commands = commands.toTypedArray(),
|
||||
rows = winSize.rows, cols = winSize.cols,
|
||||
env = host.options.envs(),
|
||||
charset = Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
|
||||
directory = if (directory.exists()) directory.absolutePath else SystemUtils.USER_HOME
|
||||
)
|
||||
|
||||
return ptyConnector
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import app.termora.keymgr.OhKeyPair
|
||||
import app.termora.macro.Macro
|
||||
import app.termora.macro.MacroManager
|
||||
import app.termora.native.FileChooser
|
||||
import app.termora.sftp.SFTPTab
|
||||
import app.termora.snippet.Snippet
|
||||
import app.termora.snippet.SnippetManager
|
||||
import app.termora.sync.SyncConfig
|
||||
@@ -25,7 +26,6 @@ import app.termora.terminal.CursorStyle
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import app.termora.transport.SFTPAction
|
||||
import cash.z.ecc.android.bip39.Mnemonics
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||
@@ -46,14 +46,15 @@ import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.jdesktop.swingx.JXEditorPane
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.ItemEvent
|
||||
import java.awt.event.ItemListener
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.nio.charset.StandardCharsets
|
||||
@@ -72,7 +73,6 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private val snippetManager get() = SnippetManager.getInstance()
|
||||
private val keymapManager get() = KeymapManager.getInstance()
|
||||
private val macroManager get() = MacroManager.getInstance()
|
||||
private val actionManager get() = ActionManager.getInstance()
|
||||
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
|
||||
private val keyManager get() = KeyManager.getInstance()
|
||||
|
||||
@@ -1334,9 +1334,11 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
private val editCommandField = OutlineTextField(255)
|
||||
private val sftpCommandField = OutlineTextField(255)
|
||||
private val defaultDirectoryField = OutlineTextField(255)
|
||||
private val browseDirectoryBtn = JButton(Icons.folder)
|
||||
private val pinTabComboBox = YesOrNoComboBox()
|
||||
private val preserveModificationTimeComboBox = YesOrNoComboBox()
|
||||
private val sftp get() = database.sftp
|
||||
private val sftpAction get() = actionManager.getAction(Actions.SFTP) as SFTPAction
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -1358,25 +1360,53 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
})
|
||||
|
||||
pinTabComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
defaultDirectoryField.document.addDocumentListener(object : DocumentAdaptor() {
|
||||
override fun changedUpdate(e: DocumentEvent) {
|
||||
sftp.defaultDirectory = defaultDirectoryField.text
|
||||
}
|
||||
})
|
||||
|
||||
pinTabComboBox.addItemListener(object : ItemListener {
|
||||
override fun itemStateChanged(e: ItemEvent) {
|
||||
if (e.stateChange != ItemEvent.SELECTED) return
|
||||
sftp.pinTab = pinTabComboBox.selectedItem as Boolean
|
||||
for (window in TermoraFrameManager.getInstance().getWindows()) {
|
||||
val evt = AnActionEvent(window, StringUtils.EMPTY, EventObject(window))
|
||||
if (pinTabComboBox.selectedItem == true) {
|
||||
sftpAction.openOrCreateSFTPTerminalTab(evt)
|
||||
}
|
||||
val tabbed = evt.getData(DataProviders.TabbedPane) ?: continue
|
||||
val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue
|
||||
for ((index, tab) in manager.getTerminalTabs().withIndex()) {
|
||||
if (tab is SFTPTerminalTab) {
|
||||
tabbed.setTabClosable(index, pinTabComboBox.selectedItem != true)
|
||||
break
|
||||
|
||||
if (sftp.pinTab) {
|
||||
if (manager.getTerminalTabs().none { it is SFTPTab }) {
|
||||
manager.addTerminalTab(1, SFTPTab(), false)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新状态
|
||||
manager.refreshTerminalTabs()
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
preserveModificationTimeComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
sftp.preserveModificationTime = preserveModificationTimeComboBox.selectedItem as Boolean
|
||||
}
|
||||
}
|
||||
|
||||
browseDirectoryBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val chooser = FileChooser()
|
||||
chooser.allowsMultiSelection = false
|
||||
chooser.defaultDirectory = StringUtils.defaultIfBlank(
|
||||
defaultDirectoryField.text,
|
||||
SystemUtils.USER_HOME
|
||||
)
|
||||
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
chooser.showOpenDialog(owner).thenAccept { files ->
|
||||
if (files.isNotEmpty()) defaultDirectoryField.text = files.first().absolutePath
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1393,9 +1423,14 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
sftpCommandField.placeholderText = "sftp"
|
||||
}
|
||||
|
||||
defaultDirectoryField.placeholderText = SystemUtils.USER_HOME
|
||||
defaultDirectoryField.trailingComponent = browseDirectoryBtn
|
||||
|
||||
defaultDirectoryField.text = sftp.defaultDirectory
|
||||
editCommandField.text = sftp.editCommand
|
||||
sftpCommandField.text = sftp.sftpCommand
|
||||
pinTabComboBox.selectedItem = sftp.pinTab
|
||||
preserveModificationTimeComboBox.selectedItem = sftp.preserveModificationTime
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
@@ -1416,13 +1451,23 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val box = Box.createHorizontalBox()
|
||||
box.add(JLabel("${I18n.getString("termora.settings.sftp.preserve-time")}:"))
|
||||
box.add(Box.createHorizontalStrut(8))
|
||||
box.add(preserveModificationTimeComboBox)
|
||||
|
||||
var rows = 1
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, 1)
|
||||
builder.add(pinTabComboBox).xy(3, 1)
|
||||
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, 3)
|
||||
builder.add(editCommandField).xy(3, 3)
|
||||
builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, 5)
|
||||
builder.add(sftpCommandField).xy(3, 5)
|
||||
builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, rows)
|
||||
builder.add(pinTabComboBox).xy(3, rows).apply { rows += 2 }
|
||||
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, rows)
|
||||
builder.add(editCommandField).xy(3, rows).apply { rows += 2 }
|
||||
builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, rows)
|
||||
builder.add(sftpCommandField).xy(3, rows).apply { rows += 2 }
|
||||
builder.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
|
||||
builder.add(defaultDirectoryField).xy(3, rows).apply { rows += 2 }
|
||||
builder.add(box).xyw(1, rows, 3).apply { rows += 2 }
|
||||
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import app.termora.keymgr.OhKeyPairKeyPairProvider
|
||||
import app.termora.terminal.TerminalSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.sshd.client.ClientBuilder
|
||||
@@ -16,6 +20,7 @@ import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier
|
||||
import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
|
||||
import org.apache.sshd.client.keyverifier.ServerKeyVerifier
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.common.AttributeRepository
|
||||
import org.apache.sshd.common.SshException
|
||||
import org.apache.sshd.common.channel.PtyChannelConfiguration
|
||||
import org.apache.sshd.common.config.keys.KeyUtils
|
||||
@@ -48,6 +53,9 @@ import javax.swing.SwingUtilities
|
||||
import kotlin.math.max
|
||||
|
||||
object SshClients {
|
||||
|
||||
val HOST_KEY = AttributeRepository.AttributeKey<Host>()
|
||||
|
||||
private val timeout = Duration.ofSeconds(30)
|
||||
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
|
||||
|
||||
@@ -151,7 +159,8 @@ object SshClients {
|
||||
log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
|
||||
}
|
||||
// 映射完毕之后修改Host和端口
|
||||
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
|
||||
jumpHosts[i + 1] =
|
||||
nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +200,8 @@ object SshClients {
|
||||
throw SshException("Authentication failed")
|
||||
}
|
||||
|
||||
session.setAttribute(HOST_KEY, host)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -230,6 +241,29 @@ object SshClients {
|
||||
return sshdSocketAddress
|
||||
}
|
||||
|
||||
suspend fun openClient(host: Host, owner: Window): Pair<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
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开一个客户端
|
||||
*/
|
||||
|
||||
@@ -43,6 +43,8 @@ interface TerminalTab : Disposable, DataProvider {
|
||||
*/
|
||||
fun canClose(): Boolean = true
|
||||
|
||||
fun willBeClose(): Boolean = true
|
||||
|
||||
/**
|
||||
* 是否可以克隆
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,6 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhereResult
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.transport.TransportPanel
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
@@ -121,7 +120,7 @@ class TerminalTabbed(
|
||||
val results = mutableListOf<FindEverywhereResult>()
|
||||
for (i in 0 until tabbedPane.tabCount) {
|
||||
val c = tabbedPane.getComponentAt(i)
|
||||
if (c is WelcomePanel || c is TransportPanel) {
|
||||
if (c is JComponent && c.getClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE) != null) {
|
||||
continue
|
||||
}
|
||||
results.add(
|
||||
@@ -155,7 +154,7 @@ class TerminalTabbed(
|
||||
val tab = tabs[index]
|
||||
|
||||
if (disposable) {
|
||||
if (!tab.canClose()) {
|
||||
if (!tab.willBeClose()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -327,6 +326,13 @@ class TerminalTabbed(
|
||||
Disposer.register(this, tab)
|
||||
}
|
||||
|
||||
override fun refreshTerminalTabs() {
|
||||
for (i in 0 until tabbedPane.tabCount) {
|
||||
tabbedPane.setTabClosable(i, tabs[i].canClose())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
|
||||
if (!SFTPPtyTerminalTab.canSupports) {
|
||||
OptionPane.showMessageDialog(
|
||||
|
||||
@@ -7,4 +7,5 @@ interface TerminalTabbedManager {
|
||||
fun getTerminalTabs(): List<TerminalTab>
|
||||
fun setSelectedTerminalTab(tab: TerminalTab)
|
||||
fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true)
|
||||
fun refreshTerminalTabs()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package app.termora
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.sftp.SFTPTab
|
||||
import app.termora.terminal.DataKey
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
@@ -103,7 +104,7 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
// 下一次事件循环检测是否固定 SFTP
|
||||
SwingUtilities.invokeLater {
|
||||
if (sftp.pinTab) {
|
||||
terminalTabbed.addTerminalTab(SFTPTerminalTab(), false)
|
||||
terminalTabbed.addTerminalTab(SFTPTab(), false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
|
||||
private fun initView() {
|
||||
putClientProperty(FlatClientProperties.TABBED_PANE_TAB_CLOSABLE, false)
|
||||
putClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE, true)
|
||||
|
||||
val panel = JPanel(BorderLayout())
|
||||
panel.add(createSearchPanel(), BorderLayout.NORTH)
|
||||
|
||||
@@ -6,9 +6,9 @@ import app.termora.findeverywhere.FindEverywhereAction
|
||||
import app.termora.highlight.KeywordHighlightAction
|
||||
import app.termora.keymgr.KeyManagerAction
|
||||
import app.termora.macro.MacroAction
|
||||
import app.termora.sftp.SFTPAction
|
||||
import app.termora.snippet.SnippetAction
|
||||
import app.termora.tlog.TerminalLoggerAction
|
||||
import app.termora.transport.SFTPAction
|
||||
import javax.swing.Action
|
||||
|
||||
class ActionManager : org.jdesktop.swingx.action.ActionManager() {
|
||||
|
||||
@@ -73,6 +73,9 @@ class AppUpdateAction private constructor() : AnAction(
|
||||
}
|
||||
|
||||
private suspend fun checkUpdate() {
|
||||
if (Application.isUnknownVersion()) {
|
||||
return
|
||||
}
|
||||
|
||||
val latestVersion = updaterManager.fetchLatestVersion()
|
||||
if (latestVersion.isSelf) {
|
||||
@@ -220,7 +223,10 @@ class AppUpdateAction private constructor() : AnAction(
|
||||
// 没有安装过 则打开安装向导
|
||||
else listOf(file.absolutePath)
|
||||
|
||||
println(commands)
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("restart {}", commands.joinToString(StringUtils.SPACE))
|
||||
}
|
||||
|
||||
TermoraRestarter.getInstance().scheduleRestart(owner, commands)
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import app.termora.Scope
|
||||
interface FindEverywhereProvider {
|
||||
|
||||
companion object {
|
||||
|
||||
const val SKIP_FIND_EVERYWHERE = "SKIP_FIND_EVERYWHERE"
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun getFindEverywhereProviders(scope: Scope): MutableList<FindEverywhereProvider> {
|
||||
var list = scope.getAnyOrNull("FindEverywhereProviders")
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
package app.termora.macro
|
||||
|
||||
import app.termora.Actions
|
||||
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.PtyConnectorDelegate
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import java.util.*
|
||||
|
||||
class MacroPtyConnector(private val connector: PtyConnector) : PtyConnectorDelegate(connector) {
|
||||
private val isRecording get() = ActionManager.getInstance().isSelected(Actions.MACRO)
|
||||
|
||||
companion object {
|
||||
private val bytes = LinkedList<Byte>()
|
||||
private val bytes = ArrayDeque<Byte>()
|
||||
|
||||
fun getRecodingByteArray(): ByteArray {
|
||||
val array = bytes.toByteArray()
|
||||
|
||||
@@ -35,6 +35,11 @@ class FileChooser {
|
||||
} else {
|
||||
val fileChooser = JnaFileChooser()
|
||||
fileChooser.isMultiSelectionEnabled = allowsMultiSelection
|
||||
when (fileSelectionMode) {
|
||||
JFileChooser.DIRECTORIES_ONLY -> fileChooser.mode = JnaFileChooser.Mode.Directories
|
||||
JFileChooser.FILES_ONLY -> fileChooser.mode = JnaFileChooser.Mode.Files
|
||||
JFileChooser.FILES_AND_DIRECTORIES -> fileChooser.mode = JnaFileChooser.Mode.FilesAndDirectories
|
||||
}
|
||||
fileChooser.setTitle(title)
|
||||
|
||||
if (defaultDirectory.isNotBlank()) {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
package app.termora.transport
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.I18n
|
||||
import app.termora.Icons
|
||||
import app.termora.assertEventDispatchThread
|
||||
import app.termora.Database
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.ui.FlatUIUtils
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.ActionEvent
|
||||
@@ -23,6 +18,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
|
||||
private val properties by lazy { Database.getDatabase().properties }
|
||||
private val arrowWidth = 16
|
||||
private val arrowSize = 6
|
||||
private val button = this
|
||||
|
||||
/**
|
||||
* 为 true 表示在书签内
|
||||
@@ -49,13 +45,15 @@ class BookmarkButton : JButton(Icons.bookmarks) {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e)) {
|
||||
if (e.x < oldWidth) {
|
||||
super@BookmarkButton.fireActionPerformed(
|
||||
ActionEvent(
|
||||
this@BookmarkButton,
|
||||
ActionEvent.ACTION_PERFORMED,
|
||||
StringUtils.EMPTY
|
||||
for (listener in actionListeners) {
|
||||
listener.actionPerformed(
|
||||
ActionEvent(
|
||||
button,
|
||||
ActionEvent.ACTION_PERFORMED,
|
||||
StringUtils.EMPTY
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showBookmarks(e)
|
||||
}
|
||||
@@ -80,13 +78,15 @@ class BookmarkButton : JButton(Icons.bookmarks) {
|
||||
popupMenu.addSeparator()
|
||||
for (bookmark in bookmarks) {
|
||||
popupMenu.add(bookmark).addActionListener {
|
||||
super@BookmarkButton.fireActionPerformed(
|
||||
ActionEvent(
|
||||
this@BookmarkButton,
|
||||
ActionEvent.ACTION_PERFORMED,
|
||||
bookmark
|
||||
for (listener in actionListeners) {
|
||||
listener.actionPerformed(
|
||||
ActionEvent(
|
||||
button,
|
||||
ActionEvent.ACTION_PERFORMED,
|
||||
bookmark
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
|
||||
|
||||
g.color = if (FlatLaf.isLafDark()) Color(206, 208, 214) else Color(108, 112, 126)
|
||||
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
FlatUIUtils.paintArrow(
|
||||
g2d, x, preferredSize.height / 2 - arrowSize, arrowWidth, arrowWidth, SwingConstants.SOUTH,
|
||||
false, arrowSize, 0f, 0f, 0f
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.termora.transport
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.DynamicColor
|
||||
259
src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
468
src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
844
src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
265
src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
81
src/main/kotlin/app/termora/sftp/NativeFileIcons.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.termora.transport
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.I18n
|
||||
62
src/main/kotlin/app/termora/sftp/SFTPAction.kt
Normal 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)
|
||||
|
||||
}
|
||||
}
|
||||
11
src/main/kotlin/app/termora/sftp/SFTPActionEvent.kt
Normal 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)
|
||||
12
src/main/kotlin/app/termora/sftp/SFTPDataProviders.kt
Normal 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)
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package app.termora.transport
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.terminal.DataKey
|
||||
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
|
||||
import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.*
|
||||
@@ -23,15 +21,18 @@ import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.CardLayout
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.*
|
||||
|
||||
class SftpFileSystemPanel(
|
||||
var host: Host? = null
|
||||
) : JPanel(BorderLayout()), Disposable {
|
||||
class SFTPFileSystemViewPanel(
|
||||
var host: Host? = null,
|
||||
private val transportManager: TransportManager,
|
||||
) : JPanel(BorderLayout()), Disposable, DataProvider {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java)
|
||||
private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java)
|
||||
|
||||
private enum class State {
|
||||
Initialized,
|
||||
@@ -50,11 +51,14 @@ class SftpFileSystemPanel(
|
||||
private val selectHostPanel = SelectHostPanel()
|
||||
private val connectFailedPanel = ConnectFailedPanel()
|
||||
private val isDisposed = AtomicBoolean(false)
|
||||
private val that = this
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
|
||||
private var client: SshClient? = null
|
||||
private var session: ClientSession? = null
|
||||
private var fileSystem: SftpFileSystem? = null
|
||||
var fileSystemPanel: FileSystemPanel? = null
|
||||
private var fileSystemPanel: FileSystemViewPanel? = null
|
||||
|
||||
|
||||
init {
|
||||
@@ -71,12 +75,11 @@ class SftpFileSystemPanel(
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
Disposer.register(this, selectHostPanel)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun connect() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
coroutineScope.launch {
|
||||
if (state != State.Connecting) {
|
||||
state = State.Connecting
|
||||
|
||||
@@ -100,42 +103,17 @@ class SftpFileSystemPanel(
|
||||
connectingPanel.stop()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doConnect() {
|
||||
|
||||
val thisHost = this.host ?: return
|
||||
var host = thisHost.copy(authentication = thisHost.authentication.copy(), updateDate = System.currentTimeMillis())
|
||||
|
||||
closeIO()
|
||||
|
||||
try {
|
||||
val client = SshClients.openClient(host).apply { client = this }
|
||||
withContext(Dispatchers.Swing) {
|
||||
val owner = SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel)
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
|
||||
// 弹出授权框
|
||||
if (host.authentication.type == AuthenticationType.No) {
|
||||
val dialog = RequestAuthenticationDialog(owner, host)
|
||||
val authentication = dialog.getAuthentication()
|
||||
host = host.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
// save
|
||||
if (dialog.isRemembered()) {
|
||||
HostManager.getInstance().addHost(
|
||||
host.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val (client, host) = SshClients.openClient(thisHost, SwingUtilities.getWindowAncestor(that))
|
||||
this.client = client
|
||||
val session = SshClients.openSession(host, client).apply { session = this }
|
||||
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
|
||||
session.addCloseFutureListener { onClose() }
|
||||
@@ -152,18 +130,10 @@ class SftpFileSystemPanel(
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
state = State.Connected
|
||||
|
||||
val fileSystemPanel = FileSystemPanel(fileSystem, host)
|
||||
|
||||
val fileSystemPanel = FileSystemViewPanel(thisHost, fileSystem, transportManager, coroutineScope)
|
||||
cardPanel.add(fileSystemPanel, State.Connected.name)
|
||||
cardLayout.show(cardPanel, State.Connected.name)
|
||||
|
||||
firePropertyChange("TabName", StringUtils.EMPTY, host.name)
|
||||
|
||||
this@SftpFileSystemPanel.fileSystemPanel = fileSystemPanel
|
||||
|
||||
// 立即加载
|
||||
fileSystemPanel.reload()
|
||||
that.fileSystemPanel = fileSystemPanel
|
||||
}
|
||||
|
||||
}
|
||||
@@ -199,6 +169,7 @@ class SftpFileSystemPanel(
|
||||
override fun dispose() {
|
||||
if (isDisposed.compareAndSet(false, true)) {
|
||||
closeIO()
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +240,7 @@ class SftpFileSystemPanel(
|
||||
AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
state = State.Initialized
|
||||
this@SftpFileSystemPanel.firePropertyChange("TabName", StringUtils.SPACE, StringUtils.EMPTY)
|
||||
that.setTabTitle(I18n.getString("termora.transport.sftp.select-host"))
|
||||
cardLayout.show(cardPanel, State.Initialized.name)
|
||||
}
|
||||
}).apply {
|
||||
@@ -281,44 +252,65 @@ class SftpFileSystemPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SelectHostPanel : JPanel(BorderLayout()) {
|
||||
private inner class SelectHostPanel : JPanel(BorderLayout()), Disposable {
|
||||
private val tree = NewHostTree()
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
val formMargin = "4dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, pref, default:grow",
|
||||
"40dlu, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
tree.contextmenu = false
|
||||
tree.dragEnabled = false
|
||||
tree.doubleClickConnection = false
|
||||
|
||||
val scrollPane = JScrollPane(tree)
|
||||
scrollPane.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
|
||||
add(scrollPane, BorderLayout.CENTER)
|
||||
|
||||
val errorInfo = JLabel(I18n.getString("termora.transport.sftp.connect-a-host"))
|
||||
errorInfo.horizontalAlignment = SwingConstants.CENTER
|
||||
TreeUtils.loadExpansionState(tree, properties.getString("SFTPTabbed.Tree.state", StringUtils.EMPTY))
|
||||
}
|
||||
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
builder.add(FlatOptionPaneInformationIcon()).xy(2, 2)
|
||||
builder.add(errorInfo).xyw(1, 4, 3, "fill, center")
|
||||
builder.add(JXHyperlink(object : AnAction(I18n.getString("termora.transport.sftp.select-host")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val dialog = NewHostTreeDialog(evt.window)
|
||||
dialog.setFilter { it.host.protocol == Protocol.SSH }
|
||||
dialog.setTreeName("SftpFileSystemPanel.SelectHostTree")
|
||||
dialog.allowMulti = false
|
||||
dialog.setLocationRelativeTo(this@SelectHostPanel)
|
||||
dialog.isVisible = true
|
||||
this@SftpFileSystemPanel.host = dialog.hosts.firstOrNull() ?: return
|
||||
connect()
|
||||
private fun initEvents() {
|
||||
tree.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val node = tree.getLastSelectedPathNode() ?: return
|
||||
if (node.isFolder) return
|
||||
val host = node.data as Host
|
||||
that.setTabTitle(host.name)
|
||||
that.host = host
|
||||
that.connect()
|
||||
}
|
||||
}
|
||||
}).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
isFocusable = false
|
||||
}).xy(2, 6)
|
||||
add(builder.build(), BorderLayout.CENTER)
|
||||
})
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree))
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
215
src/main/kotlin/app/termora/sftp/SFTPPanel.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
82
src/main/kotlin/app/termora/sftp/SFTPTab.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
116
src/main/kotlin/app/termora/sftp/SFTPTabbed.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/main/kotlin/app/termora/sftp/SpeedReporter.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
267
src/main/kotlin/app/termora/sftp/Transport.kt
Normal 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 }
|
||||
}
|
||||
|
||||
}
|
||||
10
src/main/kotlin/app/termora/sftp/TransportListener.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import java.util.*
|
||||
|
||||
interface TransportListener : EventListener {
|
||||
/**
|
||||
* 状态变化
|
||||
*/
|
||||
fun onTransportChanged(transport: Transport) {}
|
||||
}
|
||||
11
src/main/kotlin/app/termora/sftp/TransportManager.kt
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package app.termora.sftp
|
||||
|
||||
class TransportStatusException(message: String) : RuntimeException(message)
|
||||
261
src/main/kotlin/app/termora/sftp/TransportTable.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
443
src/main/kotlin/app/termora/sftp/TransportTableModel.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import java.util.*
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
class TerminalReader {
|
||||
private val buffer = LinkedList<Char>()
|
||||
private val buffer = ArrayDeque<Char>()
|
||||
|
||||
|
||||
fun addLast(char: Char) {
|
||||
@@ -12,7 +12,9 @@ class TerminalReader {
|
||||
}
|
||||
|
||||
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) {
|
||||
text.toCharArray().forEach { addLast(it) }
|
||||
text.forEach { addLast(it) }
|
||||
}
|
||||
|
||||
fun read(): Char {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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){}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,8 @@ termora.settings.keymap.already-exists=The shortcut [{0}] is already in use by [
|
||||
|
||||
termora.settings.sftp.edit-command=Edit Command
|
||||
termora.settings.sftp.fixed-tab=Fixed tab
|
||||
termora.settings.sftp.default-directory=Default Directory
|
||||
termora.settings.sftp.preserve-time=Preserve original file modification time
|
||||
|
||||
|
||||
termora.settings.restart.title=Restart
|
||||
@@ -306,15 +308,14 @@ termora.transport.permissions.others=Others
|
||||
termora.transport.sftp.retry=Retry
|
||||
termora.transport.sftp.select-another-host=Select another host
|
||||
termora.transport.sftp.select-host=Select host
|
||||
termora.transport.sftp.connect-a-host=Connect to a Host
|
||||
termora.transport.sftp.connecting=Connecting...
|
||||
termora.transport.sftp.closed=The connection has been closed
|
||||
termora.transport.sftp.close-tab=Transfer is still in activated status. Are you sure you want to remove all jobs and close this session?
|
||||
termora.transport.sftp.status.transporting=Transporting
|
||||
termora.transport.sftp.close-tab-has-active-session=Session is still active. Do you want to close all sessions?
|
||||
termora.transport.sftp.status.transporting=In progress
|
||||
termora.transport.sftp.status.waiting=Waiting
|
||||
termora.transport.sftp.status.done=Done
|
||||
termora.transport.sftp.status.failed=Failed
|
||||
termora.transport.sftp.status.cancelled=Cancelled
|
||||
|
||||
|
||||
# transport job
|
||||
@@ -326,6 +327,10 @@ termora.transport.jobs.table.source-path=Source Path
|
||||
termora.transport.jobs.table.target-path=Target Path
|
||||
termora.transport.jobs.table.speed=Speed
|
||||
termora.transport.jobs.table.estimated-time=Estimated time
|
||||
termora.transport.jobs.table.estimated-time-days-format={0}d {1}h {2}m {3}s
|
||||
termora.transport.jobs.table.estimated-time-hours-format={0}h {1}m {2}s
|
||||
termora.transport.jobs.table.estimated-time-minutes-format={0}m {1}s
|
||||
termora.transport.jobs.table.estimated-time-seconds-format={0}s
|
||||
|
||||
termora.transport.jobs.contextmenu.delete=${termora.remove}
|
||||
termora.transport.jobs.contextmenu.delete-all=Delete All
|
||||
|
||||
@@ -114,7 +114,8 @@ termora.settings.keymap.already-exists=快捷键 [{0}] 已经被 [{1}] 占用
|
||||
|
||||
termora.settings.sftp.edit-command=编辑命令
|
||||
termora.settings.sftp.fixed-tab=固定标签
|
||||
|
||||
termora.settings.sftp.default-directory=默认目录
|
||||
termora.settings.sftp.preserve-time=保留原始文件修改时间
|
||||
|
||||
# Welcome
|
||||
termora.welcome.my-hosts=我的主机
|
||||
@@ -283,16 +284,15 @@ termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令删除文件
|
||||
termora.transport.sftp.retry=重试
|
||||
termora.transport.sftp.select-another-host=选择其他主机
|
||||
termora.transport.sftp.select-host=选择主机
|
||||
termora.transport.sftp.connect-a-host=连接一个主机
|
||||
termora.transport.sftp.connecting=连接中...
|
||||
termora.transport.sftp.closed=连接已经关闭
|
||||
termora.transport.sftp.close-tab=传输还处于活动状态,是否删除所有传输任务并关闭此会话?
|
||||
termora.transport.sftp.close-tab-has-active-session=会话还处于活动状态,是否关闭所有会话?
|
||||
|
||||
termora.transport.sftp.status.transporting=传输中
|
||||
termora.transport.sftp.status.waiting=等待中
|
||||
termora.transport.sftp.status.done=已完成
|
||||
termora.transport.sftp.status.failed=已失败
|
||||
termora.transport.sftp.status.cancelled=已取消
|
||||
|
||||
|
||||
# Permission
|
||||
@@ -314,6 +314,10 @@ termora.transport.jobs.table.source-path=源路径
|
||||
termora.transport.jobs.table.target-path=目标路径
|
||||
termora.transport.jobs.table.speed=速度
|
||||
termora.transport.jobs.table.estimated-time=剩余时间
|
||||
termora.transport.jobs.table.estimated-time-days-format={0}天{1}小时{2}分{3}秒
|
||||
termora.transport.jobs.table.estimated-time-hours-format={0}小时{1}分{2}秒
|
||||
termora.transport.jobs.table.estimated-time-minutes-format={0}分{1}秒
|
||||
termora.transport.jobs.table.estimated-time-seconds-format={0}秒
|
||||
|
||||
termora.transport.jobs.contextmenu.delete-all=删除所有
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ termora.settings.keymap.already-exists=快捷鍵 [{0}] 已經被 [{1}] 占用
|
||||
|
||||
termora.settings.sftp.edit-command=編輯命令
|
||||
termora.settings.sftp.fixed-tab=固定標籤
|
||||
termora.settings.sftp.default-directory=預設目錄
|
||||
termora.settings.sftp.preserve-time=保留原始文件修改時間
|
||||
|
||||
|
||||
# Find everywhere
|
||||
@@ -278,15 +280,14 @@ termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令刪除資料
|
||||
termora.transport.sftp.retry=重試
|
||||
termora.transport.sftp.select-another-host=選擇其他主機
|
||||
termora.transport.sftp.select-host=選擇主機
|
||||
termora.transport.sftp.connect-a-host=連接一個主機
|
||||
termora.transport.sftp.connecting=連接中...
|
||||
termora.transport.sftp.closed=連線已經關閉
|
||||
termora.transport.sftp.close-tab=傳輸仍處於活動狀態,是否刪除所有傳輸任務並關閉此會話?
|
||||
termora.transport.sftp.close-tab-has-active-session=會話仍處於活動狀態,是否關閉所有會話?
|
||||
termora.transport.sftp.status.transporting=傳輸中
|
||||
termora.transport.sftp.status.waiting=等待中
|
||||
termora.transport.sftp.status.done=已完成
|
||||
termora.transport.sftp.status.failed=已失敗
|
||||
termora.transport.sftp.status.cancelled=已取消
|
||||
|
||||
# transport job
|
||||
termora.transport.jobs.table.name=名稱
|
||||
@@ -297,6 +298,10 @@ termora.transport.jobs.table.source-path=來源路徑
|
||||
termora.transport.jobs.table.target-path=目標路徑
|
||||
termora.transport.jobs.table.speed=速度
|
||||
termora.transport.jobs.table.estimated-time=剩餘時間
|
||||
termora.transport.jobs.table.estimated-time-days-format={0}天{1}小時{2}分{3}秒
|
||||
termora.transport.jobs.table.estimated-time-hours-format={0}小時{1}分{2}秒
|
||||
termora.transport.jobs.table.estimated-time-minutes-format={0}分{1}秒
|
||||
termora.transport.jobs.table.estimated-time-seconds-format={0}秒
|
||||
|
||||
termora.transport.jobs.contextmenu.delete-all=刪除所有
|
||||
|
||||
|
||||
4
src/main/resources/icons/chevronRight.svg
Normal 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 |
4
src/main/resources/icons/chevronRight_dark.svg
Normal 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 |
@@ -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"
|
||||
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>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
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>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
5
src/main/resources/icons/inspectionsEye.svg
Normal 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 |
5
src/main/resources/icons/inspectionsEye_dark.svg
Normal 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 |
6
src/main/resources/icons/warningIntroduction.svg
Normal 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 |
6
src/main/resources/icons/warningIntroduction_dark.svg
Normal 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 |