feat: transfer support disconnection and reconnection

This commit is contained in:
hstyi
2025-07-06 16:16:43 +08:00
committed by GitHub
parent b7178a30fb
commit 728671509c
19 changed files with 378 additions and 189 deletions

View File

@@ -22,10 +22,7 @@ import org.apache.commons.lang3.LocaleUtils
import org.apache.commons.lang3.SystemUtils
import org.json.JSONObject
import org.slf4j.LoggerFactory
import java.awt.MenuItem
import java.awt.PopupMenu
import java.awt.SystemTray
import java.awt.TrayIcon
import java.awt.*
import java.awt.desktop.AppReopenedEvent
import java.awt.desktop.AppReopenedListener
import java.awt.desktop.SystemEventListener
@@ -202,6 +199,7 @@ class ApplicationRunner {
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)
UIManager.put(FlatClientProperties.POPUP_FORCE_HEAVY_WEIGHT, true)
UIManager.put("Component.arc", 5)
UIManager.put("TextComponent.arc", UIManager.getInt("Component.arc"))
@@ -237,12 +235,24 @@ class ApplicationRunner {
// Linux 更多的是尖锐风格
if (SystemInfo.isMacOS || SystemInfo.isWindows) {
val selectionInsets = Insets(0, 2, 0, 2)
UIManager.put("Tree.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Tree.selectionInsets", selectionInsets)
UIManager.put("List.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("List.selectionInsets", selectionInsets)
UIManager.put("ComboBox.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("ComboBox.selectionInsets", selectionInsets)
UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Table.selectionInsets", selectionInsets)
UIManager.put("MenuBar.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("MenuBar.selectionInsets", selectionInsets)
UIManager.put("MenuItem.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("MenuItem.selectionInsets", selectionInsets)
}
}

View File

@@ -34,6 +34,7 @@ object Icons {
val empty by lazy { DynamicIcon("icons/empty.svg") }
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") }
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val breakpoint by lazy { DynamicIcon("icons/breakpoint.svg", "icons/breakpoint_dark.svg") }
val softWrap by lazy { DynamicIcon("icons/softWrap.svg", "icons/softWrap_dark.svg") }
val scrollUp by lazy { DynamicIcon("icons/scrollUp.svg", "icons/scrollUp_dark.svg") }
val reformatCode by lazy { DynamicIcon("icons/reformatCode.svg", "icons/reformatCode_dark.svg") }

View File

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

View File

@@ -65,7 +65,9 @@ class DefaultInternalTransferManager(
override fun canTransfer(paths: List<Path>): Boolean {
return paths.isNotEmpty() && target.getWorkdir() != null
val c = target.getWorkdir() ?: return false
if (c.fileSystem.isOpen.not()) return false
return paths.isNotEmpty()
}
override fun addTransfer(
@@ -270,7 +272,8 @@ class DefaultInternalTransferManager(
val isDirectory = pair.second.isDirectory
val path = pair.first
if (isDirectory.not() || mode == TransferMode.Rmrf) {
val transfer = createTransfer(path, workdir.resolve(path.name), isDirectory, StringUtils.EMPTY, mode, action)
val transfer =
createTransfer(path, workdir.resolve(path.name), isDirectory, StringUtils.EMPTY, mode, action)
return if (transferManager.addTransfer(transfer)) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,9 +59,9 @@ import kotlin.io.path.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class TransportPanel(
internal class TransportPanel(
private val transferManager: InternalTransferManager,
var host: Host,
val host: Host,
val loader: TransportSupportLoader,
) : JPanel(BorderLayout()), DataProvider, Disposable, TransportNavigator {
companion object {
@@ -118,9 +118,6 @@ class TransportPanel(
private val disposed = AtomicBoolean(false)
private val futures = Collections.synchronizedSet(mutableSetOf<Future<*>>())
private val _fileSystem by lazy { getSupport().fileSystem }
private val defaultPath by lazy { getSupport().path }
/**
* 工作目录
@@ -154,7 +151,7 @@ class TransportPanel(
toolbar.add(prevBtn)
toolbar.add(homeBtn)
toolbar.add(nextBtn)
toolbar.add(TransportNavigationPanel(loader, this))
toolbar.add(TransportNavigationPanel(this))
toolbar.add(bookmarkBtn)
toolbar.add(parentBtn)
toolbar.add(eyeBtn)
@@ -235,7 +232,7 @@ class TransportPanel(
Disposer.register(this, editTransferListener)
refreshBtn.addActionListener { reload() }
refreshBtn.addActionListener { reload(requestFocus = true) }
prevBtn.addActionListener { navigator.back() }
nextBtn.addActionListener { navigator.forward() }
@@ -243,7 +240,7 @@ class TransportPanel(
parentBtn.addActionListener(createSmartAction(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (hasParent.not()) return
navigator.navigateTo(model.getPath(0))
reload(newPath = model.getPath(0).absolutePathString(), requestFocus = true)
}
}))
@@ -258,14 +255,16 @@ class TransportPanel(
}
bookmarkBtn.isBookmark = bookmarkBtn.isBookmark.not()
} else {
navigateTo(_fileSystem.getPath(e.actionCommand))
navigateTo(e.actionCommand)
}
}
}))
homeBtn.addActionListener(createSmartAction(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
navigator.navigateTo(_fileSystem.getPath(defaultPath))
if (loader.isLoaded()) {
navigator.navigateTo(loader.getSyncTransportSupport().getDefaultPath().absolutePathString())
}
}
}))
@@ -273,7 +272,7 @@ class TransportPanel(
override fun actionPerformed(e: ActionEvent) {
showHiddenFiles = showHiddenFiles.not()
eyeBtn.icon = if (showHiddenFiles) Icons.eye else Icons.eyeClose
reload()
reload(requestFocus = true)
}
}))
@@ -289,8 +288,11 @@ class TransportPanel(
transferManager.addTransferListener(object : TransferListener {
override fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State) {
if (state != TransferTreeTableNode.State.Done && state != TransferTreeTableNode.State.Failed) return
if (transfer.target().fileSystem != _fileSystem) return
if (transfer.target() == workdir || transfer.target().parent == workdir) {
val target = transfer.target()
if (loader.isLoaded()) {
if (target.fileSystem != loader.getSyncTransportSupport().getFileSystem()) return
}
if (target.pathString == workdir?.pathString || target.parent.pathString == workdir?.pathString) {
reload(requestFocus = false)
}
}
@@ -362,7 +364,7 @@ class TransportPanel(
undoManager.addEdit(object : AbstractUndoableEdit() {
override fun undo() {
super.undo()
if (navigator.navigateTo(oldValue)) {
if (navigator.reload(newPath = oldValue.absolutePathString(), requestFocus = true)) {
undoOrRedo = true
undoOrRedoPath = oldValue
}
@@ -370,7 +372,7 @@ class TransportPanel(
override fun redo() {
super.redo()
if (navigator.navigateTo(newValue)) {
if (navigator.reload(newPath = newValue.absolutePathString(), requestFocus = true)) {
undoOrRedo = true
undoOrRedoPath = newValue
}
@@ -431,10 +433,10 @@ class TransportPanel(
if (attributes.isDirectory) {
enterSelectionFolder()
} else {
transferManager.addTransfer(
listOf(model.getPath(row) to attributes),
InternalTransferManager.TransferMode.Transfer
)
val paths = listOf(model.getPath(row) to attributes)
if (loader.isOpened() && transferManager.canTransfer(paths.map { it.first })) {
transferManager.addTransfer(paths, InternalTransferManager.TransferMode.Transfer)
}
}
} else if (SwingUtilities.isRightMouseButton(e)) {
val r = table.rowAtPoint(e.point)
@@ -524,7 +526,6 @@ class TransportPanel(
}
private fun getTransferData(support: TransferSupport, load: Boolean): TransferData? {
if (loader.isLoaded.not()) return null
val workdir = workdir ?: return null
val dropLocation = support.dropLocation as? JTable.DropLocation ?: return null
val row = if (dropLocation.isInsertRow) 0 else sorter.convertRowIndexToModel(dropLocation.row)
@@ -540,7 +541,8 @@ class TransportPanel(
if (transferTransferable.component == panel) return null
paths.addAll(transferTransferable.files)
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
if (_fileSystem.isLocallyFileSystem()) return null
if (loader.isLoaded() && loader.getSyncTransportSupport().getFileSystem().isLocallyFileSystem())
return null
if (load) {
val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return null
@@ -577,22 +579,12 @@ class TransportPanel(
}
fun getTableModel(): TransportTableModel {
return model
private suspend fun getFileSystem(): FileSystem {
return loader.getTransportSupport().getFileSystem()
}
fun getFileSystem(): FileSystem {
return _fileSystem
}
/**
* 不能在 EDT 线程调用
*/
private fun getSupport(): TransportSupport {
if (SwingUtilities.isEventDispatchThread()) {
throw WrongThreadException("AWT EventQueue")
}
return loader.get()
private suspend fun getTransportSupport(): TransportSupport {
return loader.getTransportSupport()
}
private fun enterSelectionFolder() {
@@ -609,7 +601,7 @@ class TransportPanel(
if (workdir != null) registerSelectRow(workdir.name)
}
navigator.navigateTo(path)
navigator.navigateTo(path.absolutePathString())
}
private fun registerSelectRow(name: String) {
@@ -626,7 +618,11 @@ class TransportPanel(
}
}
private fun reload(oldPath: Path? = workdir, newPath: Path? = workdir, requestFocus: Boolean = false): Boolean {
fun reload(
oldPath: String? = workdir?.absolutePathString(),
newPath: String? = workdir?.absolutePathString(),
requestFocus: Boolean = false
): Boolean {
assertEventDispatchThread()
if (loading) return false
@@ -662,20 +658,26 @@ class TransportPanel(
return true
}
private suspend fun doReload(oldPath: Path? = null, newPath: Path? = null, requestFocus: Boolean = false): Path {
private suspend fun doReload(
oldPath: String? = null,
newPath: String? = null,
requestFocus: Boolean = false
): Path {
val support = getTransportSupport()
val fileSystem = support.getFileSystem()
val workdir = newPath ?: oldPath
if (workdir == null) {
val path = _fileSystem.getPath(defaultPath)
return doReload(null, path)
val path = support.getDefaultPath()
return doReload(null, path.absolutePathString())
}
val path = workdir
val path = fileSystem.getPath(workdir)
val first = AtomicBoolean(false)
var parent = path.parent
if (parent == null && _fileSystem.isWindowsFileSystem() && workdir.pathString != _fileSystem.separator) {
parent = _fileSystem.getPath(_fileSystem.separator)
if (parent == null && fileSystem.isWindowsFileSystem() && path.pathString != fileSystem.separator) {
parent = fileSystem.getPath(fileSystem.separator)
}
val files = mutableListOf<Pair<Path, Attributes>>()
if ((parent != null).also { hasParent = it }) {
@@ -696,8 +698,8 @@ class TransportPanel(
files.clear()
}
if (_fileSystem.isWindowsFileSystem() && workdir.pathString == _fileSystem.separator) {
for (path in _fileSystem.rootDirectories) {
if (fileSystem.isWindowsFileSystem() && path.pathString == fileSystem.separator) {
for (path in fileSystem.rootDirectories) {
val attributes = getAttributes(path)
files.add(path to attributes)
}
@@ -716,7 +718,7 @@ class TransportPanel(
if (requestFocus)
coroutineScope.launch(Dispatchers.Swing) { table.requestFocusInWindow() }
return workdir
return path
}
private fun listFiles(path: Path): Stream<Pair<Path, Attributes>> {
@@ -787,21 +789,24 @@ class TransportPanel(
}
}
private fun showContextmenu(rows: Array<Int>, e: MouseEvent) {
val files = rows.map { model.getPath(it) to model.getAttributes(it) }
val popupMenu = TransportPopupMenu(owner, model, transferManager, _fileSystem, files)
val popupMenu = TransportPopupMenu(owner, model, transferManager, loader, files)
popupMenu.addActionListener(PopupMenuActionListener(files))
popupMenu.show(table, e.x, e.y)
}
override fun navigateTo(destination: Path): Boolean {
override fun navigateTo(destination: String): Boolean {
assertEventDispatchThread()
if (loading) return false
if (workdir == destination) return false
return reload(workdir, destination)
if (loader.isOpened()) {
if (workdir?.absolutePathString() == destination) return false
}
return reload(newPath = destination)
}
override fun getHistory(): List<Path> {
@@ -825,7 +830,7 @@ class TransportPanel(
}
private fun setNewWorkdir(destination: Path) {
val oldValue = workdir
val oldValue = if (destination.fileSystem == workdir?.fileSystem) workdir else null
workdir = destination
firePropertyChange("workdir", oldValue, destination)
}
@@ -912,8 +917,12 @@ class TransportPanel(
val millis = Files.getLastModifiedTime(localPath).toMillis()
if (oldMillis == millis) continue
// 正在编辑时可能会出现断线的情况 ,安全获取
val fs = getFileSystem()
if (fs.isOpen.not()) continue
// 发送到服务器
transferManager.addHighTransfer(localPath, target)
transferManager.addHighTransfer(localPath, fs.getPath(target.absolutePathString()))
oldMillis = millis
}
}
@@ -1009,8 +1018,9 @@ class TransportPanel(
} else if (actionCommand == TransportPopupMenu.ActionCommand.NewFolder || actionCommand == TransportPopupMenu.ActionCommand.NewFile) {
val name = e.source.toString()
val workdir = workdir ?: return
val path = workdir.resolve(name)
processPath(e.source.toString()) {
// 因为此时可能已经断线,任何 Path 都不可完全相信
val path = getFileSystem().getPath(workdir.resolve(name).absolutePathString())
if (actionCommand == TransportPopupMenu.ActionCommand.NewFolder)
path.createDirectories()
else
@@ -1022,6 +1032,9 @@ class TransportPanel(
processPath(e.source.toString()) { source.moveTo(target) }
} else if (actionCommand == TransportPopupMenu.ActionCommand.Rmrf) {
transferManager.addTransfer(files, InternalTransferManager.TransferMode.Rmrf)
} else if (actionCommand == TransportPopupMenu.ActionCommand.Reconnect) {
// reload now
reload()
} else if (actionCommand == TransportPopupMenu.ActionCommand.ChangePermissions) {
val c = e.source as TransportPopupMenu.ChangePermission
val path = files.first().first
@@ -1104,7 +1117,7 @@ class TransportPanel(
private inner class MyDefaultTableCellRenderer : DefaultTableCellRenderer() {
override fun getTableCellRendererComponent(
table: JTable?,
table: JTable,
value: Any?,
isSelected: Boolean,
hasFocus: Boolean,
@@ -1133,12 +1146,13 @@ class TransportPanel(
text = StringUtils.EMPTY
}
foreground = null
val c = super.getTableCellRendererComponent(table, text, isSelected, hasFocus, row, column)
icon = null
if (column == TransportTableModel.COLUMN_NAME) {
if (_fileSystem.isWindowsFileSystem()) {
val path = model.getPath(sorter.convertRowIndexToModel(row))
if (path.fileSystem.isWindowsFileSystem()) {
icon = if (attributes.isParent) {
NativeIcons.folderIcon
} else {
@@ -1165,6 +1179,12 @@ class TransportPanel(
}
}
if (loader.isOpened().not()) {
if (isSelected.not()) {
foreground = UIManager.getColor("textInactiveText")
}
}
return c
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,22 +3,19 @@ package app.termora.transfer
import app.termora.Disposable
import app.termora.Disposer
import app.termora.DynamicColor
import app.termora.Icons
import app.termora.actions.DataProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.slf4j.LoggerFactory
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import java.awt.BorderLayout
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.nio.file.Path
import javax.swing.*
import kotlin.time.Duration.Companion.milliseconds
class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
companion object {
private val log = LoggerFactory.getLogger(TransportViewer::class.java)
}
internal class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val splitPane = JSplitPane()
@@ -78,10 +75,33 @@ class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
}
})
coroutineScope.launch(Dispatchers.Swing) {
while (isActive) {
delay(250.milliseconds)
checkDisconnected(leftTabbed)
checkDisconnected(rightTabbed)
}
}
Disposer.register(this, leftTabbed)
Disposer.register(this, rightTabbed)
}
private fun checkDisconnected(tabbed: TransportTabbed) {
for (i in 0 until tabbed.tabCount) {
val tab = tabbed.getTransportPanel(i) ?: continue
val icon = tabbed.getIconAt(i)
if (tab.loader.isOpened()) {
if (icon == null) continue
tabbed.setIconAt(i, null)
} else {
if (icon == Icons.breakpoint) continue
tabbed.setIconAt(i, Icons.breakpoint)
}
}
}
private fun createInternalTransferManager(
source: TransportTabbed,
target: TransportTabbed
@@ -118,4 +138,8 @@ class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
return rightTabbed
}
override fun dispose() {
coroutineScope.cancel()
}
}

View File

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

After

Width:  |  Height:  |  Size: 402 B

View File

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

After

Width:  |  Height:  |  Size: 402 B