feat: vfs2

This commit is contained in:
hstyi
2025-04-03 00:42:51 +08:00
committed by hstyi
parent f9aaf7143f
commit 01aac98437
24 changed files with 988 additions and 457 deletions

View File

@@ -22,6 +22,10 @@ commons-compress
Apache License 2.0
https://github.com/apache/commons-compress/blob/master/LICENSE.txt
commons-vfs2
Apache License 2.0
https://github.com/apache/commons-vfs/blob/master/LICENSE.txt
commons-io
Apache License 2.0
https://github.com/apache/commons-io/blob/master/LICENSE.txt

View File

@@ -67,6 +67,7 @@ dependencies {
implementation(libs.commons.net)
implementation(libs.commons.text)
implementation(libs.commons.compress)
implementation(libs.commons.vfs2) { exclude(group = "*", module = "*") }
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.kotlinx.coroutines.core)
@@ -125,6 +126,7 @@ application {
"-XX:+ZUncommit",
"-XX:+ZGenerational",
"-XX:ZUncommitDelay=60",
"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
)
if (os.isMacOsX) {

View File

@@ -12,6 +12,7 @@ commons-csv = "1.14.0"
commons-net = "3.11.1"
commons-text = "1.13.0"
commons-compress = "1.27.1"
commons-vfs2="2.10.0"
swingx = "1.6.5-1"
jgoodies-forms = "1.9.0"
jfa = "1.2.0"
@@ -54,6 +55,7 @@ commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version.
commons-csv = { group = "org.apache.commons", name = "commons-csv", version.ref = "commons-csv" }
commons-text = { group = "org.apache.commons", name = "commons-text", version.ref = "commons-text" }
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" }
commons-vfs2 = { group = "org.apache.commons", name = "commons-vfs2", version.ref = "commons-vfs2" }
pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" }
ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" }
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }

View File

@@ -2,6 +2,7 @@ package app.termora
import app.termora.actions.ActionManager
import app.termora.keymap.KeymapManager
import app.termora.vfs2.sftp.MySftpFileProvider
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatDesktop
@@ -17,6 +18,10 @@ import kotlinx.coroutines.launch
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.LocaleUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.vfs2.VFS
import org.apache.commons.vfs2.cache.WeakRefFilesCache
import org.apache.commons.vfs2.impl.DefaultFileSystemManager
import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider
import org.json.JSONObject
import org.slf4j.LoggerFactory
import java.awt.MenuItem
@@ -48,10 +53,17 @@ class ApplicationRunner {
// 统计
val enableAnalytics = measureTimeMillis { enableAnalytics() }
// init ActionManager、KeymapManager
// init ActionManager、KeymapManager、VFS
swingCoroutineScope.launch(Dispatchers.IO) {
ActionManager.getInstance()
KeymapManager.getInstance()
val fileSystemManager = DefaultFileSystemManager()
fileSystemManager.addProvider("sftp", MySftpFileProvider())
fileSystemManager.addProvider("file", DefaultLocalFileProvider())
fileSystemManager.filesCache = WeakRefFilesCache()
fileSystemManager.init()
VFS.setManager(fileSystemManager)
}
// 设置 LAF

View File

@@ -4,7 +4,12 @@ import app.termora.Icons
import app.termora.assertEventDispatchThread
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextField
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.VFS
import org.apache.commons.vfs2.provider.local.LocalFileSystem
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.Component
@@ -14,8 +19,7 @@ import java.awt.event.ActionEvent
import java.awt.event.ActionListener
import java.awt.event.ItemEvent
import java.awt.event.ItemListener
import java.nio.file.FileSystem
import java.nio.file.Path
import java.nio.file.FileSystems
import javax.swing.*
import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener
@@ -23,8 +27,8 @@ import javax.swing.filechooser.FileSystemView
import kotlin.io.path.absolutePathString
class FileSystemViewNav(
private val fileSystem: FileSystem,
private val homeDirectory: Path
private val fileSystem: org.apache.commons.vfs2.FileSystem,
private val homeDirectory: FileObject
) : JPanel(BorderLayout()) {
companion object {
@@ -38,7 +42,7 @@ class FileSystemViewNav(
private val history = linkedSetOf<String>()
private val layeredPane = LayeredPane()
private val downBtn = JButton(Icons.chevronDown)
private val comboBox = object : JComboBox<Path>() {
private val comboBox = object : JComboBox<FileObject>() {
override fun getLocationOnScreen(): Point {
val point = super.getLocationOnScreen()
point.y -= 1
@@ -80,7 +84,7 @@ class FileSystemViewNav(
): Component {
val c = super.getListCellRendererComponent(
list,
value,
if (value is FileObject) formatDisplayPath(value) else value.toString(),
index,
isSelected,
cellHasFocus
@@ -99,12 +103,12 @@ class FileSystemViewNav(
add(layeredPane, BorderLayout.CENTER)
if (fileSystem.isWindows()) {
if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
try {
for (root in fileSystemView.roots) {
history.add(root.absolutePath)
}
for (rootDirectory in fileSystem.rootDirectories) {
for (rootDirectory in FileSystems.getDefault().rootDirectories) {
history.add(rootDirectory.absolutePathString())
}
} catch (e: Exception) {
@@ -115,12 +119,16 @@ class FileSystemViewNav(
}
}
private fun formatDisplayPath(file: FileObject): String {
return file.absolutePathString()
}
private fun initEvents() {
val itemListener = ItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
val item = comboBox.selectedItem
if (item is Path) {
if (item is FileObject) {
changeSelectedPath(item)
}
}
@@ -167,7 +175,11 @@ class FileSystemViewNav(
val name = textField.text.trim()
if (name.isBlank()) return
try {
changeSelectedPath(fileSystem.getPath(name))
if (fileSystem is LocalFileSystem && SystemUtils.IS_OS_WINDOWS) {
changeSelectedPath(fileSystem.resolveFile("file://${name}"))
} else {
changeSelectedPath(fileSystem.resolveFile(name))
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
@@ -182,7 +194,11 @@ class FileSystemViewNav(
comboBox.removeAllItems()
for (text in history) {
val path = fileSystem.getPath(text)
val path = if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
VFS.getManager().resolveFile("file://${text}")
} else {
fileSystem.resolveFile(text)
}
comboBox.addItem(path)
if (text == textField.text) {
comboBox.selectedItem = path
@@ -218,15 +234,15 @@ class FileSystemViewNav(
}
}
fun getSelectedPath(): Path {
return textField.getClientProperty(PATH) as Path
fun getSelectedPath(): FileObject {
return textField.getClientProperty(PATH) as FileObject
}
fun changeSelectedPath(path: Path) {
fun changeSelectedPath(file: FileObject) {
assertEventDispatchThread()
textField.text = path.absolutePathString()
textField.putClientProperty(PATH, path)
textField.text = formatDisplayPath(file)
textField.putClientProperty(PATH, file)
for (listener in listenerList.getListeners(ActionListener::class.java)) {
listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))

View File

@@ -3,30 +3,28 @@ package app.termora.sftp
import app.termora.*
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import app.termora.vfs2.sftp.MySftpFileSystem
import com.formdev.flatlaf.extras.components.FlatToolBar
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.commons.vfs2.FileObject
import org.jdesktop.swingx.JXBusyLabel
import java.awt.BorderLayout
import java.awt.event.*
import java.nio.file.FileSystem
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.concurrent.atomic.AtomicBoolean
import java.util.function.Consumer
import javax.swing.*
import kotlin.io.path.absolutePathString
import kotlin.io.path.name
class FileSystemViewPanel(
val host: Host,
val fileSystem: FileSystem,
val fileSystem: org.apache.commons.vfs2.FileSystem,
private val transportManager: TransportManager,
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
private val coroutineScope: CoroutineScope,
) : JPanel(BorderLayout()), Disposable, DataProvider {
private val properties get() = Database.getDatabase().properties
@@ -100,7 +98,7 @@ class FileSystemViewPanel(
override fun onTransportChanged(transport: Transport) {
val path = transport.target.parent ?: return
if (path.fileSystem != fileSystem) return
if (path.absolutePathString() != workdir.absolutePathString()) return
if (path.name.path != workdir.name.path) return
// 立即刷新
reload(true)
}
@@ -123,19 +121,19 @@ class FileSystemViewPanel(
private fun enterTableSelectionFolder(row: Int = table.selectedRow) {
if (row < 0 || isLoading.get()) return
val attr = model.getAttr(row)
if (attr.isFile) return
val file = model.getFileObject(row)
if (file.isFile) return
// 当前工作目录
val workdir = getWorkdir()
// 返回上级之后,选中上级目录
if (attr.name == "..") {
if (row == 0 && model.hasParent) {
val workdirName = workdir.name
nextReloadTickSelection(workdirName)
nextReloadTickSelection(workdirName.baseName)
}
changeWorkdir(attr.path)
changeWorkdir(file)
}
@@ -169,13 +167,13 @@ class FileSystemViewPanel(
bookmarkBtn.addActionListener { e ->
if (e.actionCommand.isNullOrBlank()) {
if (bookmarkBtn.isBookmark) {
bookmarkBtn.deleteBookmark(workdir.toString())
bookmarkBtn.deleteBookmark(workdir.absolutePathString())
} else {
bookmarkBtn.addBookmark(workdir.toString())
bookmarkBtn.addBookmark(workdir.absolutePathString())
}
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
} else {
changeWorkdir(fileSystem.getPath(e.actionCommand))
changeWorkdir(fileSystem.resolveFile(e.actionCommand))
}
}
@@ -194,14 +192,13 @@ class FileSystemViewPanel(
button.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (model.rowCount < 1) return
val attr = model.getAttr(0)
if (attr !is FileSystemViewTableModel.ParentAttr) return
if (model.hasParent) return
enterTableSelectionFolder(0)
}
})
addPropertyChangeListener("workdir") {
button.isEnabled = model.rowCount > 0 && model.getAttr(0) is FileSystemViewTableModel.ParentAttr
button.isEnabled = model.rowCount > 0 && model.hasParent
}
return button
@@ -211,7 +208,7 @@ class FileSystemViewPanel(
// 创建成功之后需要修改和选中
registerNextReloadTick {
for (i in 0 until table.rowCount) {
if (model.getAttr(i).name == name) {
if (model.getFileObject(i).name.baseName == name) {
table.addRowSelectionInterval(i, i)
table.scrollRectToVisible(table.getCellRect(i, 0, true))
consumer.accept(i)
@@ -221,18 +218,19 @@ class FileSystemViewPanel(
}
}
private fun changeWorkdir(workdir: Path) {
private fun changeWorkdir(workdir: FileObject) {
assertEventDispatchThread()
nav.changeSelectedPath(workdir)
}
fun renameTo(oldPath: Path, newPath: Path) {
fun renameTo(oldPath: FileObject, newPath: FileObject) {
// 新建文件夹
coroutineScope.launch {
if (requestLoading()) {
try {
Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE)
oldPath.moveTo(newPath)
} catch (e: Exception) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
@@ -247,7 +245,7 @@ class FileSystemViewPanel(
}
// 创建成功之后需要选中
nextReloadTickSelection(newPath.name)
nextReloadTickSelection(newPath.name.baseName)
// 立即刷新
reload()
@@ -258,7 +256,7 @@ class FileSystemViewPanel(
coroutineScope.launch {
if (requestLoading()) {
try {
doNewFolderOrFile(getWorkdir().resolve(name), isFile)
doNewFolderOrFile(getWorkdir().resolveFile(name), isFile)
} finally {
stopLoading()
}
@@ -273,9 +271,9 @@ class FileSystemViewPanel(
}
private suspend fun doNewFolderOrFile(path: Path, isFile: Boolean) {
private suspend fun doNewFolderOrFile(path: FileObject, isFile: Boolean) {
if (Files.exists(path)) {
if (path.exists()) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
@@ -288,7 +286,7 @@ class FileSystemViewPanel(
// 创建文件夹
withContext(Dispatchers.IO) {
runCatching { if (isFile) Files.createFile(path) else Files.createDirectories(path) }.onFailure {
runCatching { if (isFile) path.createFile() else path.createFolder() }.onFailure {
withContext(Dispatchers.Swing) {
if (it is Exception) {
OptionPane.showMessageDialog(
@@ -329,7 +327,7 @@ class FileSystemViewPanel(
fun reload(rememberSelection: Boolean = false) {
if (!requestLoading()) return
if (fileSystem.isSFTP()) loadingPanel.start()
if (fileSystem is MySftpFileSystem) loadingPanel.start()
val oldWorkdir = workdir
val path = nav.getSelectedPath()
@@ -338,7 +336,7 @@ class FileSystemViewPanel(
if (rememberSelection) {
withContext(Dispatchers.Swing) {
table.selectedRows.sortedDescending().map { model.getAttr(it).name }
table.selectedRows.sortedDescending().map { model.getFileObject(it).name.baseName }
.forEach { nextReloadTickSelection(it) }
}
}
@@ -347,7 +345,7 @@ class FileSystemViewPanel(
if (it is Exception) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, ExceptionUtils.getMessage(it),
owner, ExceptionUtils.getRootCauseMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
@@ -367,34 +365,35 @@ class FileSystemViewPanel(
} finally {
stopLoading()
if (fileSystem.isSFTP()) {
if (fileSystem is MySftpFileSystem) {
withContext(Dispatchers.Swing) { loadingPanel.stop() }
}
}
}
}
private fun getHomeDirectory(): Path {
if (fileSystem.isSFTP()) {
val fs = fileSystem as SftpFileSystem
val host = fs.session.getAttribute(SshClients.HOST_KEY) ?: return fs.defaultDir
private fun getHomeDirectory(): FileObject {
if (fileSystem is MySftpFileSystem) {
val host = fileSystem.getClientSession().getAttribute(SshClients.HOST_KEY)
?: return fileSystem.resolveFile(fileSystem.getDefaultDir())
val defaultDirectory = host.options.sftpDefaultDirectory
if (defaultDirectory.isNotBlank()) {
return runCatching { fs.getPath(defaultDirectory) }
.getOrElse { fs.defaultDir }
return fileSystem.resolveFile(defaultDirectory)
}
return fs.defaultDir
return fileSystem.resolveFile(fileSystem.getDefaultDir())
}
if (sftp.defaultDirectory.isNotBlank()) {
return runCatching { fileSystem.getPath(sftp.defaultDirectory) }
.getOrElse { fileSystem.getPath(SystemUtils.USER_HOME) }
val resolveFile = fileSystem.resolveFile("file://${sftp.defaultDirectory}")
if (resolveFile.exists()) {
return resolveFile
}
}
return fileSystem.getPath(SystemUtils.USER_HOME)
return fileSystem.resolveFile("file://${SystemUtils.USER_HOME}")
}
fun getWorkdir(): Path {
fun getWorkdir(): FileObject {
return workdir
}

View File

@@ -3,6 +3,9 @@ package app.termora.sftp
import app.termora.*
import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import app.termora.sftp.FileSystemViewTable.AskTransfer.Action
import app.termora.vfs2.sftp.MySftpFileObject
import app.termora.vfs2.sftp.MySftpFileSystem
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.FlatPopupMenu
@@ -11,14 +14,11 @@ 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.FileUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.apache.sshd.sftp.client.SftpClient
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath
import org.apache.sshd.sftp.client.fs.SftpPosixFileAttributes
import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.VFS
import org.apache.commons.vfs2.provider.local.LocalFileSystem
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.awt.Component
@@ -32,8 +32,12 @@ import java.awt.event.*
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.nio.file.*
import java.nio.file.FileVisitResult
import java.nio.file.FileVisitor
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileTime
import java.text.MessageFormat
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
@@ -41,14 +45,14 @@ import java.util.regex.Pattern
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import kotlin.collections.ArrayDeque
import kotlin.io.path.*
import kotlin.io.path.absolutePathString
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
@Suppress("DuplicatedCode")
@Suppress("DuplicatedCode", "CascadeIf")
class FileSystemViewTable(
private val fileSystem: FileSystem,
private val fileSystem: org.apache.commons.vfs2.FileSystem,
private val transportManager: TransportManager,
private val coroutineScope: CoroutineScope
) : JTable(), Disposable {
@@ -105,8 +109,8 @@ class FileSystemViewTable(
): Component {
foreground = null
val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getAttr(row).icon else null
foreground = if (!isSelected && model.getAttr(row).isHidden)
icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getFileIcon(row) else null
foreground = if (!isSelected && model.getFileObject(row).isHidden)
UIManager.getColor("textInactiveText") else foreground
return c
}
@@ -141,10 +145,10 @@ class FileSystemViewTable(
} else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val row = table.selectedRow
if (row <= 0 || row >= table.rowCount) return
val attr = model.getAttr(row)
if (attr.isDirectory) return
val file = model.getFileObject(row)
if (file.isFolder) return
// 传输
transfer(arrayOf(attr))
transfer(listOf(file))
}
}
})
@@ -156,8 +160,7 @@ class FileSystemViewTable(
if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) {
val rows = selectedRows
if (rows.contains(0)) return
val attrs = rows.map { model.getAttr(it) }.toTypedArray()
val files = attrs.map { it.path }.toTypedArray()
val files = rows.map { model.getFileObject(it) }
deletePaths(files, false)
} else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) {
fileSystemViewPanel.reload(true)
@@ -173,13 +176,15 @@ class FileSystemViewTable(
// 如果不是新增行,并且光标不在第一列,那么不允许
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false
// 如果不是新增行,如果在一个文件上,那么不允许
if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false
if (!dropLocation.isInsertRow && model.getFileObject(dropLocation.row).isFile) return false
// 如果不是新增行,在 .. 上面,不允许
if (!dropLocation.isInsertRow && model.hasParent && dropLocation.row == 0) return false
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
return data is FileSystemTableRowTransferable && data.source != table
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
return !fileSystem.isLocal()
return fileSystem !is LocalFileSystem
}
return false
@@ -190,27 +195,25 @@ class FileSystemViewTable(
// 如果不是新增行,并且光标不在第一列,那么不允许
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false
// 如果不是新增行,如果在一个文件上,那么不允许
if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false
if (!dropLocation.isInsertRow && model.getFileObject(dropLocation.row).isFile) return false
var targetWorkdir: Path? = null
var targetWorkdir: FileObject? = null
// 变更工作目录
if (!dropLocation.isInsertRow) {
targetWorkdir = model.getAttr(dropLocation.row).path
targetWorkdir = model.getFileObject(dropLocation.row)
}
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
if (data !is FileSystemTableRowTransferable) return false
// 委托源表开始传输
data.source.transfer(data.attrs.toTypedArray(), false, targetWorkdir)
data.source.transfer(data.files, false, targetWorkdir)
return true
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return false
val paths = files.filterIsInstance<File>()
.map { FileSystemViewTableModel.Attr(it.toPath()) }
.toTypedArray()
val paths = files.filterIsInstance<File>().map { VFS.getManager().resolveFile(it.toURI()) }
if (paths.isEmpty()) return false
val localTarget = sftpPanel.getLocalTarget()
val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false
@@ -226,9 +229,9 @@ class FileSystemViewTable(
}
override fun createTransferable(c: JComponent?): Transferable? {
val attrs = table.selectedRows.filter { it != 0 }.map { model.getAttr(it) }
if (attrs.isEmpty()) return null
return FileSystemTableRowTransferable(table, attrs)
val files = table.selectedRows.filter { it != 0 }.map { model.getFileObject(it) }
if (files.isEmpty()) return null
return FileSystemTableRowTransferable(table, files)
}
}
@@ -243,7 +246,7 @@ class FileSystemViewTable(
}
private fun navigate(row: Int, c: Char): Boolean {
val name = model.getAttr(row).name
val name = model.getFileObject(row).name.baseName
if (name.startsWith(c, true)) {
clearSelection()
addRowSelectionInterval(row, row)
@@ -255,18 +258,8 @@ class FileSystemViewTable(
})
}
override fun dispose() {
if (isDisposed.compareAndSet(false, true)) {
if (!fileSystem.isSFTP()) {
coroutineScope.cancel()
}
}
}
private fun showContextMenu(rows: IntArray, e: MouseEvent) {
val attrs = rows.map { model.getAttr(it) }.toTypedArray()
val files = attrs.map { it.path }.toTypedArray()
val files = rows.map { model.getFileObject(it) }
val hasParent = rows.contains(0)
val popupMenu = FlatPopupMenu()
@@ -279,13 +272,13 @@ class FileSystemViewTable(
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
// 编辑
val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit"))
edit.isEnabled = fileSystem.isSFTP() && attrs.all { it.isFile }
edit.isEnabled = fileSystem is MySftpFileSystem && files.all { it.isFile }
popupMenu.addSeparator()
// 复制路径
val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path"))
// 如果是本地,那么支持打开本地路径
if (fileSystem.isLocal()) {
if (fileSystem is LocalFileSystem) {
popupMenu.add(
I18n.getString(
"termora.transport.table.contextmenu.open-in-folder",
@@ -294,7 +287,7 @@ class FileSystemViewTable(
else I18n.getString("termora.folder")
)
).addActionListener {
Application.browseInFolder(files.last().toFile())
Application.browseInFolder(File(files.last().absolutePathString()))
}
}
@@ -307,18 +300,15 @@ class FileSystemViewTable(
val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete"))
// rm -rf
val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction))
// 只有 SFTP 可以
if (!fileSystem.isSFTP()) {
rmrf.isVisible = false
}
rmrf.isVisible = fileSystem is MySftpFileSystem
// 修改权限
val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
permission.isEnabled = false
// 如果是本地系统文件,那么不允许修改权限,用户应该自己修改
if (fileSystem.isSFTP() && rows.isNotEmpty()) {
if (fileSystem is MySftpFileSystem && rows.isNotEmpty()) {
permission.isEnabled = true
}
popupMenu.addSeparator()
@@ -360,23 +350,25 @@ class FileSystemViewTable(
})
copyPath.addActionListener {
val sb = StringBuilder()
attrs.forEach { sb.append(it.path.absolutePathString()).appendLine() }
files.forEach { sb.append(it.absolutePathString()).appendLine() }
sb.deleteCharAt(sb.length - 1)
toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null)
}
edit.addActionListener { if (files.isNotEmpty()) editFiles(files) }
permission.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val last = attrs.last()
val last = files.last()
if (last !is MySftpFileObject) return
val dialog = PosixFilePermissionDialog(
SwingUtilities.getWindowAncestor(table),
last.posixFilePermissions
model.getFilePermissions(last)
)
val permissions = dialog.open() ?: return
if (fileSystemViewPanel.requestLoading()) {
coroutineScope.launch(Dispatchers.IO) {
val c = runCatching { Files.setPosixFilePermissions(last.path, permissions) }.onFailure {
val c = runCatching { last.setPosixFilePermissions(permissions) }.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
@@ -398,7 +390,7 @@ class FileSystemViewTable(
}
})
refresh.addActionListener { fileSystemViewPanel.reload() }
transfer.addActionListener { transfer(attrs) }
transfer.addActionListener { transfer(files) }
if (rows.isEmpty() || hasParent) {
transfer.isEnabled = false
@@ -419,13 +411,13 @@ class FileSystemViewTable(
private fun renameSelection() {
val index = selectedRow
if (index < 0) return
val attr = model.getAttr(index)
val file = model.getFileObject(index)
val text = OptionPane.showInputDialog(
owner,
value = attr.name,
value = file.name.baseName,
title = I18n.getString("termora.transport.table.contextmenu.rename")
) ?: return
if (text.isBlank() || text == attr.name) return
if (text.isBlank() || text == file.name.baseName) return
if (model.getPathNames().contains(text)) {
OptionPane.showMessageDialog(
owner,
@@ -434,10 +426,11 @@ class FileSystemViewTable(
)
return
}
fileSystemViewPanel.renameTo(attr.path, attr.path.parent.resolve(text))
fileSystemViewPanel.renameTo(file, file.parent.resolveFile(text))
}
private fun editFiles(files: Array<Path>) {
private fun editFiles(files: List<FileObject>) {
if (files.isEmpty()) return
if (SystemInfo.isLinux) {
@@ -455,10 +448,11 @@ class FileSystemViewTable(
for (file in files) {
val dir = Application.createSubTemporaryDir()
val path = Paths.get(dir.absolutePathString(), file.name)
val path = Paths.get(dir.absolutePathString(), file.name.baseName)
val target = VFS.getManager().resolveFile("file://" + path.absolutePathString())
val newTransport = createTransport(file, false, 0L)
.apply { target = path }
.apply { this.target = target }
transportManager.addTransportListener(object : TransportListener {
override fun onTransportChanged(transport: Transport) {
@@ -467,7 +461,7 @@ class FileSystemViewTable(
transportManager.removeTransportListener(this)
if (transport.status != TransportStatus.Done) return
// 监听文件变动
listenFileChange(path, file)
listenFileChange(target, file)
}
})
@@ -476,21 +470,15 @@ class FileSystemViewTable(
}
}
private fun listenFileChange(localPath: Path, remotePath: Path) {
private fun listenFileChange(localPath: FileObject, remotePath: FileObject) {
try {
val p = localPath.absolutePathString()
if (sftp.editCommand.isNotBlank()) {
ProcessBuilder(
parseCommand(
MessageFormat.format(
sftp.editCommand,
localPath.absolutePathString()
)
)
).start()
ProcessBuilder(parseCommand(MessageFormat.format(sftp.editCommand, p))).start()
} else if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start()
ProcessBuilder("open", "-a", "TextEdit", p).start()
} else if (SystemInfo.isWindows) {
ProcessBuilder("notepad", localPath.absolutePathString()).start()
ProcessBuilder("notepad", p).start()
} else {
return
}
@@ -501,13 +489,17 @@ class FileSystemViewTable(
return
}
var lastModifiedTime = localPath.getLastModifiedTime().toMillis()
var lastModifiedTime = localPath.content.lastModifiedTime
coroutineScope.launch(Dispatchers.IO) {
while (coroutineScope.isActive) {
try {
if (isDisposed.get() || !Files.exists(localPath)) break
val nowModifiedTime = localPath.getLastModifiedTime().toMillis()
if (isDisposed.get()) break
localPath.refresh()
if (!localPath.exists()) break
val nowModifiedTime = localPath.content.lastModifiedTime
if (nowModifiedTime != lastModifiedTime) {
lastModifiedTime = nowModifiedTime
if (log.isDebugEnabled) {
@@ -562,7 +554,7 @@ class FileSystemViewTable(
fileSystemViewPanel.newFolderOrFile(text, isFile)
}
private fun deletePaths(paths: Array<Path>, rm: Boolean = false) {
private fun deletePaths(paths: List<FileObject>, 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"),
@@ -576,10 +568,10 @@ class FileSystemViewTable(
return
}
coroutineScope.launch {
coroutineScope.launch(Dispatchers.IO) {
runCatching {
if (fileSystem.isSFTP()) {
if (fileSystem is MySftpFileSystem) {
deleteSftpPaths(paths, rm)
} else {
deleteRecursively(paths)
@@ -590,97 +582,74 @@ class FileSystemViewTable(
}
}
withContext(Dispatchers.Swing) {
// 停止加载
fileSystemViewPanel.stopLoading()
// 刷新
fileSystemViewPanel.reload()
}
}
}
private fun deleteSftpPaths(paths: Array<Path>, rm: Boolean = false) {
val fs = this.fileSystem as SftpFileSystem
private fun deleteSftpPaths(files: List<FileObject>, rm: Boolean = false) {
if (rm) {
for (path in paths) {
fs.session.executeRemoteCommand(
val session = (this.fileSystem as MySftpFileSystem).getClientSession()
for (path in files) {
session.executeRemoteCommand(
"rm -rf '${path.absolutePathString()}'",
OutputStream.nullOutputStream(),
Charsets.UTF_8
)
}
} else {
fs.client.use {
for (path in paths) {
deleteRecursivelySFTP(path as SftpPath, it)
}
}
deleteRecursively(files)
}
}
private fun deleteRecursively(paths: Array<Path>) {
for (path in paths) {
FileUtils.deleteQuietly(path.toFile())
private fun deleteRecursively(files: List<FileObject>) {
for (path in files) {
path.deleteAll()
path.close()
}
}
/**
* 优化删除效率,采用一个连接
*/
private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) {
val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory()
if (isDirectory) {
for (e in sftpClient.readDir(path.toString())) {
if (e.filename == ".." || e.filename == ".") {
continue
}
if (e.attributes.isDirectory) {
deleteRecursivelySFTP(path.resolve(e.filename), sftpClient)
} else {
sftpClient.remove(path.resolve(e.filename).toString())
}
}
sftpClient.rmdir(path.toString())
} else {
sftpClient.remove(path.toString())
}
}
private fun transfer(
attrs: Array<FileSystemViewTableModel.Attr>,
files: List<FileObject>,
fromLocalSystem: Boolean = false,
targetWorkdir: Path? = null
targetWorkdir: FileObject? = null
) {
assertEventDispatchThread()
val target = sftpPanel.getTarget(table) ?: return
val table = target.getData(SFTPDataProviders.FileSystemViewTable) ?: return
var overwriteAll = false
var isApplyAll = false
var lastAction = Action.Overwrite
for (attr in attrs) {
if (!overwriteAll) {
val targetAttr = 0.rangeUntil(table.model.rowCount).map { table.model.getAttr(it) }
.find { it.name == attr.name }
for (file in files) {
if (!isApplyAll && (targetWorkdir == null || target.getWorkdir() == targetWorkdir)) {
val targetAttr = 0.rangeUntil(table.model.rowCount).map { table.model.getFileObject(it) }
.find { it.name.baseName == file.name.baseName }
if (targetAttr != null) {
val askTransfer = askTransfer(attr, targetAttr)
val askTransfer = askTransfer(file, targetAttr)
if (askTransfer.option != JOptionPane.YES_OPTION) {
continue
}
if (askTransfer.action == AskTransfer.Action.Skip) {
if (askTransfer.action == Action.Skip) {
if (askTransfer.applyAll) break
continue
} else if (askTransfer.action == AskTransfer.Action.Overwrite) {
overwriteAll = askTransfer.applyAll
} else {
lastAction = askTransfer.action
isApplyAll = askTransfer.applyAll
}
}
}
coroutineScope.launch {
try {
doTransfer(attr, fromLocalSystem, targetWorkdir)
doTransfer(file, lastAction, fromLocalSystem, targetWorkdir)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
@@ -698,13 +667,14 @@ class FileSystemViewTable(
) {
enum class Action {
Overwrite,
Append,
Skip
}
}
private fun askTransfer(
sourceAttr: FileSystemViewTableModel.Attr,
targetAttr: FileSystemViewTableModel.Attr
sourceFile: FileObject,
targetFile: FileObject
): AskTransfer {
val formMargin = "7dlu"
val layout = FormLayout(
@@ -715,34 +685,29 @@ class FileSystemViewTable(
val iconSize = 36
val targetIcon = if (SystemInfo.isWindows)
NativeFileIcons.getIcon(targetAttr.name, targetAttr.isFile, iconSize, iconSize).first
else if (targetAttr.isDirectory) {
model.getFileIcon(targetFile, iconSize, iconSize)
else if (targetFile.isFolder) {
FlatSVGIcon(Icons.folder.name, iconSize, iconSize)
} else {
FlatSVGIcon(Icons.file.name, iconSize, iconSize)
}
val sourceIcon = if (SystemInfo.isWindows)
NativeFileIcons.getIcon(sourceAttr.name, sourceAttr.isFile, iconSize, iconSize).first
else if (sourceAttr.isDirectory) {
model.getFileIcon(sourceFile, iconSize, iconSize)
else if (sourceFile.isFolder) {
FlatSVGIcon(Icons.folder.name, iconSize, iconSize)
} else {
FlatSVGIcon(Icons.file.name, iconSize, iconSize)
}
val sourceModified = if (sourceAttr.modified > 0) DateFormatUtils.format(
Date(sourceAttr.modified),
"yyyy/MM/dd HH:mm"
) else "-"
val targetModified = if (targetAttr.modified > 0) DateFormatUtils.format(
Date(targetAttr.modified),
"yyyy/MM/dd HH:mm"
) else "-"
val sourceModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(sourceFile), "-")
val targetModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(targetFile), "-")
val actionsComBoBox = JComboBox<AskTransfer.Action>()
actionsComBoBox.addItem(AskTransfer.Action.Overwrite)
actionsComBoBox.addItem(AskTransfer.Action.Skip)
val actionsComBoBox = JComboBox<Action>()
actionsComBoBox.addItem(Action.Overwrite)
actionsComBoBox.addItem(Action.Append)
actionsComBoBox.addItem(Action.Skip)
actionsComBoBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
@@ -752,10 +717,12 @@ class FileSystemViewTable(
cellHasFocus: Boolean
): Component {
var text = value?.toString() ?: StringUtils.EMPTY
if (value == AskTransfer.Action.Overwrite) {
if (value == Action.Overwrite) {
text = I18n.getString("termora.transport.sftp.already-exists.overwrite")
} else if (value == AskTransfer.Action.Skip) {
} else if (value == Action.Skip) {
text = I18n.getString("termora.transport.sftp.already-exists.skip")
} else if (value == Action.Append) {
text = I18n.getString("termora.transport.sftp.already-exists.append")
}
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
}
@@ -781,11 +748,11 @@ class FileSystemViewTable(
val step = 2
val panel = FormBuilder.create().layout(layout)
// tip
.add(JLabel(warningIcon)).xy(1, rows, "center, fill")
.add(JLabel(warningIcon)).xy(1, rows)
.add(ttBox).xyw(3, rows, 3).apply { rows += step }
// name
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.name")}:")).xy(1, rows)
.add(sourceAttr.name).xyw(3, rows, 3).apply { rows += step }
.add(sourceFile.name.baseName).xyw(3, rows, 3).apply { rows += step }
// separator
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
// Destination
@@ -813,12 +780,13 @@ class FileSystemViewTable(
owner, panel,
messageType = JOptionPane.PLAIN_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION,
title = sourceAttr.name,
title = sourceFile.name.baseName,
initialValue = JOptionPane.YES_OPTION,
) {
it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height)
it.setLocationRelativeTo(it.owner)
},
action = actionsComBoBox.selectedItem as AskTransfer.Action,
action = actionsComBoBox.selectedItem as Action,
applyAll = applyAllCheckbox.isSelected
)
@@ -829,9 +797,10 @@ class FileSystemViewTable(
* 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止
*/
private fun doTransfer(
attr: FileSystemViewTableModel.Attr,
file: FileObject,
action: Action,
fromLocalSystem: Boolean,
targetWorkdir: Path?
targetWorkdir: FileObject?
) {
val sftpPanel = this.sftpPanel
val target = sftpPanel.getTarget(table) ?: return
@@ -841,9 +810,14 @@ class FileSystemViewTable(
*/
val adder = object {
fun add(transport: Transport): Boolean {
if (action == Action.Append) {
transport.mode = StandardOpenOption.APPEND
} else {
transport.mode = StandardOpenOption.TRUNCATE_EXISTING
}
return addTransport(
sftpPanel,
if (fromLocalSystem) attr.path.parent else null,
if (fromLocalSystem) file.parent else null,
target,
targetWorkdir,
transport
@@ -851,8 +825,8 @@ class FileSystemViewTable(
}
}
if (attr.isFile) {
adder.add(createTransport(attr.path, false, 0).apply { scanned() })
if (file.isFile) {
adder.add(createTransport(file, false, 0).apply { scanned() })
return
}
@@ -860,26 +834,26 @@ class FileSystemViewTable(
var isTerminate = false
try {
walk(attr.path, object : FileVisitor<Path> {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
walk(file, object : FileVisitor<FileObject> {
override fun preVisitDirectory(dir: FileObject, attrs: BasicFileAttributes): FileVisitResult {
val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L)
.apply { queue.addLast(this) }
if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true }
}
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
override fun visitFile(file: FileObject, attrs: BasicFileAttributes): FileVisitResult {
if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS
val transport = createTransport(file, false, queue.last().id).apply { scanned() }
if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true }
}
override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult {
override fun visitFileFailed(file: FileObject, exc: IOException): FileVisitResult {
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
override fun postVisitDirectory(dir: FileObject, exc: IOException?): FileVisitResult {
// 标记为扫描完毕
queue.removeLast().scanned()
return FileVisitResult.CONTINUE
@@ -890,6 +864,13 @@ class FileSystemViewTable(
if (log.isErrorEnabled) {
log.error(e.message, e)
}
SwingUtilities.invokeLater {
OptionPane.showMessageDialog(
owner,
message = ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
isTerminate = true
}
@@ -899,35 +880,28 @@ class FileSystemViewTable(
}
}
private fun walk(dir: Path, visitor: FileVisitor<Path>) {
if (fileSystem is SftpFileSystem) {
val attr = SftpPosixFileAttributes(dir, SftpClient.Attributes())
fileSystem.client.use { walkSFTP(dir, attr, visitor, it) }
} else {
Files.walkFileTree(dir, setOf(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, visitor)
}
}
private fun walkSFTP(
dir: Path,
attr: SftpPosixFileAttributes,
visitor: FileVisitor<Path>,
client: SftpClient
private fun walk(
dir: FileObject,
visitor: FileVisitor<FileObject>,
): FileVisitResult {
if (visitor.preVisitDirectory(dir, attr) == FileVisitResult.TERMINATE) {
// clear cache
if (visitor.preVisitDirectory(dir, EmptyBasicFileAttributes.INSTANCE) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
val paths = client.readDir(dir.absolutePathString())
for (e in paths) {
if (e.filename == ".." || e.filename == ".") continue
if (e.attributes.isDirectory) {
if (walkSFTP(dir.resolve(e.filename), attr, visitor, client) == FileVisitResult.TERMINATE) {
for (e in dir.children) {
if (e.name.baseName == ".." || e.name.baseName == ".") continue
if (e.isFolder) {
if (walk(dir.resolveFile(e.name.baseName), visitor) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
} else {
val result = visitor.visitFile(dir.resolve(e.filename), attr)
val result = visitor.visitFile(
dir.resolveFile(e.name.baseName),
EmptyBasicFileAttributes.INSTANCE
)
if (result == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
} else if (result == FileVisitResult.SKIP_SUBTREE) {
@@ -945,19 +919,22 @@ class FileSystemViewTable(
private fun addTransport(
sftpPanel: SFTPPanel,
sourceWorkdir: Path?,
sourceWorkdir: FileObject?,
target: FileSystemViewPanel,
targetWorkdir: Path?,
targetWorkdir: FileObject?,
transport: Transport
): Boolean {
return try {
sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
false
}
}
private fun createTransport(source: Path, isDirectory: Boolean, parentId: Long): Transport {
private fun createTransport(source: FileObject, isDirectory: Boolean, parentId: Long): Transport {
val transport = Transport(
source = source,
target = source,
@@ -965,7 +942,7 @@ class FileSystemViewTable(
isDirectory = isDirectory,
)
if (transport.isFile) {
transport.filesize.addAndGet(source.fileSize())
transport.filesize.addAndGet(source.content.size)
}
return transport
}
@@ -973,7 +950,7 @@ class FileSystemViewTable(
private class FileSystemTableRowTransferable(
val source: FileSystemViewTable,
val attrs: List<FileSystemViewTableModel.Attr>
val files: List<FileObject>
) : Transferable {
companion object {
val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable")
@@ -996,4 +973,47 @@ class FileSystemViewTable(
}
private class EmptyBasicFileAttributes : BasicFileAttributes {
companion object {
val INSTANCE = EmptyBasicFileAttributes()
}
override fun lastModifiedTime(): FileTime {
TODO("Not yet implemented")
}
override fun lastAccessTime(): FileTime {
TODO("Not yet implemented")
}
override fun creationTime(): FileTime {
TODO("Not yet implemented")
}
override fun isRegularFile(): Boolean {
TODO("Not yet implemented")
}
override fun isDirectory(): Boolean {
TODO("Not yet implemented")
}
override fun isSymbolicLink(): Boolean {
TODO("Not yet implemented")
}
override fun isOther(): Boolean {
TODO("Not yet implemented")
}
override fun size(): Long {
TODO("Not yet implemented")
}
override fun fileKey(): Any {
TODO("Not yet implemented")
}
}
}

View File

@@ -3,21 +3,24 @@ package app.termora.sftp
import app.termora.I18n
import app.termora.NativeStringComparator
import app.termora.formatBytes
import app.termora.vfs2.sftp.MySftpFileObject
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.apache.sshd.sftp.client.fs.SftpPath
import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.FileType
import org.apache.commons.vfs2.provider.local.LocalFileSystem
import org.slf4j.LoggerFactory
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.PosixFilePermissions
import java.util.*
import javax.swing.Icon
import javax.swing.SwingUtilities
import javax.swing.table.DefaultTableModel
import kotlin.io.path.*
class FileSystemViewTableModel : DefaultTableModel() {
@@ -29,9 +32,10 @@ class FileSystemViewTableModel : DefaultTableModel() {
const val COLUMN_LAST_MODIFIED_TIME = 3
const val COLUMN_ATTRS = 4
const val COLUMN_OWNER = 5
private val log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java)
private fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
val result = mutableSetOf<PosixFilePermission>()
// 将十进制权限转换为八进制字符串
@@ -68,21 +72,67 @@ class FileSystemViewTableModel : DefaultTableModel() {
}
}
override fun getValueAt(row: Int, column: Int): Any {
val attr = getAttr(row)
return when (column) {
COLUMN_NAME -> attr.name
COLUMN_FILE_SIZE -> if (attr.isDirectory) StringUtils.EMPTY else formatBytes(attr.size)
COLUMN_TYPE -> attr.type
COLUMN_LAST_MODIFIED_TIME -> if (attr.modified > 0) DateFormatUtils.format(
Date(attr.modified),
"yyyy/MM/dd HH:mm"
) else StringUtils.EMPTY
var hasParent: Boolean = false
private set
COLUMN_ATTRS -> attr.permissions
COLUMN_OWNER -> attr.owner
override fun getValueAt(row: Int, column: Int): Any {
val file = getFileObject(row)
val isParentRow = hasParent && row == 0
try {
if (file.type == FileType.IMAGINARY) return StringUtils.EMPTY
return when (column) {
COLUMN_NAME -> if (isParentRow) ".." else file.name.baseName
COLUMN_FILE_SIZE -> if (isParentRow || file.isFolder) StringUtils.EMPTY else formatBytes(file.content.size)
COLUMN_TYPE -> if (isParentRow) StringUtils.EMPTY else getFileType(file)
COLUMN_LAST_MODIFIED_TIME -> if (isParentRow) StringUtils.EMPTY else getLastModifiedTime(file)
COLUMN_ATTRS -> if (isParentRow) StringUtils.EMPTY else getAttrs(file)
COLUMN_OWNER -> StringUtils.EMPTY
else -> StringUtils.EMPTY
}
} catch (e: Exception) {
if (file.fileSystem is LocalFileSystem) {
if (ExceptionUtils.getRootCause(e) is java.nio.file.NoSuchFileException) {
SwingUtilities.invokeLater { removeRow(row) }
return StringUtils.EMPTY
}
}
if (log.isWarnEnabled) {
log.warn(e.message, e)
}
return StringUtils.EMPTY
}
}
private fun getFileType(file: FileObject): String {
return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile).second
else if (file.isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
else NativeFileIcons.getIcon(file.name.baseName, file.isFile).second
}
fun getFileIcon(file: FileObject, width: Int = 16, height: Int = 16): Icon {
return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile, width, height).first
else NativeFileIcons.getIcon(file.name.baseName, file.isFile).first
}
fun getFileIcon(row: Int): Icon {
return getFileIcon(getFileObject(row))
}
fun getLastModifiedTime(file: FileObject): String {
if (file.content.lastModifiedTime < 1) return "-"
return DateFormatUtils.format(Date(file.content.lastModifiedTime), "yyyy/MM/dd HH:mm")
}
private fun getAttrs(file: FileObject): String {
if (file.fileSystem is LocalFileSystem) return StringUtils.EMPTY
return PosixFilePermissions.toString(getFilePermissions(file))
}
fun getFilePermissions(file: FileObject): Set<PosixFilePermission> {
val permissions = file.content.getAttribute(MySftpFileObject.POSIX_FILE_PERMISSIONS)
as Int? ?: return emptySet()
return fromSftpPermissions(permissions)
}
override fun getDataVector(): Vector<Vector<Any>> {
@@ -100,14 +150,14 @@ class FileSystemViewTableModel : DefaultTableModel() {
}
}
fun getAttr(row: Int): Attr {
return super.getValueAt(row, 0) as Attr
fun getFileObject(row: Int): FileObject {
return super.getValueAt(row, 0) as FileObject
}
fun getPathNames(): Set<String> {
val names = linkedSetOf<String>()
for (i in 0 until rowCount) {
names.add(getAttr(i).name)
names.add(getFileObject(i).name.baseName)
}
return names
}
@@ -129,144 +179,40 @@ class FileSystemViewTableModel : DefaultTableModel() {
return false
}
suspend fun reload(dir: Path, useFileHiding: Boolean) {
suspend fun reload(dir: FileObject, useFileHiding: Boolean) {
if (log.isDebugEnabled) {
log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding)
}
val attrs = mutableListOf<Attr>()
if (dir.parent != null) {
attrs.add(ParentAttr(dir.parent))
}
val files = mutableListOf<FileObject>()
withContext(Dispatchers.IO) {
Files.list(dir).use { paths ->
for (path in paths) {
val attr = if (path is SftpPath) SftpAttr(path) else Attr(path)
if (useFileHiding && attr.isHidden) continue
attrs.add(attr)
}
dir.refresh()
for (file in dir.children) {
if (useFileHiding && file.isHidden) continue
files.add(file)
}
}
attrs.sortWith(compareBy<Attr> { !it.isDirectory }.thenComparing { a, b ->
files.sortWith(compareBy<FileObject> { !it.isFolder }.thenComparing { a, b ->
NativeStringComparator.getInstance().compare(
a.name,
b.name
a.name.baseName,
b.name.baseName
)
})
hasParent = dir.parent != null
if (hasParent) {
files.addFirst(dir.parent)
}
withContext(Dispatchers.Swing) {
while (rowCount > 0) removeRow(0)
attrs.forEach { addRow(arrayOf(it)) }
}
files.forEach { addRow(arrayOf(it)) }
}
open class Attr(val path: Path) {
/**
* 名称
*/
open val name by lazy { path.name }
/**
* 文件类型
*/
open val type by lazy {
if (path.fileSystem.isWindows()) NativeFileIcons.getIcon(name, isFile).second
else if (isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
else NativeFileIcons.getIcon(name, isFile).second
}
/**
* 大小
*/
open val size by lazy { path.fileSize() }
/**
* 修改时间
*/
open val modified by lazy { path.getLastModifiedTime().toMillis() }
/**
* 获取所有者
*/
open val owner by lazy { StringUtils.EMPTY }
/**
* 获取操作系统图标
*/
open val icon by lazy { NativeFileIcons.getIcon(name, isFile).first }
/**
* 是否是文件夹
*/
open val isDirectory by lazy { path.isDirectory() }
/**
* 是否是文件
*/
open val isFile by lazy { !isDirectory }
/**
* 是否是文件夹
*/
open val isHidden by lazy { path.isHidden() }
open val isSymbolicLink by lazy { path.isSymbolicLink() }
/**
* 获取权限
*/
open val permissions: String by lazy {
posixFilePermissions.let {
if (it.isNotEmpty()) PosixFilePermissions.toString(
it
) else StringUtils.EMPTY
}
}
open val posixFilePermissions by lazy { if (path.fileSystem.isUnix()) path.getPosixFilePermissions() else emptySet() }
open fun toFile(): File {
if (path.fileSystem.isSFTP()) {
return File(path.absolutePathString())
}
return path.toFile()
}
}
class ParentAttr(path: Path) : Attr(path) {
override val name by lazy { ".." }
override val isDirectory = true
override val isFile = false
override val isHidden = false
override val permissions = StringUtils.EMPTY
override val modified = 0L
override val type = StringUtils.EMPTY
override val icon by lazy { NativeFileIcons.getFolderIcon() }
override val isSymbolicLink = false
}
class SftpAttr(sftpPath: SftpPath) : Attr(sftpPath) {
private val attributes = sftpPath.attributes
override val isSymbolicLink = attributes.isSymbolicLink
override val isDirectory = if (isSymbolicLink) sftpPath.isDirectory() else attributes.isDirectory
override val isHidden = name.startsWith(".")
override val size = attributes.size
override val owner: String = StringUtils.defaultString(attributes.owner)
override val modified = attributes.modifyTime.toMillis()
override val permissions: String = PosixFilePermissions.toString(fromSftpPermissions(attributes.permissions))
override val posixFilePermissions = fromSftpPermissions(attributes.permissions)
override fun toFile(): File {
return File(path.absolutePathString())
}
}

View File

@@ -3,6 +3,7 @@ package app.termora.sftp
import app.termora.*
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
@@ -11,10 +12,11 @@ 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.commons.vfs2.FileSystem
import org.apache.commons.vfs2.FileSystemOptions
import org.apache.commons.vfs2.VFS
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
@@ -46,18 +48,16 @@ class SFTPFileSystemViewPanel(
private var state = State.Initialized
private val cardLayout = CardLayout()
private val cardPanel = JPanel(cardLayout)
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val connectingPanel = ConnectingPanel()
private val selectHostPanel = SelectHostPanel()
private val connectFailedPanel = ConnectFailedPanel()
private val isDisposed = AtomicBoolean(false)
private val that = this
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val properties get() = Database.getDatabase().properties
private var client: SshClient? = null
private var session: ClientSession? = null
private var fileSystem: SftpFileSystem? = null
private var fileSystemPanel: FileSystemViewPanel? = null
@@ -111,11 +111,17 @@ class SFTPFileSystemViewPanel(
closeIO()
val mySftpFileSystem: FileSystem
try {
val owner = SwingUtilities.getWindowAncestor(that)
val client = SshClients.openClient(thisHost, owner).apply { client = this }
val session = SshClients.openSession(thisHost, client).apply { session = this }
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
val options = FileSystemOptions()
MySftpFileSystemConfigBuilder.getInstance()
.setClientSession(options, session)
mySftpFileSystem = VFS.getManager().resolveFile("sftp:///", options).fileSystem
session.addCloseFutureListener { onClose() }
} catch (e: Exception) {
closeIO()
@@ -126,11 +132,10 @@ class SFTPFileSystemViewPanel(
throw IllegalStateException("Closed")
}
val fileSystem = this.fileSystem ?: return
withContext(Dispatchers.Swing) {
state = State.Connected
val fileSystemPanel = FileSystemViewPanel(thisHost, fileSystem, transportManager, coroutineScope)
val fileSystemPanel = FileSystemViewPanel(thisHost, mySftpFileSystem, transportManager, coroutineScope)
cardPanel.add(fileSystemPanel, State.Connected.name)
cardLayout.show(cardPanel, State.Connected.name)
that.fileSystemPanel = fileSystemPanel
@@ -157,7 +162,6 @@ class SFTPFileSystemViewPanel(
fileSystemPanel?.let { Disposer.dispose(it) }
fileSystemPanel = null
runCatching { IOUtils.closeQuietly(fileSystem) }
runCatching { IOUtils.closeQuietly(session) }
runCatching { IOUtils.closeQuietly(client) }

View File

@@ -0,0 +1,18 @@
package app.termora.sftp
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.provider.local.LocalFile
import java.io.File
fun FileObject.absolutePathString(): String {
var text = name.path
if (this is LocalFile && SystemUtils.IS_OS_WINDOWS) {
text = this.name.toString()
text = StringUtils.removeStart(text, "file:///")
text = StringUtils.replace(text, "/", File.separator)
}
return text
}

View File

@@ -5,31 +5,34 @@ import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.terminal.DataKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import okio.withLock
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.VFS
import java.awt.BorderLayout
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.nio.file.FileSystem
import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.*
import kotlin.io.path.absolutePathString
fun FileSystem.isSFTP() = this is SftpFileSystem
fun FileSystem.isUnix() = isLocal() && SystemUtils.IS_OS_UNIX
fun FileSystem.isWindows() = isLocal() && SystemUtils.IS_OS_WINDOWS
fun FileSystem.isLocal() = StringUtils.startsWithAny(javaClass.name, "java", "sun")
class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val transportTable = TransportTable()
private val transportManager get() = transportTable.model
private val dataProviderSupport = DataProviderSupport()
private val leftComponent = SFTPTabbed(transportManager)
private val rightComponent = SFTPTabbed(transportManager)
private val localHost = Host(
id = "local",
name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local,
)
init {
initViews()
@@ -87,11 +90,10 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
leftComponent.addTab(
I18n.getString("termora.transport.local"),
FileSystemViewPanel(
Host(
id = "local",
name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local,
), FileSystems.getDefault(), transportManager
localHost,
VFS.getManager().resolveFile("file:///${SystemUtils.USER_HOME}").fileSystem,
transportManager,
coroutineScope
)
)
leftComponent.setTabClosable(0, false)
@@ -165,9 +167,9 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
*/
fun addTransport(
source: JComponent,
sourceWorkdir: Path?,
sourceWorkdir: FileObject?,
target: FileSystemViewPanel,
targetWorkdir: Path?,
targetWorkdir: FileObject?,
transport: Transport
): Boolean {
@@ -175,15 +177,12 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
as? FileSystemViewPanel ?: return false
val targetPanel = target as? FileSystemViewPanel ?: return false
if (sourcePanel.isDisposed || targetPanel.isDisposed) return false
val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir()).absolutePathString()
val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir()).absolutePathString()
val targetFileSystem = targetPanel.fileSystem
val sourcePath = transport.source.absolutePathString()
val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir())
val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir())
val sourcePath = transport.source
transport.target = targetFileSystem.getPath(
myTargetWorkdir,
StringUtils.removeStart(sourcePath, mySourceWorkdir)
)
val relativeName = mySourceWorkdir.name.getRelativeName(sourcePath.name)
transport.target = myTargetWorkdir.resolveFile(relativeName)
return transportManager.addTransport(transport)
@@ -212,4 +211,8 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
return dataProviderSupport.getData(dataKey)
}
override fun dispose() {
coroutineScope.cancel()
}
}

View File

@@ -13,7 +13,6 @@ import javax.swing.JButton
import javax.swing.JToolBar
import javax.swing.SwingUtilities
import javax.swing.UIManager
import kotlin.io.path.absolutePathString
import kotlin.math.max
@Suppress("DuplicatedCode")

View File

@@ -6,18 +6,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.apache.commons.io.IOUtils
import org.apache.commons.net.io.Util
import org.apache.commons.vfs2.FileObject
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.BasicFileAttributeView
import java.nio.file.StandardOpenOption
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.name
enum class TransportStatus {
Ready,
@@ -48,12 +43,19 @@ class Transport(
/**
* 源
*/
val source: Path,
val source: FileObject,
/**
* 目标
*/
var target: Path,
var target: FileObject,
/**
* 仅对文件生效,切只有两个选项
*
* 1. [StandardOpenOption.APPEND]
* 2. [StandardOpenOption.TRUNCATE_EXISTING]
*/
var mode: StandardOpenOption = StandardOpenOption.TRUNCATE_EXISTING
) {
companion object {
@@ -154,7 +156,7 @@ class Transport(
withContext(Dispatchers.IO) {
try {
if (!target.exists()) {
target.createDirectories()
target.createFolder()
}
} catch (e: FileAlreadyExistsException) {
if (log.isWarnEnabled) {
@@ -169,8 +171,8 @@ class Transport(
}
withContext(Dispatchers.IO) {
val input = Files.newInputStream(source)
val output = Files.newOutputStream(target)
val input = source.content.inputStream
val output = target.content.getOutputStream(mode == StandardOpenOption.APPEND)
try {
@@ -209,8 +211,7 @@ class Transport(
private fun preserveModificationTime() {
// 设置修改时间
if (isPreserveModificationTime) {
Files.getFileAttributeView(target, BasicFileAttributeView::class.java)
.setTimes(source.getLastModifiedTime(), source.getLastModifiedTime(), null)
target.content.lastModifiedTime = source.content.lastModifiedTime
}
}

View File

@@ -3,12 +3,11 @@ package app.termora.sftp
import app.termora.I18n
import app.termora.formatBytes
import app.termora.formatSeconds
import org.apache.commons.io.file.PathUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import app.termora.vfs2.sftp.MySftpFileSystem
import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder
import org.apache.commons.vfs2.FileObject
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import java.nio.file.Path
import kotlin.io.path.absolutePathString
class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) {
val transport get() = userObject as Transport
@@ -20,7 +19,7 @@ class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode
(transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0
return when (column) {
TransportTableModel.COLUMN_NAME -> PathUtils.getFileNameString(transport.source)
TransportTableModel.COLUMN_NAME -> transport.source.name.baseName
TransportTableModel.COLUMN_STATUS -> formatStatus(transport)
TransportTableModel.COLUMN_SIZE -> size()
TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-"
@@ -31,12 +30,14 @@ class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode
}
}
private fun formatPath(path: Path): String {
if (path.fileSystem.isSFTP()) {
val hostname = ((path.fileSystem as SftpFileSystem).session as JGitClientSession).hostConfigEntry.hostName
return hostname + ":" + path.absolutePathString()
private fun formatPath(file: FileObject): String {
if (file.fileSystem is MySftpFileSystem) {
val session = MySftpFileSystemConfigBuilder.getInstance()
.getClientSession(file.fileSystem.fileSystemOptions) as JGitClientSession
val hostname = session.hostConfigEntry.hostName
return hostname + ":" + file.name.path
}
return path.toUri().scheme + ":" + path.absolutePathString()
return file.name.toString()
}
private fun formatStatus(transport: Transport): String {

View File

@@ -0,0 +1,273 @@
package app.termora.vfs2.sftp
import app.termora.sftp.FileSystemViewTableModel
import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.FileSystemException
import org.apache.commons.vfs2.FileType
import org.apache.commons.vfs2.provider.AbstractFileName
import org.apache.commons.vfs2.provider.AbstractFileObject
import org.apache.sshd.sftp.client.SftpClient
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath
import org.apache.sshd.sftp.client.fs.WithFileAttributes
import org.slf4j.LoggerFactory
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.FileTime
import java.nio.file.attribute.PosixFilePermission
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.io.path.*
class MySftpFileObject(
private val sftpFileSystem: SftpFileSystem,
fileName: AbstractFileName,
fileSystem: MySftpFileSystem
) : AbstractFileObject<MySftpFileSystem>(fileName, fileSystem) {
companion object {
private val log = LoggerFactory.getLogger(MySftpFileObject::class.java)
const val POSIX_FILE_PERMISSIONS = "PosixFilePermissions"
}
private var _attributes: SftpClient.Attributes? = null
private val isInitialized = AtomicBoolean(false)
private val path by lazy { sftpFileSystem.getPath(fileName.path) }
private val attributes = mutableMapOf<String, Any>()
override fun doGetContentSize(): Long {
val attributes = getAttributes()
if (attributes == null || !attributes.flags.contains(SftpClient.Attribute.Size)) {
throw FileSystemException("vfs.provider.sftp/unknown-size.error")
}
return attributes.size
}
override fun doGetType(): FileType {
val attributes = getAttributes() ?: return FileType.IMAGINARY
return if (attributes.isDirectory)
FileType.FOLDER
else if (attributes.isRegularFile)
FileType.FILE
else if (attributes.isSymbolicLink) {
val e = path.readSymbolicLink()
if (e is SftpPath && e.attributes != null) {
if (e.attributes.isDirectory) {
FileType.FOLDER
} else {
FileType.FILE
}
} else if (e.isDirectory()) {
FileType.FOLDER
} else {
FileType.FILE
}
} else FileType.IMAGINARY
}
override fun doListChildren(): Array<String>? {
return null
}
override fun doListChildrenResolved(): Array<FileObject>? {
if (isFile) return null
val children = mutableListOf<FileObject>()
Files.list(path).use { files ->
for (file in files) {
val fo = resolveFile(file.name)
if (file is WithFileAttributes && fo is MySftpFileObject) {
if (fo.isInitialized.compareAndSet(false, true)) {
fo.setAttributes(file.attributes)
}
}
children.add(fo)
}
}
return children.toTypedArray()
}
override fun doGetOutputStream(bAppend: Boolean): OutputStream {
if (bAppend) {
return path.outputStream(StandardOpenOption.WRITE, StandardOpenOption.APPEND)
}
return path.outputStream()
}
override fun doGetInputStream(bufferSize: Int): InputStream {
return path.inputStream()
}
override fun doCreateFolder() {
Files.createDirectories(path)
}
override fun doIsExecutable(): Boolean {
val permissions = getPermissions()
return permissions.contains(PosixFilePermission.GROUP_EXECUTE) ||
permissions.contains(PosixFilePermission.OWNER_EXECUTE) ||
permissions.contains(PosixFilePermission.GROUP_EXECUTE)
}
override fun doIsReadable(): Boolean {
val permissions = getPermissions()
return permissions.contains(PosixFilePermission.GROUP_READ) ||
permissions.contains(PosixFilePermission.OWNER_READ) ||
permissions.contains(PosixFilePermission.OTHERS_READ)
}
override fun doIsWriteable(): Boolean {
val permissions = getPermissions()
return permissions.contains(PosixFilePermission.GROUP_WRITE) ||
permissions.contains(PosixFilePermission.OWNER_WRITE) ||
permissions.contains(PosixFilePermission.OTHERS_WRITE)
}
override fun doRename(newFile: FileObject) {
if (newFile !is MySftpFileObject) {
throw FileSystemException("vfs.provider/rename-not-supported.error")
}
Files.move(path, newFile.path, StandardCopyOption.ATOMIC_MOVE)
}
override fun moveTo(destFile: FileObject) {
if (canRenameTo(destFile)) {
doRename(destFile)
} else {
throw FileSystemException("vfs.provider/rename-not-supported.error")
}
}
override fun doDelete() {
sftpFileSystem.client.use { deleteRecursivelySFTP(path, 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())
}
}
override fun doSetExecutable(executable: Boolean, ownerOnly: Boolean): Boolean {
val permissions = getPermissions().toMutableSet()
permissions.add(PosixFilePermission.OWNER_EXECUTE)
if (ownerOnly) {
permissions.remove(PosixFilePermission.OTHERS_EXECUTE)
permissions.remove(PosixFilePermission.GROUP_EXECUTE)
}
Files.setPosixFilePermissions(path, permissions)
return true
}
override fun doSetReadable(readable: Boolean, ownerOnly: Boolean): Boolean {
val permissions = getPermissions().toMutableSet()
permissions.add(PosixFilePermission.OWNER_READ)
if (ownerOnly) {
permissions.remove(PosixFilePermission.OTHERS_READ)
permissions.remove(PosixFilePermission.GROUP_EXECUTE)
}
Files.setPosixFilePermissions(path, permissions)
return true
}
override fun doSetWritable(writable: Boolean, ownerOnly: Boolean): Boolean {
val permissions = getPermissions().toMutableSet()
permissions.add(PosixFilePermission.OWNER_WRITE)
if (ownerOnly) {
permissions.remove(PosixFilePermission.OTHERS_WRITE)
permissions.remove(PosixFilePermission.GROUP_WRITE)
}
Files.setPosixFilePermissions(path, permissions)
return true
}
override fun doSetLastModifiedTime(modtime: Long): Boolean {
Files.setLastModifiedTime(path, FileTime.fromMillis(modtime))
return true
}
override fun doDetach() {
setAttributes(null)
isInitialized.compareAndSet(true, false)
}
override fun doIsHidden(): Boolean {
return name.baseName.startsWith(".")
}
override fun doGetAttributes(): MutableMap<String, Any> {
return attributes
}
override fun doGetLastModifiedTime(): Long {
val attributes = getAttributes()
if (attributes == null || !attributes.flags.contains(SftpClient.Attribute.ModifyTime)) {
throw FileSystemException("vfs.provider.sftp/unknown-modtime.error")
}
return attributes.modifyTime.toMillis()
}
override fun doSetAttribute(attrName: String, value: Any) {
attributes[attrName] = value
}
override fun doIsSymbolicLink(): Boolean {
return getAttributes()?.isSymbolicLink == true
}
fun setPosixFilePermissions(permissions: Set<PosixFilePermission>) {
path.setPosixFilePermissions(permissions)
}
private fun getAttributes(): SftpClient.Attributes? {
if (isInitialized.compareAndSet(false, true)) {
try {
val attributes = sftpFileSystem.provider()
.readRemoteAttributes(sftpFileSystem.provider().toSftpPath(path))
setAttributes(attributes)
} catch (e: Exception) {
if (log.isDebugEnabled) {
log.debug(e.message, e)
}
}
}
return _attributes
}
private fun setAttributes(attributes: SftpClient.Attributes?) {
if (attributes == null) {
doGetAttributes().remove(POSIX_FILE_PERMISSIONS)
} else {
doSetAttribute(POSIX_FILE_PERMISSIONS, attributes.permissions)
}
this._attributes = attributes
}
private fun getPermissions(): Set<PosixFilePermission> {
return FileSystemViewTableModel.fromSftpPermissions(getAttributes()?.permissions ?: return setOf())
}
}

View File

@@ -0,0 +1,45 @@
package app.termora.vfs2.sftp
import org.apache.commons.vfs2.Capability
import org.apache.commons.vfs2.FileName
import org.apache.commons.vfs2.FileSystem
import org.apache.commons.vfs2.FileSystemOptions
import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider
import org.apache.sshd.sftp.client.SftpClientFactory
class MySftpFileProvider : AbstractOriginatingFileProvider() {
companion object {
val capabilities = listOf(
Capability.CREATE,
Capability.DELETE,
Capability.RENAME,
Capability.GET_TYPE,
Capability.LIST_CHILDREN,
Capability.READ_CONTENT,
Capability.URI,
Capability.WRITE_CONTENT,
Capability.GET_LAST_MODIFIED,
Capability.SET_LAST_MODIFIED_FILE,
Capability.RANDOM_ACCESS_READ,
Capability.APPEND_CONTENT
)
}
override fun getCapabilities(): Collection<Capability> {
return MySftpFileProvider.capabilities
}
override fun doCreateFileSystem(rootFileName: FileName, fileSystemOptions: FileSystemOptions): FileSystem {
val clientSession = MySftpFileSystemConfigBuilder.getInstance()
.getClientSession(fileSystemOptions)
if (clientSession == null) {
throw IllegalArgumentException("client session not found")
}
return MySftpFileSystem(
SftpClientFactory.instance().createSftpFileSystem(clientSession),
rootFileName,
fileSystemOptions
)
}
}

View File

@@ -0,0 +1,34 @@
package app.termora.vfs2.sftp
import org.apache.commons.vfs2.Capability
import org.apache.commons.vfs2.FileName
import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.FileSystemOptions
import org.apache.commons.vfs2.provider.AbstractFileName
import org.apache.commons.vfs2.provider.AbstractFileSystem
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import kotlin.io.path.absolutePathString
class MySftpFileSystem(
private val sftpFileSystem: SftpFileSystem,
rootName: FileName,
fileSystemOptions: FileSystemOptions
) : AbstractFileSystem(rootName, null, fileSystemOptions) {
override fun addCapabilities(caps: MutableCollection<Capability>) {
caps.addAll(MySftpFileProvider.capabilities)
}
override fun createFile(name: AbstractFileName): FileObject {
return MySftpFileObject(sftpFileSystem, name, this)
}
fun getDefaultDir(): String {
return sftpFileSystem.defaultDir.absolutePathString()
}
fun getClientSession(): ClientSession {
return sftpFileSystem.session
}
}

View File

@@ -0,0 +1,29 @@
package app.termora.vfs2.sftp
import org.apache.commons.vfs2.FileSystem
import org.apache.commons.vfs2.FileSystemConfigBuilder
import org.apache.commons.vfs2.FileSystemOptions
import org.apache.sshd.client.session.ClientSession
class MySftpFileSystemConfigBuilder : FileSystemConfigBuilder() {
companion object {
private val INSTANCE by lazy { MySftpFileSystemConfigBuilder() }
fun getInstance(): MySftpFileSystemConfigBuilder {
return INSTANCE
}
}
override fun getConfigClass(): Class<out FileSystem> {
return MySftpFileSystem::class.java
}
fun setClientSession(options: FileSystemOptions, session: ClientSession) {
setParam(options, "session", session)
}
fun getClientSession(options: FileSystemOptions): ClientSession? {
return getParam(options, "session")
}
}

View File

@@ -320,6 +320,7 @@ termora.transport.sftp.status.failed=Failed
termora.transport.sftp.already-exists.message1=This folder already contains an object named as below
termora.transport.sftp.already-exists.message2=Select a task to do
termora.transport.sftp.already-exists.overwrite=Overwrite
termora.transport.sftp.already-exists.append=Append
termora.transport.sftp.already-exists.skip=Skip
termora.transport.sftp.already-exists.apply-all=Apply all
termora.transport.sftp.already-exists.name=Name

View File

@@ -298,6 +298,7 @@ termora.transport.sftp.status.failed=已失败
termora.transport.sftp.already-exists.message1=此文件夹已包含一下名称的对象
termora.transport.sftp.already-exists.message2=请选择要执行的操作
termora.transport.sftp.already-exists.overwrite=覆盖
termora.transport.sftp.already-exists.append=追加
termora.transport.sftp.already-exists.skip=跳过
termora.transport.sftp.already-exists.apply-all=应用全部
termora.transport.sftp.already-exists.name=名称

View File

@@ -292,6 +292,7 @@ termora.transport.sftp.status.failed=已失敗
termora.transport.sftp.already-exists.message1=此資料夾已包含一下名稱的對象
termora.transport.sftp.already-exists.message2=請選擇要執行的操作
termora.transport.sftp.already-exists.overwrite=覆蓋
termora.transport.sftp.already-exists.append=追加
termora.transport.sftp.already-exists.skip=跳過
termora.transport.sftp.already-exists.apply-all=應用全部
termora.transport.sftp.already-exists.name=名稱

View File

@@ -11,11 +11,9 @@ class SFTPTest : SSHDTest() {
@Test
fun test() {
val client = SshClients.openClient(host)
val session = SshClients.openSession(host, client)
val session = newClientSession()
assertTrue(session.isOpen)
val fileSystem = DefaultSftpClientFactory.INSTANCE.createSftpFileSystem(session)
for (path in Files.list(fileSystem.rootDirectories.first())) {
println(path)

View File

@@ -1,8 +1,10 @@
package app.termora
import org.apache.sshd.client.session.ClientSession
import org.testcontainers.containers.GenericContainer
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.assertTrue
abstract class SSHDTest {
@@ -17,8 +19,8 @@ abstract class SSHDTest {
.withEnv("SUDO_ACCESS", "true")
.withExposedPorts(2222)
protected val host by lazy {
Host(
protected val host
get() = Host(
name = sshd.containerName,
protocol = Protocol.SSH,
host = "127.0.0.1",
@@ -26,7 +28,6 @@ abstract class SSHDTest {
username = "foo",
authentication = Authentication.No.copy(type = AuthenticationType.Password, password = "pass"),
)
}
@BeforeTest
@@ -38,4 +39,11 @@ abstract class SSHDTest {
fun teardown() {
sshd.stop()
}
fun newClientSession(): ClientSession {
val client = SshClients.openClient(host)
val session = SshClients.openSession(host, client)
assertTrue(session.isOpen)
return session
}
}

View File

@@ -0,0 +1,114 @@
package app.termora.vfs2.sftp
import app.termora.SSHDTest
import app.termora.toSimpleString
import org.apache.commons.vfs2.*
import org.apache.commons.vfs2.impl.DefaultFileSystemManager
import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider
import org.apache.sshd.sftp.client.SftpClientFactory
import java.io.File
import java.util.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MySftpFileProviderTest : SSHDTest() {
companion object {
init {
val fileSystemManager = DefaultFileSystemManager()
fileSystemManager.addProvider("sftp", MySftpFileProvider())
fileSystemManager.addProvider("file", DefaultLocalFileProvider())
fileSystemManager.init()
VFS.setManager(fileSystemManager)
}
}
@Test
fun testSetExecutable() {
val file = newFileObject("/config/test.txt")
file.createFile()
file.refresh()
assertFalse(file.isExecutable)
file.setExecutable(true, false)
file.refresh()
assertTrue(file.isExecutable)
}
@Test
fun testCreateFile() {
val file = newFileObject("/config/test.txt")
assertFalse(file.exists())
file.createFile()
assertTrue(file.exists())
}
@Test
fun testWriteAndReadFile() {
val file = newFileObject("/config/test.txt")
file.createFile()
assertFalse(file.content.isOpen)
val os = file.content.outputStream
os.write("test".toByteArray())
os.flush()
assertTrue(file.content.isOpen)
os.close()
assertFalse(file.content.isOpen)
val input = file.content.inputStream
assertEquals("test", String(input.readAllBytes()))
assertTrue(file.content.isOpen)
input.close()
assertFalse(file.content.isOpen)
}
@Test
fun testCreateFolder() {
val file = newFileObject("/config/test")
assertFalse(file.exists())
file.createFolder()
assertTrue(file.exists())
}
@Test
fun testSftpClient() {
val session = newClientSession()
val client = SftpClientFactory.instance().createSftpClient(session)
assertTrue(client.isOpen)
session.close()
assertFalse(client.isOpen)
}
@Test
fun testCopy() {
val file = newFileObject("/config/sshd.pid")
val filepath = File("build", UUID.randomUUID().toSimpleString())
val localFile = getVFS().resolveFile("file://${filepath.absolutePath}")
localFile.copyFrom(file, Selectors.SELECT_ALL)
assertEquals(
file.content.getString(Charsets.UTF_8),
localFile.content.getString(Charsets.UTF_8)
)
localFile.delete()
}
private fun getVFS(): FileSystemManager {
return VFS.getManager()
}
private fun newFileObject(path: String): FileObject {
val vfs = getVFS()
val fileSystemOptions = FileSystemOptions()
MySftpFileSystemConfigBuilder.getInstance().setClientSession(fileSystemOptions, newClientSession())
return vfs.resolveFile("sftp://${path}", fileSystemOptions)
}
}