feat: SFTP file editing support (#209)

This commit is contained in:
hstyi
2025-02-12 15:55:51 +08:00
committed by GitHub
parent 2a64bd28a8
commit 189f8fb3ba
10 changed files with 232 additions and 138 deletions

View File

@@ -73,6 +73,9 @@ class ApplicationRunner {
// 解密数据
val openDoor = measureTimeMillis { openDoor() }
// clear temporary
clearTemporary()
// 启动主窗口
val startMainFrame = measureTimeMillis { startMainFrame() }
@@ -94,6 +97,22 @@ class ApplicationRunner {
}
}
@Suppress("OPT_IN_USAGE")
private fun clearTemporary() {
GlobalScope.launch(Dispatchers.IO) {
// 启动时清除
FileUtils.cleanDirectory(File(Application.getBaseDataDir(), "temporary"))
// 关闭时清除
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
override fun dispose() {
FileUtils.cleanDirectory(File(Application.getBaseDataDir(), "temporary"))
}
})
}
}
private fun openDoor() {
if (Doorman.getInstance().isWorking()) {

View File

@@ -12,6 +12,7 @@ import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.FileUtils
import org.apache.commons.io.file.PathUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
@@ -35,8 +36,11 @@ import java.nio.file.*
import java.util.*
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.isDirectory
import kotlin.time.Duration.Companion.milliseconds
/**
@@ -44,9 +48,8 @@ import kotlin.io.path.isDirectory
*/
class FileSystemPanel(
private val fileSystem: FileSystem,
private val transportManager: TransportManager,
private val host: Host
) : JPanel(BorderLayout()), Disposable, FileSystemTransportListener.Provider {
) : JPanel(BorderLayout()), Disposable {
companion object {
private val log = LoggerFactory.getLogger(FileSystemPanel::class.java)
@@ -64,6 +67,12 @@ class FileSystemPanel(
private val showHiddenFilesBtn = JButton(Icons.eyeClose)
private val properties get() = Database.getDatabase().properties
private val showHiddenFilesKey by lazy { "termora.transport.host.${host.id}.show-hidden-files" }
private val evt by lazy { AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) }
/**
* Edit
*/
private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
val workdir get() = tableModel.workdir
@@ -342,6 +351,9 @@ class FileSystemPanel(
}
override fun dispose() {
coroutineScope.cancel()
}
private fun copyLocalFileToFileSystem(files: List<File>) {
val event = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
@@ -425,14 +437,6 @@ class FileSystemPanel(
}
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
@@ -460,6 +464,7 @@ class FileSystemPanel(
private fun showContextMenu(rows: IntArray, event: MouseEvent) {
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
@@ -477,11 +482,22 @@ class FileSystemPanel(
// 传输
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)
}
}
// 编辑
val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit"))
// 不是 Linux & 不是本地文件系统 & 包含文件
edit.isEnabled = !SystemInfo.isLinux && !tableModel.isLocalFileSystem && paths.any { !it.isDirectory }
edit.addActionListener {
val files = paths.filter { !it.isDirectory }
if (files.isNotEmpty()) {
editFiles(files)
}
}
popupMenu.addSeparator()
// 复制路径
@@ -574,6 +590,75 @@ class FileSystemPanel(
popupMenu.show(table, event.x, event.y)
}
private fun editFiles(files: List<FileSystemTableModel.CacheablePath>) {
if (files.isEmpty()) return
val transportManager = evt.getData(TransportDataProviders.TransportManager) ?: return
val temporary = Paths.get(Application.getBaseDataDir().absolutePath, "temporary")
Files.createDirectories(temporary)
for (file in files) {
val dir = Files.createTempDirectory(temporary, "termora-")
val path = Paths.get(dir.absolutePathString(), file.fileName)
transportManager.addTransport(
transport = FileTransport(
name = file.fileName,
source = file.path,
target = path,
sourceHolder = this,
targetHolder = this,
listener = editFileTransportListener(file.path, path)
)
)
}
}
private fun editFileTransportListener(source: Path, localPath: Path): TransportListener {
return object : TransportListener {
override fun onTransportChanged(transport: Transport) {
// 传输成功
if (transport.state == TransportState.Done) {
val transportManager = evt.getData(TransportDataProviders.TransportManager) ?: return
var lastModifiedTime = localPath.getLastModifiedTime().toMillis()
if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start()
} else if (SystemInfo.isWindows) {
ProcessBuilder("notepad", localPath.absolutePathString()).start()
} else {
return
}
coroutineScope.launch(Dispatchers.IO) {
while (coroutineScope.isActive) {
try {
val nowModifiedTime = localPath.getLastModifiedTime().toMillis()
if (nowModifiedTime != lastModifiedTime) {
lastModifiedTime = nowModifiedTime
// upload
transportManager.addTransport(
transport = FileTransport(
name = PathUtils.getFileNameString(localPath.fileName),
source = localPath,
target = source,
sourceHolder = this@FileSystemPanel,
targetHolder = this@FileSystemPanel,
)
)
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
break
}
delay(250.milliseconds)
}
}
}
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun renamePath(path: Path) {
@@ -789,17 +874,31 @@ class FileSystemPanel(
private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) {
if (paths.isEmpty()) return
val listeners = listenerList.getListeners(FileSystemTransportListener::class.java)
if (listeners.isEmpty()) return
val transportPanel = evt.getData(TransportDataProviders.TransportPanel) ?: return
val leftFileSystemPanel = evt.getData(TransportDataProviders.LeftFileSystemPanel) ?: return
val rightFileSystemPanel = evt.getData(TransportDataProviders.RightFileSystemPanel) ?: return
val sourceFileSystemPanel = this
val targetFileSystemPanel = if (this == leftFileSystemPanel) rightFileSystemPanel else leftFileSystemPanel
// 收集数据
for (e in paths) {
if (!e.isDirectory) {
val job = TransportJob(
fileSystemPanel = this,
workdir = workdir,
isDirectory = false,
path = e.path,
)
withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, false, e.path) }
transportPanel.transport(
sourceWorkdir = workdir,
targetWorkdir = targetFileSystemPanel.workdir,
isSourceDirectory = false,
sourcePath = e.path,
sourceHolder = sourceFileSystemPanel,
targetHolder = targetFileSystemPanel
)
}
continue
}
@@ -811,12 +910,26 @@ class FileSystemPanel(
val isDirectory = if (path.attributes != null)
path.attributes.isDirectory else path.isDirectory()
withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) }
transportPanel.transport(
sourceWorkdir = workdir,
targetWorkdir = targetFileSystemPanel.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = sourceFileSystemPanel,
targetHolder = targetFileSystemPanel
)
}
} else {
val isDirectory = path.isDirectory()
withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) }
transportPanel.transport(
sourceWorkdir = workdir,
targetWorkdir = targetFileSystemPanel.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = sourceFileSystemPanel,
targetHolder = targetFileSystemPanel
)
}
}
}

View File

@@ -3,9 +3,9 @@ package app.termora.transport
import app.termora.*
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
import java.awt.Component
import java.awt.Point
import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.*
import kotlin.math.max
@@ -13,9 +13,8 @@ import kotlin.math.max
class FileSystemTabbed(
private val transportManager: TransportManager,
private val isLeft: Boolean = false
) : FlatTabbedPane(), FileSystemTransportListener.Provider, Disposable {
) : FlatTabbedPane(), Disposable {
private val addBtn = JButton(Icons.add)
private val listeners = mutableListOf<FileSystemTransportListener>()
init {
initView()
@@ -36,23 +35,20 @@ class FileSystemTabbed(
trailingComponent = toolbar
if (isLeft) {
addFileSystemTransportProvider(
I18n.getString("termora.transport.local"),
FileSystemPanel(
addTab(
I18n.getString("termora.transport.local"), FileSystemPanel(
FileSystems.getDefault(),
transportManager,
host = Host(
id = "local",
name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local,
)
).apply { reload() }
)
).apply { reload() })
setTabClosable(0, false)
} else {
addFileSystemTransportProvider(
addTab(
I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel(transportManager)
SftpFileSystemPanel()
)
}
@@ -70,8 +66,8 @@ class FileSystemTabbed(
dialog.isVisible = true
for (host in dialog.hosts) {
val panel = SftpFileSystemPanel(transportManager, host)
addFileSystemTransportProvider(host.name, panel)
val panel = SftpFileSystemPanel(host)
addTab(host.name, panel)
panel.connect()
}
@@ -120,9 +116,9 @@ class FileSystemTabbed(
if (tabCount == 0) {
if (!isLeft) {
addFileSystemTransportProvider(
addTab(
I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel(transportManager)
SftpFileSystemPanel()
)
}
}
@@ -130,39 +126,31 @@ class FileSystemTabbed(
}
fun addFileSystemTransportProvider(title: String, provider: FileSystemTransportListener.Provider) {
if (provider !is JComponent) {
throw IllegalArgumentException("Provider is not an JComponent")
}
override fun addTab(title: String, component: Component) {
super.addTab(title, component)
provider.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
}
})
selectedIndex = tabCount - 1
// 修改 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
if (component is SftpFileSystemPanel) {
component.addPropertyChangeListener("TabName") { e ->
SwingUtilities.invokeLater {
val name = StringUtils.defaultIfEmpty(
e.newValue.toString(),
I18n.getString("termora.transport.sftp.select-host")
)
for (i in 0 until tabCount) {
if (getComponentAt(i) == component) {
setTitleAt(i, name)
break
}
}
}
}
}
addTab(title, provider)
if (tabCount > 0)
selectedIndex = tabCount - 1
}
fun getSelectedFileSystemPanel(): FileSystemPanel? {
return getFileSystemPanel(selectedIndex)
}
@@ -184,14 +172,6 @@ class FileSystemTabbed(
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)

View File

@@ -1,19 +0,0 @@
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

@@ -21,15 +21,12 @@ 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 {
) : JPanel(BorderLayout()), Disposable {
companion object {
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java)
@@ -50,7 +47,6 @@ class SftpFileSystemPanel(
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
@@ -136,17 +132,7 @@ class SftpFileSystemPanel(
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) }
}
})
val fileSystemPanel = FileSystemPanel(fileSystem, host)
cardPanel.add(fileSystemPanel, State.Connected.name)
cardLayout.show(cardPanel, State.Connected.name)
@@ -312,11 +298,4 @@ class SftpFileSystemPanel(
}
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.add(listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.remove(listener)
}
}

View File

@@ -31,10 +31,15 @@ abstract class Transport(
val target: Path,
val sourceHolder: Disposable,
val targetHolder: Disposable,
val listener: TransportListener = TransportListener.EMPTY
) : Disposable, Runnable {
private val listeners = ArrayList<TransportListener>()
init {
listeners.add(listener)
}
@Volatile
var state = TransportState.Waiting
protected set(value) {
@@ -142,9 +147,9 @@ private class SlidingWindowByteCounter {
*/
class FileTransport(
name: String, source: Path, target: Path,
sourceHolder: Disposable, targetHolder: Disposable,
sourceHolder: Disposable, targetHolder: Disposable, listener: TransportListener = TransportListener.EMPTY
) : Transport(
name, source, target, sourceHolder, targetHolder,
name, source, target, sourceHolder, targetHolder, listener
), CopyStreamListener {
companion object {

View File

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

View File

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

View File

@@ -107,32 +107,6 @@ class TransportPanel : JPanel(BorderLayout()), Disposable, DataProvider {
})
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 transport(

View File

@@ -259,6 +259,7 @@ termora.transport.table.owner=Owner
# contextmenu
termora.transport.table.contextmenu.transfer=Transfer
termora.transport.table.contextmenu.edit=${termora.keymgr.edit}
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}