feat: support SFTP

Refs #10
Refs #9
Refs #6
This commit is contained in:
hstyi
2025-01-05 20:32:02 +08:00
committed by hstyi
parent 46af9a44b2
commit 89fa153c1e
50 changed files with 3567 additions and 23 deletions

View File

@@ -17,8 +17,11 @@ import java.io.File
import java.net.URI
import java.time.Duration
import java.util.*
import kotlin.math.ln
import kotlin.math.pow
import kotlin.reflect.KClass
object Application {
private val services = Collections.synchronizedMap(mutableMapOf<KClass<*>, Any>())
private lateinit var baseDataDir: File
@@ -99,6 +102,10 @@ object Application {
return version
}
fun isUnknownVersion(): Boolean {
return getVersion().contains("unknown")
}
fun getAppPath(): String {
return StringUtils.defaultString(System.getProperty("jpackage.app-path"))
}
@@ -144,3 +151,28 @@ object Application {
}
}
}
fun formatBytes(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
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])
}
fun formatSeconds(seconds: Long): String {
val days = seconds / 86400
val hours = (seconds % 86400) / 3600
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}"
}
}

View File

@@ -27,6 +27,13 @@ class HostTree : JTree(), Disposable {
private val hostManager get() = HostManager.instance
private val editor = OutlineTextField(64)
var contextmenu = true
/**
* 双击是否打开连接
*/
var doubleClickConnection = true
val model = HostTreeModel()
val searchableModel = SearchableHostTreeModel(model)
@@ -122,7 +129,7 @@ class HostTree : JTree(), Disposable {
}
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val host = lastSelectedPathComponent
if (host is Host && host.protocol != Protocol.Folder) {
ActionManager.getInstance().getAction(Actions.OPEN_HOST)
@@ -296,6 +303,8 @@ class HostTree : JTree(), Disposable {
}
private fun showContextMenu(event: MouseEvent) {
if (!contextmenu) return
val lastHost = lastSelectedPathComponent
if (lastHost !is Host) {
return
@@ -356,7 +365,7 @@ class HostTree : JTree(), Disposable {
remove.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
"删除后无法恢复,你确定要删除吗?",
I18n.getString("termora.keymgr.delete-warning"),
I18n.getString("termora.remove"),
JOptionPane.YES_NO_OPTION,
JOptionPane.QUESTION_MESSAGE
@@ -512,7 +521,7 @@ class HostTree : JTree(), Disposable {
collapsePath(TreePath(model.getPathToRoot(node)))
}
private fun getSelectionNodes(): List<Host> {
fun getSelectionNodes(): List<Host> {
val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent }
.filterIsInstance<Host>()

View File

@@ -0,0 +1,119 @@
package app.termora
import app.termora.db.Database
import java.awt.Dimension
import java.awt.Window
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.*
import javax.swing.tree.TreeSelectionModel
class HostTreeDialog(owner: Window) : DialogWrapper(owner) {
private val tree = HostTree()
val hosts = mutableListOf<Host>()
var allowMulti = true
set(value) {
field = value
if (value) {
tree.selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
} else {
tree.selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION
}
}
init {
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
isModal = true
isResizable = false
controlsVisible = false
title = I18n.getString("termora.transport.sftp.select-host")
tree.setModel(SearchableHostTreeModel(tree.model) { host ->
host.protocol == Protocol.Folder || host.protocol == Protocol.SSH
})
tree.contextmenu = true
tree.doubleClickConnection = false
tree.dragEnabled = false
initEvents()
init()
setLocationRelativeTo(null)
}
private fun initEvents() {
addWindowListener(object : WindowAdapter() {
override fun windowActivated(e: WindowEvent) {
removeWindowListener(this)
val state = Database.instance.properties.getString("HostTreeDialog.HostTreeExpansionState")
if (state != null) {
TreeUtils.loadExpansionState(tree, state)
}
}
})
tree.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val node = tree.lastSelectedPathComponent ?: return
if (node is Host && node.protocol != Protocol.Folder) {
doOKAction()
}
}
}
})
addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
Database.instance.properties.putString(
"HostTreeDialog.HostTreeExpansionState",
TreeUtils.saveExpansionState(tree)
)
}
})
}
override fun createCenterPanel(): JComponent {
val scrollPane = JScrollPane(tree)
scrollPane.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(4, 6, 4, 6)
)
return scrollPane
}
override fun doOKAction() {
if (allowMulti) {
val nodes = tree.getSelectionNodes().filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) {
return
}
hosts.clear()
hosts.addAll(nodes)
} else {
val node = tree.lastSelectedPathComponent ?: return
if (node !is Host || node.protocol != Protocol.SSH) {
return
}
hosts.clear()
hosts.add(node)
}
super.doOKAction()
}
override fun doCancelAction() {
hosts.clear()
super.doCancelAction()
}
}

View File

@@ -1,6 +1,7 @@
package app.termora
object Icons {
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
@@ -14,6 +15,7 @@ object Icons {
val pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") }
val empty by lazy { DynamicIcon("icons/empty.svg") }
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_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") }
@@ -26,6 +28,8 @@ object Icons {
val rec by lazy { DynamicIcon("icons/rec.svg", "icons/rec_dark.svg") }
val stop by lazy { DynamicIcon("icons/stop.svg", "icons/stop_dark.svg") }
val find by lazy { DynamicIcon("icons/find.svg", "icons/find_dark.svg") }
val bookmarks by lazy { DynamicIcon("icons/bookmarks.svg", "icons/bookmarks_dark.svg") }
val bookmarksOff by lazy { DynamicIcon("icons/bookmarksOff.svg", "icons/bookmarksOff_dark.svg") }
val keyboard by lazy { DynamicIcon("icons/keyboard.svg", "icons/keyboard_dark.svg") }
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") }
val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
@@ -64,9 +68,12 @@ object Icons {
val split by lazy { DynamicIcon("icons/split.svg", "icons/split_dark.svg") }
val setKey by lazy { DynamicIcon("icons/setKey.svg", "icons/setKey_dark.svg") }
val greyKey by lazy { DynamicIcon("icons/greyKey.svg", "icons/greyKey_dark.svg") }
val refresh by lazy { DynamicIcon("icons/refresh.svg", "icons/refresh_dark.svg") }
val sortedSet by lazy { DynamicIcon("icons/sortedSet.svg", "icons/sortedSet_dark.svg") }
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") }
val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
val fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") }
val expand by lazy { DynamicIcon("icons/expand.svg", "icons/expand_dark.svg") }
val collapse by lazy { DynamicIcon("icons/collapse.svg", "icons/collapse_dark.svg") }

View File

@@ -0,0 +1,53 @@
package app.termora
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 {
private val transportPanel by lazy {
TransportPanel().apply {
Disposer.register(this@SFTPTerminalTab, this)
}
}
override fun getTitle(): String {
return "SFTP"
}
override fun getIcon(): Icon {
return Icons.fileTransfer
}
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
}
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
}
override fun getJComponent(): JComponent {
return transportPanel
}
override fun canClose(): Boolean {
assertEventDispatchThread()
if (transportPanel.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
}
}

View File

@@ -5,7 +5,10 @@ import javax.swing.event.TreeModelListener
import javax.swing.tree.TreeModel
import javax.swing.tree.TreePath
class SearchableHostTreeModel(private val model: HostTreeModel) : TreeModel {
class SearchableHostTreeModel(
private val model: HostTreeModel,
private val filter: (host: Host) -> Boolean = { true }
) : TreeModel {
private var text = String()
override fun getRoot(): Any {
@@ -45,7 +48,8 @@ class SearchableHostTreeModel(private val model: HostTreeModel) : TreeModel {
val children = model.getChildren(parent)
if (children.isEmpty()) return emptyList()
return children.filter { e ->
e.name.contains(text, true) || TreeUtils.children(model, e, true).filterIsInstance<Host>().any {
filter.invoke(e) && e.name.contains(text, true) || TreeUtils.children(model, e, true)
.filterIsInstance<Host>().any {
it.name.contains(text, true)
}
}

View File

@@ -37,5 +37,10 @@ interface TerminalTab : Disposable {
fun onLostFocus() {}
fun onGrabFocus() {}
/**
* @return 返回 false 则不可关闭
*/
fun canClose(): Boolean = true
}

View File

@@ -3,9 +3,9 @@ package app.termora
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.JPanel
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.*
class TerminalTabDialog(
owner: Window,
@@ -19,10 +19,20 @@ class TerminalTabDialog(
isAlwaysOnTop = false
iconImages = owner.iconImages
escapeDispose = false
super.setSize(size)
init()
defaultCloseOperation = WindowConstants.DO_NOTHING_ON_CLOSE
addWindowListener(object : WindowAdapter() {
override fun windowClosing(e: WindowEvent) {
if (terminalTab.canClose()) {
SwingUtilities.invokeLater { doCancelAction() }
}
}
})
setLocationRelativeTo(null)
}

View File

@@ -162,7 +162,7 @@ class TerminalTabbed(
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e)) {
val index = tabbedPane.indexAtLocation(e.x, e.y)
if (index >= 0) {
if (index > 0) {
tabbedPane.getComponentAt(index).requestFocusInWindow()
}
}
@@ -213,6 +213,13 @@ class TerminalTabbed(
private fun removeTabAt(index: Int, disposable: Boolean = true) {
if (tabbedPane.isTabClosable(index)) {
val tab = tabs[index]
if (disposable) {
if (!tab.canClose()) {
return
}
}
tab.onLostFocus()
tab.removePropertyChangeListener(iconListener)
@@ -244,6 +251,7 @@ class TerminalTabbed(
val popupMenu = FlatPopupMenu()
// 修改名称
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
rename.addActionListener {
val index = tabbedPane.selectedIndex
@@ -261,6 +269,7 @@ class TerminalTabbed(
}
// 克隆
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
clone.addActionListener {
val index = tabbedPane.selectedIndex
@@ -272,9 +281,9 @@ class TerminalTabbed(
.actionPerformed(OpenHostActionEvent(this, tab.host))
}
}
}
// 在新窗口中打开
val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window"))
openInNewWindow.addActionListener {
val index = tabbedPane.selectedIndex
@@ -294,11 +303,13 @@ class TerminalTabbed(
popupMenu.addSeparator()
// 关闭
val close = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close"))
close.addActionListener {
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabIndex)
}
// 关闭其他标签页
popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close-other-tabs")).addActionListener {
for (i in tabbedPane.tabCount - 1 downTo tabIndex + 1) {
tabbedPane.tabCloseCallback?.accept(tabbedPane, i)
@@ -308,6 +319,7 @@ class TerminalTabbed(
}
}
// 关闭所有标签页
popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close-all-tabs")).addActionListener {
for (i in 0 until tabbedPane.tabCount) {
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabbedPane.tabCount - 1)
@@ -320,6 +332,11 @@ class TerminalTabbed(
clone.isEnabled = close.isEnabled
openInNewWindow.isEnabled = close.isEnabled
// SFTP不允许克隆
if (clone.isEnabled && getSelectedTerminalTab() is SFTPTerminalTab) {
clone.isEnabled = false
}
if (close.isEnabled) {
popupMenu.addSeparator()
@@ -400,5 +417,14 @@ class TerminalTabbed(
return tabs
}
override fun setSelectedTerminalTab(tab: TerminalTab) {
for (index in tabs.indices) {
if (tabs[index] == tab) {
tabbedPane.selectedIndex = index
break
}
}
}
}

View File

@@ -4,4 +4,5 @@ interface TerminalTabbedManager {
fun addTerminalTab(tab: TerminalTab)
fun getSelectedTerminalTab(): TerminalTab?
fun getTerminalTabs(): List<TerminalTab>
fun setSelectedTerminalTab(tab: TerminalTab)
}

View File

@@ -392,9 +392,6 @@ class TermoraFrame : JFrame() {
if (e.source == tabbedPane) {
val index = tabbedPane.indexAtLocation(e.x, e.y)
if (index >= 0) {
if (e.id == MouseEvent.MOUSE_CLICKED) {
tabbedPane.getComponentAt(index)?.requestFocusInWindow()
}
return
}
}

View File

@@ -1,10 +1,9 @@
package app.termora.findeverywhere
import app.termora.Actions
import app.termora.I18n
import app.termora.Icons
import app.termora.*
import com.formdev.flatlaf.FlatLaf
import org.jdesktop.swingx.action.ActionManager
import java.awt.event.ActionEvent
import javax.swing.Icon
class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
@@ -15,6 +14,24 @@ class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
ActionManager.getInstance().getAction(Actions.ADD_HOST)?.let {
list.add(CreateHostFindEverywhereResult())
}
// SFTP
list.add(ActionFindEverywhereResult(object : AnAction("SFTP", Icons.fileTransfer) {
override fun actionPerformed(evt: ActionEvent) {
val terminalTabbedManager = Application.getService(TerminalTabbedManager::class)
val tabs = terminalTabbedManager.getTerminalTabs()
for (i in tabs.indices) {
val tab = tabs[i]
if (tab is SFTPTerminalTab) {
terminalTabbedManager.setSelectedTerminalTab(tab)
return
}
}
// 创建一个新的
terminalTabbedManager.addTerminalTab(SFTPTerminalTab())
}
}))
return list
}

View File

@@ -0,0 +1,164 @@
package app.termora.transport
import app.termora.Application.ohMyJson
import app.termora.DynamicColor
import app.termora.I18n
import app.termora.Icons
import app.termora.assertEventDispatchThread
import app.termora.db.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
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.SwingConstants
import javax.swing.SwingUtilities
class BookmarkButton : JButton(Icons.bookmarks) {
private val properties by lazy { Database.instance.properties }
private val arrowWidth = 16
private val arrowSize = 6
/**
* 为 true 表示在书签内
*/
var isBookmark = false
set(value) {
field = value
icon = if (value) {
Icons.bookmarksOff
} else {
Icons.bookmarks
}
}
init {
val oldWidth = preferredSize.width
preferredSize = Dimension(oldWidth + arrowWidth, preferredSize.height)
horizontalAlignment = SwingConstants.LEFT
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e)) {
if (e.x < oldWidth) {
super@BookmarkButton.fireActionPerformed(
ActionEvent(
this@BookmarkButton,
ActionEvent.ACTION_PERFORMED,
StringUtils.EMPTY
)
)
} else {
showBookmarks(e)
}
}
}
})
isBookmark = false
}
private fun showBookmarks(e: MouseEvent) {
if (StringUtils.isBlank(name)) return
val popupMenu = FlatPopupMenu()
val bookmarks = getBookmarks()
popupMenu.add(I18n.getString("termora.transport.bookmarks")).addActionListener {
val list = BookmarksDialog(SwingUtilities.getWindowAncestor(this), bookmarks).open()
properties.putString(name, ohMyJson.encodeToString(list))
}
if (bookmarks.isNotEmpty()) {
popupMenu.addSeparator()
for (bookmark in bookmarks) {
popupMenu.add(bookmark).addActionListener {
super@BookmarkButton.fireActionPerformed(
ActionEvent(
this@BookmarkButton,
ActionEvent.ACTION_PERFORMED,
bookmark
)
)
}
}
}
popupMenu.show(e.component, -(popupMenu.preferredSize.width / 2 - width / 2), height + 2)
}
fun addBookmark(text: String) {
assertEventDispatchThread()
if (StringUtils.isBlank(name)) return
val bookmarks = getBookmarks().toMutableList()
bookmarks.add(text)
properties.putString(name, ohMyJson.encodeToString(bookmarks))
}
fun deleteBookmark(text: String) {
assertEventDispatchThread()
if (StringUtils.isBlank(name)) return
val bookmarks = getBookmarks().toMutableList()
bookmarks.removeIf { text == it }
properties.putString(name, ohMyJson.encodeToString(bookmarks))
}
fun getBookmarks(): List<String> {
if (StringUtils.isBlank(name)) {
return emptyList()
}
val text = properties.getString(name, "[]")
if (StringUtils.isNotBlank(text)) {
runCatching { ohMyJson.decodeFromString<List<String>>(text) }.onSuccess {
return it
}
}
return emptyList()
}
override fun paintComponent(g: Graphics) {
val g2d = g as Graphics2D
super.paintComponent(g2d)
val x = preferredSize.width - arrowWidth
g.color = DynamicColor.BorderColor
g.drawLine(x + 1, 4, x + 1, preferredSize.height - 2)
g.color = if (FlatLaf.isLafDark()) Color(206, 208, 214) else Color(108, 112, 126)
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
)
}
override fun isSelected(): Boolean {
return false
}
/**
* 忽略默认的触发事件
*/
override fun fireActionPerformed(event: ActionEvent) {
}
}

View File

@@ -0,0 +1,157 @@
package app.termora.transport
import app.termora.DialogWrapper
import app.termora.DynamicColor
import app.termora.I18n
import app.termora.OptionPane
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import javax.swing.*
import javax.swing.border.EmptyBorder
class BookmarksDialog(
owner: Window,
bookmarks: List<String>
) : DialogWrapper(owner) {
private val model = DefaultListModel<String>()
private val list = JList(model)
private val upBtn = JButton(I18n.getString("termora.transport.bookmarks.up"))
private val downBtn = JButton(I18n.getString("termora.transport.bookmarks.down"))
private val deleteBtn = JButton(I18n.getString("termora.remove"))
init {
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
isModal = true
title = I18n.getString("termora.transport.bookmarks")
initView()
initEvents()
model.addAll(bookmarks)
init()
setLocationRelativeTo(null)
}
private fun initView() {
upBtn.isEnabled = false
downBtn.isEnabled = false
deleteBtn.isEnabled = false
upBtn.isFocusable = false
downBtn.isFocusable = false
deleteBtn.isFocusable = false
list.fixedCellHeight = UIManager.getInt("Tree.rowHeight")
list.selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION
}
private fun initEvents() {
upBtn.addActionListener {
val rows = list.selectedIndices.sorted()
list.clearSelection()
for (row in rows) {
val a = model.getElementAt(row - 1)
val b = model.getElementAt(row)
model.setElementAt(b, row - 1)
model.setElementAt(a, row)
list.selectionModel.addSelectionInterval(row - 1, row - 1)
}
}
downBtn.addActionListener {
val rows = list.selectedIndices.sortedDescending()
list.clearSelection()
for (row in rows) {
val a = model.getElementAt(row + 1)
val b = model.getElementAt(row)
model.setElementAt(b, row + 1)
model.setElementAt(a, row)
list.selectionModel.addSelectionInterval(row + 1, row + 1)
}
}
deleteBtn.addActionListener {
if (list.selectionModel.selectedItemsCount > 0) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
for (e in list.selectionModel.selectedIndices.sortedDescending()) {
model.removeElementAt(e)
}
if (model.size > 0) {
list.selectedIndex = 0
}
}
}
}
list.selectionModel.addListSelectionListener {
upBtn.isEnabled = list.selectionModel.selectedItemsCount > 0
downBtn.isEnabled = upBtn.isEnabled
deleteBtn.isEnabled = upBtn.isEnabled
upBtn.isEnabled = list.minSelectionIndex != 0
downBtn.isEnabled = list.maxSelectionIndex != model.size - 1
}
}
override fun createCenterPanel(): JComponent {
val panel = JPanel(BorderLayout())
panel.add(JScrollPane(list).apply {
border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
}, BorderLayout.CENTER)
var rows = 1
val step = 2
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow",
"pref, $formMargin, pref, $formMargin, pref"
)
panel.add(
FormBuilder.create().layout(layout).padding(EmptyBorder(0, 12, 0, 0))
.add(upBtn).xy(1, rows).apply { rows += step }
.add(downBtn).xy(1, rows).apply { rows += step }
.add(deleteBtn).xy(1, rows).apply { rows += step }
.build(),
BorderLayout.EAST)
panel.border = BorderFactory.createEmptyBorder(
if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0,
12, 12, 12
)
return panel
}
override fun createSouthPanel(): JComponent? {
return null
}
fun open(): List<String> {
isModal = true
isVisible = true
return model.elements().toList()
}
}

View File

@@ -0,0 +1,787 @@
package app.termora.transport
import app.termora.*
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.icons.FlatFileViewDirectoryIcon
import com.formdev.flatlaf.icons.FlatFileViewFileIcon
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.sftp.client.SftpClient
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath
import org.jdesktop.swingx.JXBusyLabel
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Desktop
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.dnd.DnDConstants
import java.awt.dnd.DropTarget
import java.awt.dnd.DropTargetDropEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.io.File
import java.nio.file.*
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
/**
* 文件系统面板
*/
class FileSystemPanel(
private val fileSystem: FileSystem,
private val transportManager: TransportManager,
private val host: Host
) : JPanel(BorderLayout()), Disposable,
FileSystemTransportListener.Provider {
companion object {
private val log = LoggerFactory.getLogger(FileSystemPanel::class.java)
}
private val tableModel = FileSystemTableModel(fileSystem)
private val table = JTable(tableModel)
private val parentBtn = JButton(Icons.up)
private val workdirTextField = OutlineTextField()
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val layeredPane = FileSystemLayeredPane()
private val loadingPanel = LoadingPanel()
private val bookmarkBtn = BookmarkButton()
private val homeBtn = JButton(Icons.homeFolder)
val workdir get() = tableModel.workdir
init {
initView()
initEvents()
}
private fun initView() {
// 设置书签名称
bookmarkBtn.name = "Host.${host.id}.Bookmarks"
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString())
table.autoResizeMode = JTable.AUTO_RESIZE_OFF
table.fillsViewportHeight = true
table.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
)
)
table.setDefaultRenderer(
Any::class.java,
DefaultTableCellRenderer().apply {
horizontalAlignment = SwingConstants.CENTER
}
)
val modifyDateColumn = table.columnModel.getColumn(FileSystemTableModel.COLUMN_LAST_MODIFIED_TIME)
modifyDateColumn.preferredWidth = 130
val nameColumn = table.columnModel.getColumn(FileSystemTableModel.COLUMN_NAME)
nameColumn.preferredWidth = 250
nameColumn.setCellRenderer(object : DefaultTableCellRenderer() {
private val b = BorderFactory.createEmptyBorder(0, 4, 0, 0)
private val d = FlatFileViewDirectoryIcon()
private val f = FlatFileViewFileIcon()
override fun getTableCellRendererComponent(
table: JTable?,
value: Any,
isSelected: Boolean,
hasFocus: Boolean,
row: Int,
column: Int
): Component {
var text = value.toString()
// name
if (value is FileSystemTableModel.CacheablePath) {
text = value.fileName
icon = if (value.isDirectory) d else f
iconTextGap = 4
}
val c = super.getTableCellRendererComponent(table, text, isSelected, hasFocus, row, column)
border = b
return c
}
})
parentBtn.toolTipText = I18n.getString("termora.transport.parent-folder")
val toolbar = FlatToolBar()
toolbar.add(homeBtn)
toolbar.add(Box.createHorizontalStrut(2))
toolbar.add(workdirTextField)
toolbar.add(bookmarkBtn)
toolbar.add(parentBtn)
toolbar.add(JButton(Icons.refresh).apply {
addActionListener { reload() }
toolTipText = I18n.getString("termora.transport.table.contextmenu.refresh")
})
toolbar.border = BorderFactory.createEmptyBorder(4, 2, 4, 2)
val scrollPane = JScrollPane(table)
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
layeredPane.add(scrollPane, JLayeredPane.DEFAULT_LAYER as Any)
layeredPane.add(loadingPanel, JLayeredPane.MODAL_LAYER as Any)
add(toolbar, BorderLayout.NORTH)
add(layeredPane, BorderLayout.CENTER)
}
private fun initEvents() {
homeBtn.addActionListener {
if (tableModel.isLocalFileSystem) {
tableModel.workdir(SystemUtils.USER_HOME)
} else if (fileSystem is SftpFileSystem) {
tableModel.workdir(fileSystem.defaultDir)
}
reload()
}
bookmarkBtn.addActionListener { e ->
if (e.actionCommand.isNullOrBlank()) {
if (bookmarkBtn.isBookmark) {
bookmarkBtn.deleteBookmark(workdir.toString())
} else {
bookmarkBtn.addBookmark(workdir.toString())
}
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
} else if (!loadingPanel.isLoading) {
tableModel.workdir(e.actionCommand)
reload()
}
}
// contextmenu
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isRightMouseButton(e)) {
val r = table.rowAtPoint(e.point)
if (r >= 0 && r < table.rowCount) {
if (!table.isRowSelected(r)) {
table.setRowSelectionInterval(r, r)
}
} else {
table.clearSelection()
}
val rows = table.selectedRows
if (!table.hasFocus()) {
table.requestFocusInWindow()
}
showContextMenu(rows.filter { it != 0 }.toIntArray(), e)
}
}
})
// double click
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val row = table.selectedRow
if (row < 0) return
val path = tableModel.getCacheablePath(row)
if (path.isDirectory) {
openFolder()
} else {
transport(listOf(path))
}
}
}
})
// 本地文件系统不支持本地拖拽进去
if (!tableModel.isLocalFileSystem) {
table.dropTarget = object : DropTarget() {
override fun drop(dtde: DropTargetDropEvent) {
val transportPanel = getTransportPanel() ?: return
val localFileSystemPanel = transportPanel.leftFileSystemTabbed.getFileSystemPanel(0) ?: return
dtde.acceptDrop(DnDConstants.ACTION_COPY)
val files = dtde.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return
val paths = files.filterIsInstance<File>().map { FileSystemTableModel.CacheablePath(it.toPath()) }
for (path in paths) {
if (path.isDirectory) {
Files.walk(path.path).use {
for (e in it) {
transportPanel.transport(
sourceWorkdir = path.path.parent,
targetWorkdir = workdir,
isSourceDirectory = e.isDirectory(),
sourcePath = e,
sourceHolder = localFileSystemPanel,
targetHolder = this@FileSystemPanel
)
}
}
} else {
transportPanel.transport(
sourceWorkdir = localFileSystemPanel.workdir,
targetWorkdir = workdir,
isSourceDirectory = false,
sourcePath = path.path,
sourceHolder = localFileSystemPanel,
targetHolder = this@FileSystemPanel
)
}
}
}
}.apply {
this.defaultActions = DnDConstants.ACTION_COPY
}
}
// 工作目录变动
tableModel.addPropertyChangeListener {
if (it.propertyName == "workdir") {
workdirTextField.text = tableModel.workdir.toAbsolutePath().toString()
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdirTextField.text)
}
}
// 修改工作目录
workdirTextField.addActionListener {
val text = workdirTextField.text
if (text.isBlank()) {
workdirTextField.text = tableModel.workdir.toAbsolutePath().toString()
reload()
} else {
val path = fileSystem.getPath(workdirTextField.text)
if (Files.exists(path)) {
tableModel.workdir(path)
reload()
} else {
workdirTextField.outline = "error"
}
}
}
// 返回上一级目录
parentBtn.addActionListener {
if (tableModel.rowCount > 0) {
val path = tableModel.getCacheablePath(0)
if (path.isDirectory && path.fileName == "..") {
tableModel.workdir(path.path)
reload()
}
}
}
}
@OptIn(DelicateCoroutinesApi::class)
fun reload() {
if (loadingPanel.isLoading) {
return
}
GlobalScope.launch(Dispatchers.IO) {
runCatching { suspendReload() }
}
}
private suspend fun suspendReload() {
if (loadingPanel.isLoading) {
return
}
withContext(Dispatchers.Swing) {
// reload
loadingPanel.start()
workdirTextField.text = workdir.toString()
}
try {
tableModel.reload()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
return
} finally {
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
}
withContext(Dispatchers.Swing) {
table.scrollRectToVisible(table.getCellRect(0, 0, true))
}
}
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listenerList.add(FileSystemTransportListener::class.java, listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listenerList.remove(FileSystemTransportListener::class.java, listener)
}
private fun openFolder() {
val row = table.selectedRow
if (row < 0) return
val path = tableModel.getCacheablePath(row)
if (path.isDirectory) {
tableModel.workdir(path.path)
reload()
}
}
private fun canTransfer(): Boolean {
return getTransportPanel()?.getTargetFileSystemPanel(this) != null
}
private fun getTransportPanel(): TransportPanel? {
var p = this as Component?
while (p != null) {
if (p is TransportPanel) {
return p
}
p = p.parent
}
return null
}
private fun showContextMenu(rows: IntArray, event: MouseEvent) {
val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
// 创建文件夹
newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder")).addActionListener {
newFolderOrFile(file = false)
}
// 创建文件
newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file")).addActionListener {
newFolderOrFile(file = true)
}
// 传输
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
transfer.addActionListener {
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
if (paths.isNotEmpty()) {
transport(paths)
}
}
popupMenu.addSeparator()
// 复制路径
val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path"))
copyPath.addActionListener {
val row = table.selectedRow
if (row > 0) {
toolkit.systemClipboard.setContents(
StringSelection(
tableModel.getPath(row).toAbsolutePath().toString()
), null
)
}
}
// 如果是本地,那么支持打开本地路径
if (tableModel.isLocalFileSystem) {
if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
popupMenu.add(
I18n.getString(
"termora.transport.table.contextmenu.open-in-folder",
if (SystemInfo.isMacOS) I18n.getString("termora.finder")
else if (SystemInfo.isWindows) I18n.getString("termora.explorer")
else I18n.getString("termora.folder")
)
).addActionListener {
val row = table.selectedRow
if (row > 0) {
Desktop.getDesktop().browseFileDirectory(tableModel.getPath(row).toFile())
}
}
}
}
popupMenu.addSeparator()
// 重命名
val rename = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.rename"))
rename.addActionListener { renamePath(tableModel.getPath(rows.last())) }
// 删除
val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete")).apply {
addActionListener { deletePaths(rows) }
}
// rm -rf
val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.errorIntroduction)).apply {
addActionListener {
deletePaths(rows, true)
}
}
// 只有 SFTP 可以
if (fileSystem !is SftpFileSystem) {
rmrf.isVisible = false
}
// 修改权限
val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
permission.isEnabled = false
// 如果是本地系统文件,那么不允许修改权限,用户应该自己修改
if (!tableModel.isLocalFileSystem && rows.isNotEmpty()) {
permission.isEnabled = true
permission.addActionListener { changePermissions(tableModel.getCacheablePath(rows.last())) }
}
popupMenu.addSeparator()
// 刷新
popupMenu.add(I18n.getString("termora.transport.table.contextmenu.refresh"))
.apply { addActionListener { reload() } }
popupMenu.addSeparator()
// 新建
popupMenu.add(newMenu)
if (rows.isEmpty()) {
transfer.isEnabled = false
rename.isEnabled = false
delete.isEnabled = false
rmrf.isEnabled = false
copyPath.isEnabled = false
permission.isEnabled = false
} else {
transfer.isEnabled = canTransfer()
}
popupMenu.show(table, event.x, event.y)
}
@OptIn(DelicateCoroutinesApi::class)
private fun renamePath(path: Path) {
val fileName = path.fileName.toString()
val text = InputDialog(
owner = owner,
title = fileName,
text = fileName,
).getText() ?: return
if (fileName == text) return
loadingPanel.stop()
GlobalScope.launch(Dispatchers.IO) {
val result = runCatching {
Files.move(path, path.parent.resolve(text), StandardCopyOption.ATOMIC_MOVE)
}.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, it.message ?: ExceptionUtils.getRootCauseMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
if (result.isSuccess) {
reload()
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun newFolderOrFile(file: Boolean = false) {
val title = I18n.getString("termora.transport.table.contextmenu.new.${if (file) "file" else "folder"}")
val text = InputDialog(
owner = owner,
title = title,
).getText() ?: return
if (text.isEmpty()) return
loadingPanel.stop()
GlobalScope.launch(Dispatchers.IO) {
val result = runCatching {
val path = workdir.resolve(text)
if (path.exists()) {
throw IllegalStateException(I18n.getString("termora.transport.file-already-exists", text))
}
if (file)
Files.createFile(path)
else
Files.createDirectories(path)
}.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, it.message ?: ExceptionUtils.getRootCauseMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
if (result.isSuccess) {
reload()
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun deletePaths(rows: IntArray, rm: Boolean = false) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"),
messageType = if (rm) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE
) != JOptionPane.YES_OPTION
) {
return
}
loadingPanel.start()
GlobalScope.launch(Dispatchers.IO) {
runCatching {
for (row in rows.sortedDescending()) {
try {
deleteRecursively(tableModel.getPath(row), rm)
withContext(Dispatchers.Swing) {
tableModel.removeRow(row)
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}.onFailure {
if (log.isErrorEnabled) {
log.error(it.message, it)
}
}
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
}
}
private fun deleteRecursively(path: Path, rm: Boolean) {
if (path.fileSystem == FileSystems.getDefault()) {
FileUtils.deleteDirectory(path.toFile())
} else if (path.fileSystem is SftpFileSystem) {
val fs = path.fileSystem as SftpFileSystem
if (rm) {
fs.session.executeRemoteCommand("rm -rf '$path'")
} else {
fs.client.use {
deleteRecursivelySFTP(path as SftpPath, it)
}
}
}
}
/**
* 优化删除效率,采用一个连接
*/
private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) {
val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory()
if (isDirectory) {
for (e in sftpClient.readDir(path.toString())) {
if (e.filename == ".." || e.filename == ".") {
continue
}
if (e.attributes.isDirectory) {
deleteRecursivelySFTP(path.resolve(e.filename), sftpClient)
} else {
sftpClient.remove(path.resolve(e.filename).toString())
}
}
sftpClient.rmdir(path.toString())
} else {
sftpClient.remove(path.toString())
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun changePermissions(cacheablePath: FileSystemTableModel.CacheablePath) {
val dialog = PosixFilePermissionDialog(
SwingUtilities.getWindowAncestor(this),
cacheablePath.posixFilePermissions
)
val permissions = dialog.open() ?: return
loadingPanel.start()
GlobalScope.launch(Dispatchers.IO) {
val result = runCatching {
Files.setPosixFilePermissions(cacheablePath.path, permissions)
}
result.onFailure {
if (log.isErrorEnabled) {
log.error(it.message, it)
}
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
SwingUtilities.getWindowAncestor(this@FileSystemPanel), ExceptionUtils.getRootCauseMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
if (result.isSuccess) {
reload()
}
}
}
private fun transport(paths: List<FileSystemTableModel.CacheablePath>) {
assertEventDispatchThread()
if (!canTransfer()) {
return
}
loadingPanel.start()
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
runCatching { doTransport(paths) }
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
}
}
private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) {
if (paths.isEmpty()) return
val listeners = listenerList.getListeners(FileSystemTransportListener::class.java)
if (listeners.isEmpty()) return
// 收集数据
for (e in paths) {
if (!e.isDirectory) {
withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, false, e.path) }
}
continue
}
withContext(Dispatchers.IO) {
Files.walk(e.path).use { walkPaths ->
for (path in walkPaths) {
if (path is SftpPath) {
val isDirectory = if (path.attributes != null)
path.attributes.isDirectory else path.isDirectory()
withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) }
}
} else {
val isDirectory = path.isDirectory()
withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) }
}
}
}
}
}
}
}
private class LoadingPanel : JPanel() {
private val busyLabel = JXBusyLabel()
val isLoading get() = busyLabel.isBusy
init {
isOpaque = false
border = BorderFactory.createEmptyBorder(50, 0, 0, 0)
add(busyLabel, BorderLayout.CENTER)
addMouseListener(object : MouseAdapter() {})
isVisible = false
}
fun start() {
busyLabel.isBusy = true
isVisible = true
}
fun stop() {
busyLabel.isBusy = false
isVisible = false
}
}
private class FileSystemLayeredPane : JLayeredPane() {
override fun doLayout() {
synchronized(treeLock) {
val w = width
val h = height
for (c in components) {
if (c is JScrollPane) {
c.setBounds(0, 0, w, h)
} else if (c is LoadingPanel) {
c.setBounds(0, 0, w, h)
}
}
}
}
}
}

View File

@@ -0,0 +1,205 @@
package app.termora.transport
import app.termora.*
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
import java.awt.Point
import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.*
import kotlin.math.max
class FileSystemTabbed(
private val transportManager: TransportManager,
private val isLeft: Boolean = false
) : FlatTabbedPane(), FileSystemTransportListener.Provider, Disposable {
private val addBtn = JButton(Icons.add)
private val listeners = mutableListOf<FileSystemTransportListener>()
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) {
addFileSystemTransportProvider(
I18n.getString("termora.transport.local"),
FileSystemPanel(
FileSystems.getDefault(),
transportManager,
host = Host(
id = "local",
name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local,
)
).apply { reload() }
)
setTabClosable(0, false)
} else {
addFileSystemTransportProvider(
I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel(transportManager)
)
}
}
private fun initEvents() {
addBtn.addActionListener {
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this))
dialog.location = Point(
addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2,
addBtn.locationOnScreen.y + max(tabHeight, addBtn.height)
)
dialog.isVisible = true
for (host in dialog.hosts) {
val panel = SftpFileSystemPanel(transportManager, host)
addFileSystemTransportProvider(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) {
addFileSystemTransportProvider(
I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel(transportManager)
)
}
}
}
fun addFileSystemTransportProvider(title: String, provider: FileSystemTransportListener.Provider) {
if (provider !is JComponent) {
throw IllegalArgumentException("Provider is not an JComponent")
}
provider.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
}
})
// 修改 Tab名称
provider.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) == provider) {
setTitleAt(i, name)
break
}
}
}
}
addTab(title, provider)
if (tabCount > 0)
selectedIndex = tabCount - 1
}
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 addFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.add(listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.remove(listener)
}
override fun dispose() {
while (tabCount > 0) {
val c = getComponentAt(0)
if (c is Disposable) {
Disposer.dispose(c)
}
super.removeTabAt(0)
}
}
}

View File

@@ -0,0 +1,232 @@
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 }
override fun getRowCount(): Int {
return files?.size ?: 0
}
override fun getValueAt(row: Int, column: Int): Any {
val path = files?.get(row) ?: return StringUtils.EMPTY
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 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 files?.get(index) ?: throw IndexOutOfBoundsException()
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
override fun removeRow(row: Int) {
files?.removeAt(row) ?: return
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)
}
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 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: Boolean
get() = attributes.isDirectory
override val fileSize: Long
get() = attributes.size
override val lastModifiedTime: Long
by lazy { attributes.modifyTime.toMillis() }
override val owner: String
get() = attributes.owner
override val posixFilePermissions: Set<PosixFilePermission>
by lazy { fromSftpPermissions(attributes.permissions) }
}
}

View File

@@ -0,0 +1,19 @@
package app.termora.transport
import java.nio.file.Path
import java.util.*
interface FileSystemTransportListener : EventListener {
/**
* @param workdir 当前工作目录
* @param isDirectory 要传输的是否是文件夹
* @param path 要传输的文件/文件夹
*/
fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path)
interface Provider {
fun addFileSystemTransportListener(listener: FileSystemTransportListener)
fun removeFileSystemTransportListener(listener: FileSystemTransportListener)
}
}

View File

@@ -0,0 +1,162 @@
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.show(table, event.x, event.y)
}
}

View File

@@ -0,0 +1,123 @@
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
return when (column) {
COLUMN_NAME -> " ${transport.name}"
COLUMN_STATUS -> formatStatus(transport.state)
COLUMN_PROGRESS -> String.format("%.0f%%", transport.progress * 100.0)
// 大小
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
}
}
private fun formatStatus(state: TransportState): String {
return when (state) {
TransportState.Transporting -> I18n.getString("termora.transport.sftp.status.transporting")
TransportState.Waiting -> I18n.getString("termora.transport.sftp.status.waiting")
TransportState.Done -> I18n.getString("termora.transport.sftp.status.done")
TransportState.Failed -> I18n.getString("termora.transport.sftp.status.failed")
TransportState.Cancelled -> I18n.getString("termora.transport.sftp.status.cancelled")
}
}
override fun getColumnName(column: Int): String {
return when (column) {
COLUMN_NAME -> I18n.getString("termora.transport.jobs.table.name")
COLUMN_STATUS -> I18n.getString("termora.transport.jobs.table.status")
COLUMN_PROGRESS -> I18n.getString("termora.transport.jobs.table.progress")
COLUMN_SIZE -> I18n.getString("termora.transport.jobs.table.size")
COLUMN_SOURCE_PATH -> I18n.getString("termora.transport.jobs.table.source-path")
COLUMN_TARGET_PATH -> I18n.getString("termora.transport.jobs.table.target-path")
COLUMN_SPEED -> I18n.getString("termora.transport.jobs.table.speed")
COLUMN_ESTIMATED_TIME -> I18n.getString("termora.transport.jobs.table.estimated-time")
else -> StringUtils.EMPTY
}
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
}

View File

@@ -0,0 +1,148 @@
package app.termora.transport
import app.termora.DialogWrapper
import app.termora.I18n
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import java.awt.Dimension
import java.awt.Window
import java.nio.file.attribute.PosixFilePermission
import javax.swing.*
import kotlin.math.max
class PosixFilePermissionDialog(
owner: Window,
private val permissions: Set<PosixFilePermission>
) : DialogWrapper(owner) {
private val ownerRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
private val ownerWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
private val ownerExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
private val groupRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
private val groupWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
private val groupExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
private val otherRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
private val otherWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
private val otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
private var isCancelled = false
init {
isModal = true
isResizable = false
controlsVisible = false
title = I18n.getString("termora.transport.permissions")
initView()
init()
pack()
size = Dimension(max(size.width, UIManager.getInt("Dialog.width") - 300), size.height)
setLocationRelativeTo(null)
}
private fun initView() {
ownerRead.isSelected = permissions.contains(PosixFilePermission.OWNER_READ)
ownerWrite.isSelected = permissions.contains(PosixFilePermission.OWNER_WRITE)
ownerExecute.isSelected = permissions.contains(PosixFilePermission.OWNER_EXECUTE)
groupRead.isSelected = permissions.contains(PosixFilePermission.GROUP_READ)
groupWrite.isSelected = permissions.contains(PosixFilePermission.GROUP_WRITE)
groupExecute.isSelected = permissions.contains(PosixFilePermission.GROUP_EXECUTE)
otherRead.isSelected = permissions.contains(PosixFilePermission.OTHERS_READ)
otherWrite.isSelected = permissions.contains(PosixFilePermission.OTHERS_WRITE)
otherExecute.isSelected = permissions.contains(PosixFilePermission.OTHERS_EXECUTE)
ownerRead.isFocusable = false
ownerWrite.isFocusable = false
ownerExecute.isFocusable = false
groupRead.isFocusable = false
groupWrite.isFocusable = false
groupExecute.isFocusable = false
otherRead.isFocusable = false
otherWrite.isFocusable = false
otherExecute.isFocusable = false
}
override fun createCenterPanel(): JComponent {
val formMargin = "7dlu"
val layout = FormLayout(
"default:grow, $formMargin, default:grow, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref"
)
val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin")
.layout(layout).debug(true)
builder.add("${I18n.getString("termora.transport.permissions.file-folder-permissions")}:").xyw(1, 1, 5)
val ownerBox = Box.createVerticalBox()
ownerBox.add(ownerRead)
ownerBox.add(ownerWrite)
ownerBox.add(ownerExecute)
ownerBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.owner"))
builder.add(ownerBox).xy(1, 3)
val groupBox = Box.createVerticalBox()
groupBox.add(groupRead)
groupBox.add(groupWrite)
groupBox.add(groupExecute)
groupBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.group"))
builder.add(groupBox).xy(3, 3)
val otherBox = Box.createVerticalBox()
otherBox.add(otherRead)
otherBox.add(otherWrite)
otherBox.add(otherExecute)
otherBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.others"))
builder.add(otherBox).xy(5, 3)
return builder.build()
}
override fun doCancelAction() {
this.isCancelled = true
super.doCancelAction()
}
/**
* @return 返回空表示取消了
*/
fun open(): Set<PosixFilePermission>? {
isModal = true
isVisible = true
if (isCancelled) {
return null
}
val permissions = mutableSetOf<PosixFilePermission>()
if (ownerRead.isSelected) {
permissions.add(PosixFilePermission.OWNER_READ)
}
if (ownerWrite.isSelected) {
permissions.add(PosixFilePermission.OWNER_WRITE)
}
if (ownerExecute.isSelected) {
permissions.add(PosixFilePermission.OWNER_EXECUTE)
}
if (groupRead.isSelected) {
permissions.add(PosixFilePermission.GROUP_READ)
}
if (groupWrite.isSelected) {
permissions.add(PosixFilePermission.GROUP_WRITE)
}
if (groupExecute.isSelected) {
permissions.add(PosixFilePermission.GROUP_EXECUTE)
}
if (otherRead.isSelected) {
permissions.add(PosixFilePermission.OTHERS_READ)
}
if (otherWrite.isSelected) {
permissions.add(PosixFilePermission.OTHERS_WRITE)
}
if (otherExecute.isSelected) {
permissions.add(PosixFilePermission.OTHERS_EXECUTE)
}
return permissions
}
}

View File

@@ -0,0 +1,316 @@
package app.termora.transport
import app.termora.*
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.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.sftp.client.SftpClientFactory
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.jdesktop.swingx.JXBusyLabel
import org.jdesktop.swingx.JXHyperlink
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.event.ActionEvent
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.*
class SftpFileSystemPanel(
private val transportManager: TransportManager,
private var host: Host? = null
) : JPanel(BorderLayout()), Disposable,
FileSystemTransportListener.Provider {
companion object {
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java)
private enum class State {
Initialized,
Connecting,
Connected,
ConnectFailed,
}
}
@Volatile
private var state = State.Initialized
private val cardLayout = CardLayout()
private val cardPanel = JPanel(cardLayout)
private val connectingPanel = ConnectingPanel()
private val selectHostPanel = SelectHostPanel()
private val connectFailedPanel = ConnectFailedPanel()
private val listeners = mutableListOf<FileSystemTransportListener>()
private val isDisposed = AtomicBoolean(false)
private var client: SshClient? = null
private var session: ClientSession? = null
private var fileSystem: SftpFileSystem? = null
var fileSystemPanel: FileSystemPanel? = null
init {
initView()
initEvents()
}
private fun initView() {
cardPanel.add(selectHostPanel, State.Initialized.name)
cardPanel.add(connectingPanel, State.Connecting.name)
cardPanel.add(connectFailedPanel, State.ConnectFailed.name)
cardLayout.show(cardPanel, State.Initialized.name)
add(cardPanel, BorderLayout.CENTER)
}
private fun initEvents() {
}
@OptIn(DelicateCoroutinesApi::class)
fun connect() {
GlobalScope.launch(Dispatchers.IO) {
if (state != State.Connecting) {
state = State.Connecting
withContext(Dispatchers.Swing) {
connectingPanel.start()
cardLayout.show(cardPanel, State.Connecting.name)
}
runCatching { doConnect() }.onFailure {
if (log.isErrorEnabled) {
log.error(it.message, it)
}
withContext(Dispatchers.Swing) {
state = State.ConnectFailed
connectFailedPanel.errorLabel.text = ExceptionUtils.getRootCauseMessage(it)
cardLayout.show(cardPanel, State.ConnectFailed.name)
}
}
withContext(Dispatchers.Swing) {
connectingPanel.stop()
}
}
}
}
private suspend fun doConnect() {
val host = this.host ?: return
closeIO()
try {
val client = SshClients.openClient(host).apply { client = this }
val session = SshClients.openSession(host, client).apply { session = this }
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
session.addCloseFutureListener { onClose() }
} catch (e: Exception) {
closeIO()
throw e
}
if (isDisposed.get()) {
throw IllegalStateException("Closed")
}
val fileSystem = this.fileSystem ?: return
withContext(Dispatchers.Swing) {
state = State.Connected
val fileSystemPanel = FileSystemPanel(fileSystem, transportManager, host)
fileSystemPanel.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(
fileSystemPanel: FileSystemPanel,
workdir: Path,
isDirectory: Boolean,
path: Path
) {
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
}
})
cardPanel.add(fileSystemPanel, State.Connected.name)
cardLayout.show(cardPanel, State.Connected.name)
firePropertyChange("TabName", StringUtils.EMPTY, host.name)
this@SftpFileSystemPanel.fileSystemPanel = fileSystemPanel
// 立即加载
fileSystemPanel.reload()
}
}
private fun onClose() {
if (isDisposed.get()) {
return
}
SwingUtilities.invokeLater {
closeIO()
state = State.ConnectFailed
connectFailedPanel.errorLabel.text = I18n.getString("termora.transport.sftp.closed")
cardLayout.show(cardPanel, State.ConnectFailed.name)
}
}
private fun closeIO() {
val host = host
fileSystemPanel?.let { Disposer.dispose(it) }
fileSystemPanel = null
runCatching { IOUtils.closeQuietly(fileSystem) }
runCatching { IOUtils.closeQuietly(session) }
runCatching { IOUtils.closeQuietly(client) }
if (host != null && log.isInfoEnabled) {
log.info("Sftp ${host.name} is closed")
}
}
override fun dispose() {
if (isDisposed.compareAndSet(false, true)) {
closeIO()
}
}
private class ConnectingPanel : JPanel(BorderLayout()) {
private val busyLabel = JXBusyLabel()
init {
initView()
}
private fun initView() {
val formMargin = "7dlu"
val layout = FormLayout(
"default:grow, pref, default:grow",
"40dlu, pref, $formMargin, pref"
)
val label = JLabel(I18n.getString("termora.transport.sftp.connecting"))
label.horizontalAlignment = SwingConstants.CENTER
busyLabel.horizontalAlignment = SwingConstants.CENTER
busyLabel.verticalAlignment = SwingConstants.CENTER
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(busyLabel).xy(2, 2, "fill, center")
builder.add(label).xy(2, 4)
add(builder.build(), BorderLayout.CENTER)
}
fun start() {
busyLabel.isBusy = true
}
fun stop() {
busyLabel.isBusy = false
}
}
private inner class ConnectFailedPanel : JPanel(BorderLayout()) {
val errorLabel = JLabel()
init {
initView()
}
private fun initView() {
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow, pref, default:grow",
"40dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
errorLabel.horizontalAlignment = SwingConstants.CENTER
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(FlatOptionPaneErrorIcon()).xy(2, 2)
builder.add(errorLabel).xyw(1, 4, 3, "fill, center")
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.retry")) {
override fun actionPerformed(e: ActionEvent) {
connect()
}
}).apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
isFocusable = false
}).xy(2, 6)
builder.add(JXHyperlink(object :
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)
cardLayout.show(cardPanel, State.Initialized.name)
}
}).apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
isFocusable = false
}).xy(2, 8)
add(builder.build(), BorderLayout.CENTER)
}
}
private inner class SelectHostPanel : JPanel(BorderLayout()) {
init {
initView()
}
private fun initView() {
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow, pref, default:grow",
"40dlu, pref, $formMargin, pref, $formMargin, pref"
)
val errorInfo = JLabel(I18n.getString("termora.transport.sftp.connect-a-host"))
errorInfo.horizontalAlignment = SwingConstants.CENTER
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(FlatOptionPaneInformationIcon()).xy(2, 2)
builder.add(errorInfo).xyw(1, 4, 3, "fill, center")
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.select-host")) {
override fun actionPerformed(e: ActionEvent) {
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel))
dialog.allowMulti = false
dialog.setLocationRelativeTo(this@SelectHostPanel)
dialog.isVisible = true
this@SftpFileSystemPanel.host = dialog.hosts.firstOrNull() ?: return
connect()
}
}).apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
isFocusable = false
}).xy(2, 6)
add(builder.build(), BorderLayout.CENTER)
}
}
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.add(listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.remove(listener)
}
}

View File

@@ -0,0 +1,274 @@
package app.termora.transport
import app.termora.Disposable
import org.apache.commons.io.IOUtils
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,
) : Disposable, Runnable {
private val listeners = ArrayList<TransportListener>()
@Volatile
var state = TransportState.Waiting
protected set(value) {
field = value
listeners.forEach { it.onTransportChanged(this) }
}
// 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 clientSession.hostConfigEntry.host
}
}
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,
) : Transport(
name, source, target, sourceHolder, targetHolder,
), 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
} finally {
counter.clear()
}
}
override fun stop() {
// 如果在传输中,那么直接关闭流
if (state == TransportState.Transporting) {
runCatching { IOUtils.closeQuietly(input) }
runCatching { IOUtils.closeQuietly(output) }
}
super.stop()
counter.clear()
}
private fun doTransport() {
size = Files.size(source)
try {
Util.copyStream(
input,
output,
Util.DEFAULT_COPY_BUFFER_SIZE * 8,
size,
this
)
} finally {
IOUtils.closeQuietly(input, output)
}
}
override fun bytesTransferred(event: CopyStreamEvent?) {
throw UnsupportedOperationException()
}
override fun bytesTransferred(totalBytesTransferred: Long, bytesTransferred: Int, streamSize: Long) {
if (state == TransportState.Cancelled) {
throw IllegalStateException("$name has already been cancelled")
}
val now = System.currentTimeMillis()
val progress = totalBytesTransferred * 1.0 / streamSize
counter.addBytes(bytesTransferred.toLong(), now)
if (now - lastVisitTime < 750) {
if (progress < 1.0) {
return
}
}
this.transferredSize = totalBytesTransferred
this.progress = progress
lastVisitTime = now
}
}
/**
* 创建文件夹
*/
class DirectoryTransport(
name: String, source: Path, target: Path,
sourceHolder: Disposable,
targetHolder: Disposable,
) : Transport(name, source, target, sourceHolder, targetHolder) {
companion object {
private val log = LoggerFactory.getLogger(DirectoryTransport::class.java)
}
override fun run() {
try {
super.run()
if (!target.exists()) {
Files.createDirectory(target)
}
state = TransportState.Done
} catch (e: FileAlreadyExistsException) {
if (log.isWarnEnabled) {
log.warn("Directory $name already exists")
}
state = TransportState.Done
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
state = TransportState.Failed
}
}
}

View File

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

View File

@@ -0,0 +1,129 @@
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) {
continue
}
transport.run()
// 成功之后 删除
if (transport.state == TransportState.Done) {
// remove
removeTransport(transport)
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
override fun dispose() {
transports.clear()
coroutineScope.cancel()
}
}

View File

@@ -0,0 +1,194 @@
package app.termora.transport
import app.termora.Disposable
import app.termora.Disposer
import app.termora.DynamicColor
import app.termora.assertEventDispatchThread
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 {
companion object {
private val log = LoggerFactory.getLogger(TransportPanel::class.java)
}
val transportManager = TransportManager()
val leftFileSystemTabbed = FileSystemTabbed(transportManager, true)
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)
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()
}
}
}
}
})
leftFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
val target = rightFileSystemTabbed.getSelectedFileSystemPanel() ?: return
transport(
fileSystemPanel.workdir, target.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = fileSystemPanel,
targetHolder = target,
)
}
})
rightFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
val target = leftFileSystemTabbed.getSelectedFileSystemPanel() ?: return
transport(
fileSystemPanel.workdir, target.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = fileSystemPanel,
targetHolder = target,
)
}
})
}
fun getTargetFileSystemPanel(fileSystemPanel: FileSystemPanel): FileSystemPanel? {
assertEventDispatchThread()
for (i in 0 until leftFileSystemTabbed.tabCount) {
if (leftFileSystemTabbed.getFileSystemPanel(i) == fileSystemPanel) {
return rightFileSystemTabbed.getSelectedFileSystemPanel()
}
}
for (i in 0 until rightFileSystemTabbed.tabCount) {
if (rightFileSystemTabbed.getFileSystemPanel(i) == fileSystemPanel) {
return leftFileSystemTabbed.getSelectedFileSystemPanel()
}
}
return null
}
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")
}
}
}

View File

@@ -8,6 +8,9 @@ termora.remove=Delete
termora.yes=Yes
termora.no=No
termora.date-format=MM/dd/yyyy hh:mm:ss a
termora.finder=Finder
termora.folder=Folder
termora.explorer=Explorer
# update
termora.update.title=New version
@@ -106,7 +109,7 @@ termora.welcome.contextmenu.rename=Rename
termora.welcome.contextmenu.expand-all=Expand all
termora.welcome.contextmenu.collapse-all=Collapse all
termora.welcome.contextmenu.new=New
termora.welcome.contextmenu.new.folder=Folder
termora.welcome.contextmenu.new.folder=${termora.folder}
termora.welcome.contextmenu.new.host=Host
termora.welcome.contextmenu.new.folder.name=New Folder
termora.welcome.contextmenu.property=Properties
@@ -187,12 +190,77 @@ termora.macro.playback=Playback
termora.macro.manager=Manage Macros
termora.macro.run=Run
# Tools
termora.tools.multiple=Send commands to multiple sessions
# Transport
termora.transport.local=Local
termora.transport.parent-folder=Parent Folder
termora.transport.file-already-exists=The file {0} already exists
termora.transport.bookmarks=Bookmarks Manager
termora.transport.bookmarks.up=Up
termora.transport.bookmarks.down=Down
termora.transport.table.filename=Filename
termora.transport.table.type=Type
termora.transport.table.type.folder=${termora.welcome.contextmenu.new.folder}
termora.transport.table.size=Size
termora.transport.table.modified-time=Modified
termora.transport.table.permissions=Permissions
termora.transport.table.owner=Owner
# contextmenu
termora.transport.table.contextmenu.transfer=Transfer
termora.transport.table.contextmenu.copy-path=Copy Path
termora.transport.table.contextmenu.open-in-folder=Open in {0}
termora.transport.table.contextmenu.rename=${termora.welcome.contextmenu.rename}
termora.transport.table.contextmenu.delete=${termora.remove}
termora.transport.table.contextmenu.delete-warning=If the folder is too large, deleting it may take some time
termora.transport.table.contextmenu.rm-warning=Using the rm -rf command to delete a folder is very dangerous
termora.transport.table.contextmenu.change-permissions=Change Permissions...
termora.transport.table.contextmenu.refresh=Refresh
termora.transport.table.contextmenu.new=${termora.welcome.contextmenu.new}
termora.transport.table.contextmenu.new.folder=${termora.welcome.contextmenu.new.folder.name}
termora.transport.table.contextmenu.new.file=New File
# Permission
termora.transport.permissions=Change Permissions
termora.transport.permissions.file-folder-permissions=File/Folder Permissions
termora.transport.permissions.read=Read
termora.transport.permissions.write=Write
termora.transport.permissions.execute=Execute
termora.transport.permissions.owner=Owner
termora.transport.permissions.group=Group
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.status.waiting=Waiting
termora.transport.sftp.status.done=Done
termora.transport.sftp.status.failed=Failed
termora.transport.sftp.status.cancelled=Cancelled
# transport job
termora.transport.jobs.table.name=Name
termora.transport.jobs.table.status=Status
termora.transport.jobs.table.progress=Progress
termora.transport.jobs.table.size=Size
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.contextmenu.delete=${termora.remove}
termora.transport.jobs.contextmenu.delete-all=Delete All
# Terminal
termora.terminal.size=Size: {0} x {1}

View File

@@ -7,6 +7,9 @@ termora.remove=删除
termora.yes=
termora.no=
termora.date-format=yyyy-MM-dd HH:mm:ss
termora.finder=访达
termora.folder=文件夹
termora.explorer=文件管理器
# update
termora.update.title=新版本
@@ -184,6 +187,70 @@ termora.macro.manager=管理宏
termora.macro.run=运行
# Transport
termora.transport.local=本机
termora.transport.parent-folder=父文件夹
termora.transport.file-already-exists=文件 {0} 已存在
termora.transport.bookmarks=书签管理
termora.transport.bookmarks.up=上移
termora.transport.bookmarks.down=下移
termora.transport.table.filename=文件名
termora.transport.table.type=类型
termora.transport.table.size=大小
termora.transport.table.modified-time=修改时间
termora.transport.table.permissions=权限
termora.transport.table.owner=所有者
# contextmenu
termora.transport.table.contextmenu.transfer=传输
termora.transport.table.contextmenu.copy-path=复制路径
termora.transport.table.contextmenu.open-in-folder=在{0}中打开
termora.transport.table.contextmenu.change-permissions=更改权限...
termora.transport.table.contextmenu.refresh=刷新
termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件
termora.transport.table.contextmenu.delete-warning=如果文件夹太大,删除可能需要耗费一定时间
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.status.transporting=传输中
termora.transport.sftp.status.waiting=等待中
termora.transport.sftp.status.done=已完成
termora.transport.sftp.status.failed=已失败
termora.transport.sftp.status.cancelled=已取消
# Permission
termora.transport.permissions=更改权限
termora.transport.permissions.file-folder-permissions=文件/文件夹权限
termora.transport.permissions.read=读取
termora.transport.permissions.write=写入
termora.transport.permissions.execute=执行
termora.transport.permissions.owner=所有者
termora.transport.permissions.group=
termora.transport.permissions.others=其他
# transport job
termora.transport.jobs.table.name=名称
termora.transport.jobs.table.status=状态
termora.transport.jobs.table.progress=进度
termora.transport.jobs.table.size=大小
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.contextmenu.delete-all=删除所有
termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已复制

View File

@@ -6,6 +6,9 @@ termora.remove=刪除
termora.yes=
termora.no=
termora.date-format=yyyy/MM/dd HH:mm:ss
termora.finder=訪達
termora.folder=資料夾
termora.explorer=檔案管理器
# update
termora.update.title=新版本
@@ -95,7 +98,7 @@ termora.welcome.contextmenu.rename=重新命名
termora.welcome.contextmenu.expand-all=展開全部
termora.welcome.contextmenu.collapse-all=全部收縮
termora.welcome.contextmenu.new=新建
termora.welcome.contextmenu.new.folder=資料夾
termora.welcome.contextmenu.new.folder=${termora.folder}
termora.welcome.contextmenu.new.host=主機
termora.welcome.contextmenu.new.folder.name=新建資料夾
termora.welcome.contextmenu.property=屬性
@@ -177,6 +180,57 @@ termora.macro.playback=回放
termora.macro.manager=管理宏
termora.macro.run=運行
# Transport
termora.transport.local=本機
termora.transport.parent-folder=父資料夾
termora.transport.file-already-exists=檔案 {0} 已存在
termora.transport.bookmarks=書籤管理
termora.transport.bookmarks.up=上移
termora.transport.bookmarks.down=下移
termora.transport.table.filename=檔名
termora.transport.table.type=類型
termora.transport.table.size=大小
termora.transport.table.modified-time=修改時間
termora.transport.table.permissions=權限
termora.transport.table.owner=所有者
# contextmenu
termora.transport.table.contextmenu.transfer=傳輸
termora.transport.table.contextmenu.copy-path=複製路徑
termora.transport.table.contextmenu.open-in-folder=在{0}中打開
termora.transport.table.contextmenu.change-permissions=更改權限...
termora.transport.table.contextmenu.refresh=刷新
termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件
termora.transport.table.contextmenu.delete-warning=如果資料夾太大,刪除可能需要耗費一定時間
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.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=名稱
termora.transport.jobs.table.status=狀態
termora.transport.jobs.table.progress=進度
termora.transport.jobs.table.size=大小
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.contextmenu.delete-all=刪除所有
termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已複製

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z" stroke="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@@ -0,0 +1,6 @@
<!-- 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="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z"
stroke="#6C707E"/>
<path d="M1.28258 1.98958L1.98969 1.28247L14.7176 14.0104L14.0105 14.7175L1.28258 1.98958Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 639 B

View File

@@ -0,0 +1,6 @@
<!-- 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="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z" stroke="#CED0D6"/>
<path d="M1.28258 1.98958L1.98969 1.28247L14.7176 14.0104L14.0105 14.7175L1.28258 1.98958Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z" stroke="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 3.25C3.91421 3.25 4.25 2.91421 4.25 2.5C4.25 2.08579 3.91421 1.75 3.5 1.75C3.08579 1.75 2.75 2.08579 2.75 2.5C2.75 2.91421 3.08579 3.25 3.5 3.25Z" fill="#6C707E"/>
<path d="M7.5 2C7.22386 2 7 2.22386 7 2.5C7 2.77614 7.22386 3 7.5 3H13.5C13.7761 3 14 2.77614 14 2.5C14 2.22386 13.7761 2 13.5 2H7.5Z" fill="#6C707E"/>
<path d="M7.5 7C7.22386 7 7 7.22386 7 7.5C7 7.77614 7.22386 8 7.5 8H13.5C13.7761 8 14 7.77614 14 7.5C14 7.22386 13.7761 7 13.5 7H7.5Z" fill="#6C707E"/>
<path d="M7.5 12C7.22386 12 7 12.2239 7 12.5C7 12.7761 7.22386 13 7.5 13H13.5C13.7761 13 14 12.7761 14 12.5C14 12.2239 13.7761 12 13.5 12H7.5Z" fill="#6C707E"/>
<path d="M3.5 8.25C3.91421 8.25 4.25 7.91421 4.25 7.5C4.25 7.08579 3.91421 6.75 3.5 6.75C3.08579 6.75 2.75 7.08579 2.75 7.5C2.75 7.91421 3.08579 8.25 3.5 8.25Z" fill="#6C707E"/>
<path d="M4.25 12.5C4.25 12.9142 3.91421 13.25 3.5 13.25C3.08579 13.25 2.75 12.9142 2.75 12.5C2.75 12.0858 3.08579 11.75 3.5 11.75C3.91421 11.75 4.25 12.0858 4.25 12.5Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 3.25C3.91421 3.25 4.25 2.91421 4.25 2.5C4.25 2.08579 3.91421 1.75 3.5 1.75C3.08579 1.75 2.75 2.08579 2.75 2.5C2.75 2.91421 3.08579 3.25 3.5 3.25Z" fill="#CED0D6"/>
<path d="M7.5 2C7.22386 2 7 2.22386 7 2.5C7 2.77614 7.22386 3 7.5 3H13.5C13.7761 3 14 2.77614 14 2.5C14 2.22386 13.7761 2 13.5 2H7.5Z" fill="#CED0D6"/>
<path d="M7.5 7C7.22386 7 7 7.22386 7 7.5C7 7.77614 7.22386 8 7.5 8H13.5C13.7761 8 14 7.77614 14 7.5C14 7.22386 13.7761 7 13.5 7H7.5Z" fill="#CED0D6"/>
<path d="M7.5 12C7.22386 12 7 12.2239 7 12.5C7 12.7761 7.22386 13 7.5 13H13.5C13.7761 13 14 12.7761 14 12.5C14 12.2239 13.7761 12 13.5 12H7.5Z" fill="#CED0D6"/>
<path d="M3.5 8.25C3.91421 8.25 4.25 7.91421 4.25 7.5C4.25 7.08579 3.91421 6.75 3.5 6.75C3.08579 6.75 2.75 7.08579 2.75 7.5C2.75 7.91421 3.08579 8.25 3.5 8.25Z" fill="#CED0D6"/>
<path d="M4.25 12.5C4.25 12.9142 3.91421 13.25 3.5 13.25C3.08579 13.25 2.75 12.9142 2.75 12.5C2.75 12.0858 3.08579 11.75 3.5 11.75C3.91421 11.75 4.25 12.0858 4.25 12.5Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6.5" fill="#FFF7F7" stroke="#DB3B4B"/>
<path d="M8 4.5L8 8.5" stroke="#DB3B4B" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="8.0002" cy="10.8" r="0.5" fill="#DB3B4B" stroke="#DB3B4B" stroke-width="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6.5" fill="#402929" stroke="#DB5C5C"/>
<path d="M8 4.5L8 8.5" stroke="#DB5C5C" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="8.0002" cy="10.8" r="0.5" fill="#DB5C5C" stroke="#DB5C5C" stroke-width="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.8536 6.85355C15.0488 6.65829 15.0488 6.34171 14.8536 6.14645L11.8536 3.14645C11.6583 2.95118 11.3417 2.95118 11.1464 3.14645L8.14645 6.14645C7.95118 6.34171 7.95118 6.65829 8.14645 6.85355C8.34171 7.04882 8.65829 7.04882 8.85355 6.85355L11 4.70711V11.5C11 11.7761 11.2239 12 11.5 12C11.7761 12 12 11.7761 12 11.5V4.70711L14.1464 6.85355C14.3417 7.04882 14.6583 7.04882 14.8536 6.85355Z" fill="#6C707E"/>
<path d="M7.85355 9.14645C8.04882 9.34171 8.04882 9.65829 7.85355 9.85355L4.85355 12.8536C4.65829 13.0488 4.34171 13.0488 4.14645 12.8536L1.14645 9.85355C0.951184 9.65829 0.951184 9.34171 1.14645 9.14645C1.34171 8.95118 1.65829 8.95118 1.85355 9.14645L4 11.2929L4 4.5C4 4.22386 4.22386 4 4.5 4C4.77614 4 5 4.22386 5 4.5V11.2929L7.14645 9.14645C7.34171 8.95118 7.65829 8.95118 7.85355 9.14645Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.8536 6.85355C15.0488 6.65829 15.0488 6.34171 14.8536 6.14645L11.8536 3.14645C11.6583 2.95118 11.3417 2.95118 11.1464 3.14645L8.14645 6.14645C7.95118 6.34171 7.95118 6.65829 8.14645 6.85355C8.34171 7.04882 8.65829 7.04882 8.85355 6.85355L11 4.70711V11.5C11 11.7761 11.2239 12 11.5 12C11.7761 12 12 11.7761 12 11.5V4.70711L14.1464 6.85355C14.3417 7.04882 14.6583 7.04882 14.8536 6.85355Z" fill="#CED0D6"/>
<path d="M7.85355 9.14645C8.04882 9.34171 8.04882 9.65829 7.85355 9.85355L4.85355 12.8536C4.65829 13.0488 4.34171 13.0488 4.14645 12.8536L1.14645 9.85355C0.951184 9.65829 0.951184 9.34171 1.14645 9.14645C1.34171 8.95118 1.65829 8.95118 1.85355 9.14645L4 11.2929L4 4.5C4 4.22386 4.22386 4 4.5 4C4.77614 4 5 4.22386 5 4.5V11.2929L7.14645 9.14645C7.34171 8.95118 7.65829 8.95118 7.85355 9.14645Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -0,0 +1,7 @@
<!-- 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">
<rect x="2.5" y="1.5" width="11" height="13" rx="1.5" stroke="#6C707E"/>
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#6C707E"/>
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#6C707E"/>
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,7 @@
<!-- 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">
<rect x="2.5" y="1.5" width="11" height="13" rx="1.5" stroke="#CED0D6"/>
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,7 @@
<!-- 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="M2.5 9V8C2.5 4.96243 4.96243 2.5 8 2.5C9.10679 2.5 10.1372 2.82692 11 3.38947" stroke="#6C707E" stroke-linecap="round"/>
<path d="M5 12.6105C5.86278 13.1731 6.89321 13.5 8 13.5C11.0376 13.5 13.5 11.0376 13.5 8V7" stroke="#6C707E" stroke-linecap="round"/>
<path d="M0.49997 7.50027L2.5 9.5L4.49998 7.50023" stroke="#6C707E" stroke-linecap="round"/>
<path d="M11.5 8.49982L13.5 6.5L15.5 8.49982" stroke="#6C707E" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 675 B

View File

@@ -0,0 +1,7 @@
<!-- 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="M2.5 9V8C2.5 4.96243 4.96243 2.5 8 2.5C9.10679 2.5 10.1372 2.82692 11 3.38947" stroke="#CED0D6" stroke-linecap="round"/>
<path d="M5 12.6105C5.86278 13.1731 6.89321 13.5 8 13.5C11.0376 13.5 13.5 11.0376 13.5 8V7" stroke="#CED0D6" stroke-linecap="round"/>
<path d="M0.49997 7.50027L2.5 9.5L4.49998 7.50023" stroke="#CED0D6" stroke-linecap="round"/>
<path d="M11.5 8.49982L13.5 6.5L15.5 8.49982" stroke="#CED0D6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 675 B

View File

@@ -0,0 +1,54 @@
package app.termora
import org.apache.sshd.sftp.client.impl.DefaultSftpClientFactory
import org.testcontainers.containers.GenericContainer
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
class SFTPTest {
private val sftpContainer = GenericContainer("linuxserver/openssh-server")
.withEnv("PUID", "1000")
.withEnv("PGID", "1000")
.withEnv("TZ", "Etc/UTC")
.withEnv("SUDO_ACCESS", "true")
.withEnv("PASSWORD_ACCESS", "true")
.withEnv("USER_NAME", "foo")
.withEnv("USER_PASSWORD", "pass")
.withEnv("SUDO_ACCESS", "true")
.withExposedPorts(2222)
@BeforeTest
fun setup() {
sftpContainer.start()
}
@AfterTest
fun teardown() {
sftpContainer.stop()
}
@Test
fun test() {
val host = Host(
name = sftpContainer.containerName,
protocol = Protocol.SSH,
host = "127.0.0.1",
port = sftpContainer.getMappedPort(2222),
username = "foo",
authentication = Authentication.No.copy(type = AuthenticationType.Password, password = "pass"),
)
val client = SshClients.openClient(host)
val session = SshClients.openSession(host, client)
assertTrue(session.isOpen)
val fileSystem = DefaultSftpClientFactory.INSTANCE.createSftpFileSystem(session)
for (path in Files.list(fileSystem.rootDirectories.first())) {
println(path)
}
}
}