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() } val openDoor = measureTimeMillis { openDoor() }
// clear temporary
clearTemporary()
// 启动主窗口 // 启动主窗口
val startMainFrame = measureTimeMillis { startMainFrame() } 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() { private fun openDoor() {
if (Doorman.getInstance().isWorking()) { if (Doorman.getInstance().isWorking()) {

View File

@@ -12,6 +12,7 @@ import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.io.file.PathUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
@@ -35,8 +36,11 @@ import java.nio.file.*
import java.util.* import java.util.*
import javax.swing.* import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableCellRenderer
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists import kotlin.io.path.exists
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.isDirectory import kotlin.io.path.isDirectory
import kotlin.time.Duration.Companion.milliseconds
/** /**
@@ -44,9 +48,8 @@ import kotlin.io.path.isDirectory
*/ */
class FileSystemPanel( class FileSystemPanel(
private val fileSystem: FileSystem, private val fileSystem: FileSystem,
private val transportManager: TransportManager,
private val host: Host private val host: Host
) : JPanel(BorderLayout()), Disposable, FileSystemTransportListener.Provider { ) : JPanel(BorderLayout()), Disposable {
companion object { companion object {
private val log = LoggerFactory.getLogger(FileSystemPanel::class.java) private val log = LoggerFactory.getLogger(FileSystemPanel::class.java)
@@ -64,6 +67,12 @@ class FileSystemPanel(
private val showHiddenFilesBtn = JButton(Icons.eyeClose) private val showHiddenFilesBtn = JButton(Icons.eyeClose)
private val properties get() = Database.getDatabase().properties private val properties get() = Database.getDatabase().properties
private val showHiddenFilesKey by lazy { "termora.transport.host.${host.id}.show-hidden-files" } 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 val workdir get() = tableModel.workdir
@@ -342,6 +351,9 @@ class FileSystemPanel(
} }
override fun dispose() {
coroutineScope.cancel()
}
private fun copyLocalFileToFileSystem(files: List<File>) { private fun copyLocalFileToFileSystem(files: List<File>) {
val event = AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) 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() { private fun openFolder() {
val row = table.selectedRow val row = table.selectedRow
if (row < 0) return if (row < 0) return
@@ -460,6 +464,7 @@ class FileSystemPanel(
private fun showContextMenu(rows: IntArray, event: MouseEvent) { private fun showContextMenu(rows: IntArray, event: MouseEvent) {
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
val popupMenu = FlatPopupMenu() val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new")) 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")) val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
transfer.addActionListener { transfer.addActionListener {
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
if (paths.isNotEmpty()) { if (paths.isNotEmpty()) {
transport(paths) 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() popupMenu.addSeparator()
// 复制路径 // 复制路径
@@ -574,6 +590,75 @@ class FileSystemPanel(
popupMenu.show(table, event.x, event.y) 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) @OptIn(DelicateCoroutinesApi::class)
private fun renamePath(path: Path) { private fun renamePath(path: Path) {
@@ -789,17 +874,31 @@ class FileSystemPanel(
private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) { private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) {
if (paths.isEmpty()) return if (paths.isEmpty()) return
val transportPanel = evt.getData(TransportDataProviders.TransportPanel) ?: return
val listeners = listenerList.getListeners(FileSystemTransportListener::class.java) val leftFileSystemPanel = evt.getData(TransportDataProviders.LeftFileSystemPanel) ?: return
if (listeners.isEmpty()) return val rightFileSystemPanel = evt.getData(TransportDataProviders.RightFileSystemPanel) ?: return
val sourceFileSystemPanel = this
val targetFileSystemPanel = if (this == leftFileSystemPanel) rightFileSystemPanel else leftFileSystemPanel
// 收集数据 // 收集数据
for (e in paths) { for (e in paths) {
if (!e.isDirectory) { if (!e.isDirectory) {
val job = TransportJob(
fileSystemPanel = this,
workdir = workdir,
isDirectory = false,
path = e.path,
)
withContext(Dispatchers.Swing) { 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 continue
} }
@@ -811,12 +910,26 @@ class FileSystemPanel(
val isDirectory = if (path.attributes != null) val isDirectory = if (path.attributes != null)
path.attributes.isDirectory else path.isDirectory() path.attributes.isDirectory else path.isDirectory()
withContext(Dispatchers.Swing) { 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 { } else {
val isDirectory = path.isDirectory() val isDirectory = path.isDirectory()
withContext(Dispatchers.Swing) { 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 app.termora.*
import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import java.awt.Component
import java.awt.Point import java.awt.Point
import java.nio.file.FileSystems import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.* import javax.swing.*
import kotlin.math.max import kotlin.math.max
@@ -13,9 +13,8 @@ import kotlin.math.max
class FileSystemTabbed( class FileSystemTabbed(
private val transportManager: TransportManager, private val transportManager: TransportManager,
private val isLeft: Boolean = false private val isLeft: Boolean = false
) : FlatTabbedPane(), FileSystemTransportListener.Provider, Disposable { ) : FlatTabbedPane(), Disposable {
private val addBtn = JButton(Icons.add) private val addBtn = JButton(Icons.add)
private val listeners = mutableListOf<FileSystemTransportListener>()
init { init {
initView() initView()
@@ -36,23 +35,20 @@ class FileSystemTabbed(
trailingComponent = toolbar trailingComponent = toolbar
if (isLeft) { if (isLeft) {
addFileSystemTransportProvider( addTab(
I18n.getString("termora.transport.local"), I18n.getString("termora.transport.local"), FileSystemPanel(
FileSystemPanel(
FileSystems.getDefault(), FileSystems.getDefault(),
transportManager,
host = Host( host = Host(
id = "local", id = "local",
name = I18n.getString("termora.transport.local"), name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local, protocol = Protocol.Local,
) )
).apply { reload() } ).apply { reload() })
)
setTabClosable(0, false) setTabClosable(0, false)
} else { } else {
addFileSystemTransportProvider( addTab(
I18n.getString("termora.transport.sftp.select-host"), I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel(transportManager) SftpFileSystemPanel()
) )
} }
@@ -70,8 +66,8 @@ class FileSystemTabbed(
dialog.isVisible = true dialog.isVisible = true
for (host in dialog.hosts) { for (host in dialog.hosts) {
val panel = SftpFileSystemPanel(transportManager, host) val panel = SftpFileSystemPanel(host)
addFileSystemTransportProvider(host.name, panel) addTab(host.name, panel)
panel.connect() panel.connect()
} }
@@ -120,9 +116,9 @@ class FileSystemTabbed(
if (tabCount == 0) { if (tabCount == 0) {
if (!isLeft) { if (!isLeft) {
addFileSystemTransportProvider( addTab(
I18n.getString("termora.transport.sftp.select-host"), I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel(transportManager) SftpFileSystemPanel()
) )
} }
} }
@@ -130,39 +126,31 @@ class FileSystemTabbed(
} }
fun addFileSystemTransportProvider(title: String, provider: FileSystemTransportListener.Provider) { override fun addTab(title: String, component: Component) {
if (provider !is JComponent) { super.addTab(title, component)
throw IllegalArgumentException("Provider is not an JComponent")
}
provider.addFileSystemTransportListener(object : FileSystemTransportListener { selectedIndex = tabCount - 1
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
}
})
// 修改 Tab名称 if (component is SftpFileSystemPanel) {
provider.addPropertyChangeListener("TabName") { e -> component.addPropertyChangeListener("TabName") { e ->
SwingUtilities.invokeLater { SwingUtilities.invokeLater {
val name = StringUtils.defaultIfEmpty( val name = StringUtils.defaultIfEmpty(
e.newValue.toString(), e.newValue.toString(),
I18n.getString("termora.transport.sftp.select-host") I18n.getString("termora.transport.sftp.select-host")
) )
for (i in 0 until tabCount) { for (i in 0 until tabCount) {
if (getComponentAt(i) == provider) { if (getComponentAt(i) == component) {
setTitleAt(i, name) setTitleAt(i, name)
break break
} }
} }
} }
} }
addTab(title, provider)
if (tabCount > 0)
selectedIndex = tabCount - 1
} }
}
fun getSelectedFileSystemPanel(): FileSystemPanel? { fun getSelectedFileSystemPanel(): FileSystemPanel? {
return getFileSystemPanel(selectedIndex) return getFileSystemPanel(selectedIndex)
} }
@@ -184,14 +172,6 @@ class FileSystemTabbed(
return null return null
} }
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.add(listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.remove(listener)
}
override fun dispose() { override fun dispose() {
while (tabCount > 0) { while (tabCount > 0) {
val c = getComponentAt(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.BorderLayout
import java.awt.CardLayout import java.awt.CardLayout
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.* import javax.swing.*
class SftpFileSystemPanel( class SftpFileSystemPanel(
private val transportManager: TransportManager,
private var host: Host? = null private var host: Host? = null
) : JPanel(BorderLayout()), Disposable, ) : JPanel(BorderLayout()), Disposable {
FileSystemTransportListener.Provider {
companion object { companion object {
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java) private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java)
@@ -50,7 +47,6 @@ class SftpFileSystemPanel(
private val connectingPanel = ConnectingPanel() private val connectingPanel = ConnectingPanel()
private val selectHostPanel = SelectHostPanel() private val selectHostPanel = SelectHostPanel()
private val connectFailedPanel = ConnectFailedPanel() private val connectFailedPanel = ConnectFailedPanel()
private val listeners = mutableListOf<FileSystemTransportListener>()
private val isDisposed = AtomicBoolean(false) private val isDisposed = AtomicBoolean(false)
private var client: SshClient? = null private var client: SshClient? = null
@@ -136,17 +132,7 @@ class SftpFileSystemPanel(
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
state = State.Connected state = State.Connected
val fileSystemPanel = FileSystemPanel(fileSystem, transportManager, host) val fileSystemPanel = FileSystemPanel(fileSystem, 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) cardPanel.add(fileSystemPanel, State.Connected.name)
cardLayout.show(cardPanel, 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 target: Path,
val sourceHolder: Disposable, val sourceHolder: Disposable,
val targetHolder: Disposable, val targetHolder: Disposable,
val listener: TransportListener = TransportListener.EMPTY
) : Disposable, Runnable { ) : Disposable, Runnable {
private val listeners = ArrayList<TransportListener>() private val listeners = ArrayList<TransportListener>()
init {
listeners.add(listener)
}
@Volatile @Volatile
var state = TransportState.Waiting var state = TransportState.Waiting
protected set(value) { protected set(value) {
@@ -142,9 +147,9 @@ private class SlidingWindowByteCounter {
*/ */
class FileTransport( class FileTransport(
name: String, source: Path, target: Path, name: String, source: Path, target: Path,
sourceHolder: Disposable, targetHolder: Disposable, sourceHolder: Disposable, targetHolder: Disposable, listener: TransportListener = TransportListener.EMPTY
) : Transport( ) : Transport(
name, source, target, sourceHolder, targetHolder, name, source, target, sourceHolder, targetHolder, listener
), CopyStreamListener { ), CopyStreamListener {
companion object { 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.* import java.util.*
interface TransportListener : EventListener { 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 * Added
*/ */
fun onTransportAdded(transport: Transport) fun onTransportAdded(transport: Transport){}
/** /**
* Removed * 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( fun transport(

View File

@@ -259,6 +259,7 @@ termora.transport.table.owner=Owner
# contextmenu # contextmenu
termora.transport.table.contextmenu.transfer=Transfer 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.copy-path=Copy Path
termora.transport.table.contextmenu.open-in-folder=Open in {0} termora.transport.table.contextmenu.open-in-folder=Open in {0}
termora.transport.table.contextmenu.rename=${termora.welcome.contextmenu.rename} termora.transport.table.contextmenu.rename=${termora.welcome.contextmenu.rename}