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 Apache License 2.0
https://github.com/apache/commons-compress/blob/master/LICENSE.txt 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 commons-io
Apache License 2.0 Apache License 2.0
https://github.com/apache/commons-io/blob/master/LICENSE.txt https://github.com/apache/commons-io/blob/master/LICENSE.txt

View File

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

View File

@@ -12,6 +12,7 @@ commons-csv = "1.14.0"
commons-net = "3.11.1" commons-net = "3.11.1"
commons-text = "1.13.0" commons-text = "1.13.0"
commons-compress = "1.27.1" commons-compress = "1.27.1"
commons-vfs2="2.10.0"
swingx = "1.6.5-1" swingx = "1.6.5-1"
jgoodies-forms = "1.9.0" jgoodies-forms = "1.9.0"
jfa = "1.2.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-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-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-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" } pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" }
ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" } ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" }
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" } 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.actions.ActionManager
import app.termora.keymap.KeymapManager import app.termora.keymap.KeymapManager
import app.termora.vfs2.sftp.MySftpFileProvider
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatSystemProperties import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatDesktop import com.formdev.flatlaf.extras.FlatDesktop
@@ -17,6 +18,10 @@ import kotlinx.coroutines.launch
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.LocaleUtils import org.apache.commons.lang3.LocaleUtils
import org.apache.commons.lang3.SystemUtils 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.json.JSONObject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.MenuItem import java.awt.MenuItem
@@ -48,10 +53,17 @@ class ApplicationRunner {
// 统计 // 统计
val enableAnalytics = measureTimeMillis { enableAnalytics() } val enableAnalytics = measureTimeMillis { enableAnalytics() }
// init ActionManager、KeymapManager // init ActionManager、KeymapManager、VFS
swingCoroutineScope.launch(Dispatchers.IO) { swingCoroutineScope.launch(Dispatchers.IO) {
ActionManager.getInstance() ActionManager.getInstance()
KeymapManager.getInstance() KeymapManager.getInstance()
val fileSystemManager = DefaultFileSystemManager()
fileSystemManager.addProvider("sftp", MySftpFileProvider())
fileSystemManager.addProvider("file", DefaultLocalFileProvider())
fileSystemManager.filesCache = WeakRefFilesCache()
fileSystemManager.init()
VFS.setManager(fileSystemManager)
} }
// 设置 LAF // 设置 LAF

View File

@@ -4,7 +4,12 @@ import app.termora.Icons
import app.termora.assertEventDispatchThread import app.termora.assertEventDispatchThread
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextField import com.formdev.flatlaf.extras.components.FlatTextField
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.lang3.StringUtils 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 org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
@@ -14,8 +19,7 @@ import java.awt.event.ActionEvent
import java.awt.event.ActionListener import java.awt.event.ActionListener
import java.awt.event.ItemEvent import java.awt.event.ItemEvent
import java.awt.event.ItemListener import java.awt.event.ItemListener
import java.nio.file.FileSystem import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.* import javax.swing.*
import javax.swing.event.PopupMenuEvent import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener import javax.swing.event.PopupMenuListener
@@ -23,8 +27,8 @@ import javax.swing.filechooser.FileSystemView
import kotlin.io.path.absolutePathString import kotlin.io.path.absolutePathString
class FileSystemViewNav( class FileSystemViewNav(
private val fileSystem: FileSystem, private val fileSystem: org.apache.commons.vfs2.FileSystem,
private val homeDirectory: Path private val homeDirectory: FileObject
) : JPanel(BorderLayout()) { ) : JPanel(BorderLayout()) {
companion object { companion object {
@@ -38,7 +42,7 @@ class FileSystemViewNav(
private val history = linkedSetOf<String>() private val history = linkedSetOf<String>()
private val layeredPane = LayeredPane() private val layeredPane = LayeredPane()
private val downBtn = JButton(Icons.chevronDown) private val downBtn = JButton(Icons.chevronDown)
private val comboBox = object : JComboBox<Path>() { private val comboBox = object : JComboBox<FileObject>() {
override fun getLocationOnScreen(): Point { override fun getLocationOnScreen(): Point {
val point = super.getLocationOnScreen() val point = super.getLocationOnScreen()
point.y -= 1 point.y -= 1
@@ -80,7 +84,7 @@ class FileSystemViewNav(
): Component { ): Component {
val c = super.getListCellRendererComponent( val c = super.getListCellRendererComponent(
list, list,
value, if (value is FileObject) formatDisplayPath(value) else value.toString(),
index, index,
isSelected, isSelected,
cellHasFocus cellHasFocus
@@ -99,12 +103,12 @@ class FileSystemViewNav(
add(layeredPane, BorderLayout.CENTER) add(layeredPane, BorderLayout.CENTER)
if (fileSystem.isWindows()) { if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
try { try {
for (root in fileSystemView.roots) { for (root in fileSystemView.roots) {
history.add(root.absolutePath) history.add(root.absolutePath)
} }
for (rootDirectory in fileSystem.rootDirectories) { for (rootDirectory in FileSystems.getDefault().rootDirectories) {
history.add(rootDirectory.absolutePathString()) history.add(rootDirectory.absolutePathString())
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -115,12 +119,16 @@ class FileSystemViewNav(
} }
} }
private fun formatDisplayPath(file: FileObject): String {
return file.absolutePathString()
}
private fun initEvents() { private fun initEvents() {
val itemListener = ItemListener { e -> val itemListener = ItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) { if (e.stateChange == ItemEvent.SELECTED) {
val item = comboBox.selectedItem val item = comboBox.selectedItem
if (item is Path) { if (item is FileObject) {
changeSelectedPath(item) changeSelectedPath(item)
} }
} }
@@ -167,7 +175,11 @@ class FileSystemViewNav(
val name = textField.text.trim() val name = textField.text.trim()
if (name.isBlank()) return if (name.isBlank()) return
try { 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) { } catch (e: Exception) {
if (log.isErrorEnabled) { if (log.isErrorEnabled) {
log.error(e.message, e) log.error(e.message, e)
@@ -182,7 +194,11 @@ class FileSystemViewNav(
comboBox.removeAllItems() comboBox.removeAllItems()
for (text in history) { 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) comboBox.addItem(path)
if (text == textField.text) { if (text == textField.text) {
comboBox.selectedItem = path comboBox.selectedItem = path
@@ -218,15 +234,15 @@ class FileSystemViewNav(
} }
} }
fun getSelectedPath(): Path { fun getSelectedPath(): FileObject {
return textField.getClientProperty(PATH) as Path return textField.getClientProperty(PATH) as FileObject
} }
fun changeSelectedPath(path: Path) { fun changeSelectedPath(file: FileObject) {
assertEventDispatchThread() assertEventDispatchThread()
textField.text = path.absolutePathString() textField.text = formatDisplayPath(file)
textField.putClientProperty(PATH, path) textField.putClientProperty(PATH, file)
for (listener in listenerList.getListeners(ActionListener::class.java)) { for (listener in listenerList.getListeners(ActionListener::class.java)) {
listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY)) 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.*
import app.termora.actions.DataProvider import app.termora.actions.DataProvider
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.vfs2.sftp.MySftpFileSystem
import com.formdev.flatlaf.extras.components.FlatToolBar 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.swing.Swing
import kotlinx.coroutines.withContext
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
import org.apache.sshd.sftp.client.fs.SftpFileSystem import org.apache.commons.vfs2.FileObject
import org.jdesktop.swingx.JXBusyLabel import org.jdesktop.swingx.JXBusyLabel
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.event.* 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.concurrent.atomic.AtomicBoolean
import java.util.function.Consumer import java.util.function.Consumer
import javax.swing.* import javax.swing.*
import kotlin.io.path.absolutePathString
import kotlin.io.path.name
class FileSystemViewPanel( class FileSystemViewPanel(
val host: Host, val host: Host,
val fileSystem: FileSystem, val fileSystem: org.apache.commons.vfs2.FileSystem,
private val transportManager: TransportManager, private val transportManager: TransportManager,
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), private val coroutineScope: CoroutineScope,
) : JPanel(BorderLayout()), Disposable, DataProvider { ) : JPanel(BorderLayout()), Disposable, DataProvider {
private val properties get() = Database.getDatabase().properties private val properties get() = Database.getDatabase().properties
@@ -100,7 +98,7 @@ class FileSystemViewPanel(
override fun onTransportChanged(transport: Transport) { override fun onTransportChanged(transport: Transport) {
val path = transport.target.parent ?: return val path = transport.target.parent ?: return
if (path.fileSystem != fileSystem) return if (path.fileSystem != fileSystem) return
if (path.absolutePathString() != workdir.absolutePathString()) return if (path.name.path != workdir.name.path) return
// 立即刷新 // 立即刷新
reload(true) reload(true)
} }
@@ -123,19 +121,19 @@ class FileSystemViewPanel(
private fun enterTableSelectionFolder(row: Int = table.selectedRow) { private fun enterTableSelectionFolder(row: Int = table.selectedRow) {
if (row < 0 || isLoading.get()) return if (row < 0 || isLoading.get()) return
val attr = model.getAttr(row) val file = model.getFileObject(row)
if (attr.isFile) return if (file.isFile) return
// 当前工作目录 // 当前工作目录
val workdir = getWorkdir() val workdir = getWorkdir()
// 返回上级之后,选中上级目录 // 返回上级之后,选中上级目录
if (attr.name == "..") { if (row == 0 && model.hasParent) {
val workdirName = workdir.name val workdirName = workdir.name
nextReloadTickSelection(workdirName) nextReloadTickSelection(workdirName.baseName)
} }
changeWorkdir(attr.path) changeWorkdir(file)
} }
@@ -169,13 +167,13 @@ class FileSystemViewPanel(
bookmarkBtn.addActionListener { e -> bookmarkBtn.addActionListener { e ->
if (e.actionCommand.isNullOrBlank()) { if (e.actionCommand.isNullOrBlank()) {
if (bookmarkBtn.isBookmark) { if (bookmarkBtn.isBookmark) {
bookmarkBtn.deleteBookmark(workdir.toString()) bookmarkBtn.deleteBookmark(workdir.absolutePathString())
} else { } else {
bookmarkBtn.addBookmark(workdir.toString()) bookmarkBtn.addBookmark(workdir.absolutePathString())
} }
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
} else { } else {
changeWorkdir(fileSystem.getPath(e.actionCommand)) changeWorkdir(fileSystem.resolveFile(e.actionCommand))
} }
} }
@@ -194,14 +192,13 @@ class FileSystemViewPanel(
button.addActionListener(object : AbstractAction() { button.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) {
if (model.rowCount < 1) return if (model.rowCount < 1) return
val attr = model.getAttr(0) if (model.hasParent) return
if (attr !is FileSystemViewTableModel.ParentAttr) return
enterTableSelectionFolder(0) enterTableSelectionFolder(0)
} }
}) })
addPropertyChangeListener("workdir") { addPropertyChangeListener("workdir") {
button.isEnabled = model.rowCount > 0 && model.getAttr(0) is FileSystemViewTableModel.ParentAttr button.isEnabled = model.rowCount > 0 && model.hasParent
} }
return button return button
@@ -211,7 +208,7 @@ class FileSystemViewPanel(
// 创建成功之后需要修改和选中 // 创建成功之后需要修改和选中
registerNextReloadTick { registerNextReloadTick {
for (i in 0 until table.rowCount) { 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.addRowSelectionInterval(i, i)
table.scrollRectToVisible(table.getCellRect(i, 0, true)) table.scrollRectToVisible(table.getCellRect(i, 0, true))
consumer.accept(i) consumer.accept(i)
@@ -221,18 +218,19 @@ class FileSystemViewPanel(
} }
} }
private fun changeWorkdir(workdir: Path) { private fun changeWorkdir(workdir: FileObject) {
assertEventDispatchThread() assertEventDispatchThread()
nav.changeSelectedPath(workdir) nav.changeSelectedPath(workdir)
} }
fun renameTo(oldPath: Path, newPath: Path) { fun renameTo(oldPath: FileObject, newPath: FileObject) {
// 新建文件夹 // 新建文件夹
coroutineScope.launch { coroutineScope.launch {
if (requestLoading()) { if (requestLoading()) {
try { try {
Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE) oldPath.moveTo(newPath)
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
@@ -247,7 +245,7 @@ class FileSystemViewPanel(
} }
// 创建成功之后需要选中 // 创建成功之后需要选中
nextReloadTickSelection(newPath.name) nextReloadTickSelection(newPath.name.baseName)
// 立即刷新 // 立即刷新
reload() reload()
@@ -258,7 +256,7 @@ class FileSystemViewPanel(
coroutineScope.launch { coroutineScope.launch {
if (requestLoading()) { if (requestLoading()) {
try { try {
doNewFolderOrFile(getWorkdir().resolve(name), isFile) doNewFolderOrFile(getWorkdir().resolveFile(name), isFile)
} finally { } finally {
stopLoading() 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) { withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
owner, owner,
@@ -288,7 +286,7 @@ class FileSystemViewPanel(
// 创建文件夹 // 创建文件夹
withContext(Dispatchers.IO) { 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) { withContext(Dispatchers.Swing) {
if (it is Exception) { if (it is Exception) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
@@ -329,7 +327,7 @@ class FileSystemViewPanel(
fun reload(rememberSelection: Boolean = false) { fun reload(rememberSelection: Boolean = false) {
if (!requestLoading()) return if (!requestLoading()) return
if (fileSystem.isSFTP()) loadingPanel.start() if (fileSystem is MySftpFileSystem) loadingPanel.start()
val oldWorkdir = workdir val oldWorkdir = workdir
val path = nav.getSelectedPath() val path = nav.getSelectedPath()
@@ -338,7 +336,7 @@ class FileSystemViewPanel(
if (rememberSelection) { if (rememberSelection) {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
table.selectedRows.sortedDescending().map { model.getAttr(it).name } table.selectedRows.sortedDescending().map { model.getFileObject(it).name.baseName }
.forEach { nextReloadTickSelection(it) } .forEach { nextReloadTickSelection(it) }
} }
} }
@@ -347,7 +345,7 @@ class FileSystemViewPanel(
if (it is Exception) { if (it is Exception) {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
owner, ExceptionUtils.getMessage(it), owner, ExceptionUtils.getRootCauseMessage(it),
messageType = JOptionPane.ERROR_MESSAGE messageType = JOptionPane.ERROR_MESSAGE
) )
} }
@@ -367,34 +365,35 @@ class FileSystemViewPanel(
} finally { } finally {
stopLoading() stopLoading()
if (fileSystem.isSFTP()) { if (fileSystem is MySftpFileSystem) {
withContext(Dispatchers.Swing) { loadingPanel.stop() } withContext(Dispatchers.Swing) { loadingPanel.stop() }
} }
} }
} }
} }
private fun getHomeDirectory(): Path { private fun getHomeDirectory(): FileObject {
if (fileSystem.isSFTP()) { if (fileSystem is MySftpFileSystem) {
val fs = fileSystem as SftpFileSystem val host = fileSystem.getClientSession().getAttribute(SshClients.HOST_KEY)
val host = fs.session.getAttribute(SshClients.HOST_KEY) ?: return fs.defaultDir ?: return fileSystem.resolveFile(fileSystem.getDefaultDir())
val defaultDirectory = host.options.sftpDefaultDirectory val defaultDirectory = host.options.sftpDefaultDirectory
if (defaultDirectory.isNotBlank()) { if (defaultDirectory.isNotBlank()) {
return runCatching { fs.getPath(defaultDirectory) } return fileSystem.resolveFile(defaultDirectory)
.getOrElse { fs.defaultDir }
} }
return fs.defaultDir return fileSystem.resolveFile(fileSystem.getDefaultDir())
} }
if (sftp.defaultDirectory.isNotBlank()) { if (sftp.defaultDirectory.isNotBlank()) {
return runCatching { fileSystem.getPath(sftp.defaultDirectory) } val resolveFile = fileSystem.resolveFile("file://${sftp.defaultDirectory}")
.getOrElse { fileSystem.getPath(SystemUtils.USER_HOME) } 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 return workdir
} }

View File

@@ -3,6 +3,9 @@ package app.termora.sftp
import app.termora.* import app.termora.*
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction 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.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
@@ -11,14 +14,11 @@ import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
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.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils import org.apache.commons.vfs2.FileObject
import org.apache.sshd.sftp.client.SftpClient import org.apache.commons.vfs2.VFS
import org.apache.sshd.sftp.client.fs.SftpFileSystem import org.apache.commons.vfs2.provider.local.LocalFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath
import org.apache.sshd.sftp.client.fs.SftpPosixFileAttributes
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.Component import java.awt.Component
@@ -32,8 +32,12 @@ import java.awt.event.*
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.OutputStream 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.BasicFileAttributes
import java.nio.file.attribute.FileTime
import java.text.MessageFormat import java.text.MessageFormat
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -41,14 +45,14 @@ import java.util.regex.Pattern
import javax.swing.* import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableCellRenderer
import kotlin.collections.ArrayDeque import kotlin.collections.ArrayDeque
import kotlin.io.path.* import kotlin.io.path.absolutePathString
import kotlin.math.max import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Suppress("DuplicatedCode") @Suppress("DuplicatedCode", "CascadeIf")
class FileSystemViewTable( class FileSystemViewTable(
private val fileSystem: FileSystem, private val fileSystem: org.apache.commons.vfs2.FileSystem,
private val transportManager: TransportManager, private val transportManager: TransportManager,
private val coroutineScope: CoroutineScope private val coroutineScope: CoroutineScope
) : JTable(), Disposable { ) : JTable(), Disposable {
@@ -105,8 +109,8 @@ class FileSystemViewTable(
): Component { ): Component {
foreground = null foreground = null
val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getAttr(row).icon else null icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getFileIcon(row) else null
foreground = if (!isSelected && model.getAttr(row).isHidden) foreground = if (!isSelected && model.getFileObject(row).isHidden)
UIManager.getColor("textInactiveText") else foreground UIManager.getColor("textInactiveText") else foreground
return c return c
} }
@@ -141,10 +145,10 @@ class FileSystemViewTable(
} else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { } else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val row = table.selectedRow val row = table.selectedRow
if (row <= 0 || row >= table.rowCount) return if (row <= 0 || row >= table.rowCount) return
val attr = model.getAttr(row) val file = model.getFileObject(row)
if (attr.isDirectory) return 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)) { if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) {
val rows = selectedRows val rows = selectedRows
if (rows.contains(0)) return if (rows.contains(0)) return
val attrs = rows.map { model.getAttr(it) }.toTypedArray() val files = rows.map { model.getFileObject(it) }
val files = attrs.map { it.path }.toTypedArray()
deletePaths(files, false) deletePaths(files, false)
} else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) { } else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) {
fileSystemViewPanel.reload(true) fileSystemViewPanel.reload(true)
@@ -173,13 +176,15 @@ class FileSystemViewTable(
// 如果不是新增行,并且光标不在第一列,那么不允许 // 如果不是新增行,并且光标不在第一列,那么不允许
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false 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)) { if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
return data is FileSystemTableRowTransferable && data.source != table return data is FileSystemTableRowTransferable && data.source != table
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
return !fileSystem.isLocal() return fileSystem !is LocalFileSystem
} }
return false return false
@@ -190,27 +195,25 @@ class FileSystemViewTable(
// 如果不是新增行,并且光标不在第一列,那么不允许 // 如果不是新增行,并且光标不在第一列,那么不允许
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false 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) { if (!dropLocation.isInsertRow) {
targetWorkdir = model.getAttr(dropLocation.row).path targetWorkdir = model.getFileObject(dropLocation.row)
} }
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
if (data !is FileSystemTableRowTransferable) return false if (data !is FileSystemTableRowTransferable) return false
// 委托源表开始传输 // 委托源表开始传输
data.source.transfer(data.attrs.toTypedArray(), false, targetWorkdir) data.source.transfer(data.files, false, targetWorkdir)
return true return true
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*> val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return false if (files.isEmpty()) return false
val paths = files.filterIsInstance<File>() val paths = files.filterIsInstance<File>().map { VFS.getManager().resolveFile(it.toURI()) }
.map { FileSystemViewTableModel.Attr(it.toPath()) }
.toTypedArray()
if (paths.isEmpty()) return false if (paths.isEmpty()) return false
val localTarget = sftpPanel.getLocalTarget() val localTarget = sftpPanel.getLocalTarget()
val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false
@@ -226,9 +229,9 @@ class FileSystemViewTable(
} }
override fun createTransferable(c: JComponent?): Transferable? { override fun createTransferable(c: JComponent?): Transferable? {
val attrs = table.selectedRows.filter { it != 0 }.map { model.getAttr(it) } val files = table.selectedRows.filter { it != 0 }.map { model.getFileObject(it) }
if (attrs.isEmpty()) return null if (files.isEmpty()) return null
return FileSystemTableRowTransferable(table, attrs) return FileSystemTableRowTransferable(table, files)
} }
} }
@@ -243,7 +246,7 @@ class FileSystemViewTable(
} }
private fun navigate(row: Int, c: Char): Boolean { 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)) { if (name.startsWith(c, true)) {
clearSelection() clearSelection()
addRowSelectionInterval(row, row) 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) { private fun showContextMenu(rows: IntArray, e: MouseEvent) {
val attrs = rows.map { model.getAttr(it) }.toTypedArray() val files = rows.map { model.getFileObject(it) }
val files = attrs.map { it.path }.toTypedArray()
val hasParent = rows.contains(0) val hasParent = rows.contains(0)
val popupMenu = FlatPopupMenu() val popupMenu = FlatPopupMenu()
@@ -279,13 +272,13 @@ class FileSystemViewTable(
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer")) val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
// 编辑 // 编辑
val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit")) 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() popupMenu.addSeparator()
// 复制路径 // 复制路径
val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path")) val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path"))
// 如果是本地,那么支持打开本地路径 // 如果是本地,那么支持打开本地路径
if (fileSystem.isLocal()) { if (fileSystem is LocalFileSystem) {
popupMenu.add( popupMenu.add(
I18n.getString( I18n.getString(
"termora.transport.table.contextmenu.open-in-folder", "termora.transport.table.contextmenu.open-in-folder",
@@ -294,7 +287,7 @@ class FileSystemViewTable(
else I18n.getString("termora.folder") else I18n.getString("termora.folder")
) )
).addActionListener { ).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")) val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete"))
// rm -rf // rm -rf
val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction)) val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction))
// 只有 SFTP 可以 // 只有 SFTP 可以
if (!fileSystem.isSFTP()) { rmrf.isVisible = fileSystem is MySftpFileSystem
rmrf.isVisible = false
}
// 修改权限 // 修改权限
val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions")) val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
permission.isEnabled = false permission.isEnabled = false
// 如果是本地系统文件,那么不允许修改权限,用户应该自己修改 // 如果是本地系统文件,那么不允许修改权限,用户应该自己修改
if (fileSystem.isSFTP() && rows.isNotEmpty()) { if (fileSystem is MySftpFileSystem && rows.isNotEmpty()) {
permission.isEnabled = true permission.isEnabled = true
} }
popupMenu.addSeparator() popupMenu.addSeparator()
@@ -360,23 +350,25 @@ class FileSystemViewTable(
}) })
copyPath.addActionListener { copyPath.addActionListener {
val sb = StringBuilder() val sb = StringBuilder()
attrs.forEach { sb.append(it.path.absolutePathString()).appendLine() } files.forEach { sb.append(it.absolutePathString()).appendLine() }
sb.deleteCharAt(sb.length - 1) sb.deleteCharAt(sb.length - 1)
toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null) toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null)
} }
edit.addActionListener { if (files.isNotEmpty()) editFiles(files) } edit.addActionListener { if (files.isNotEmpty()) editFiles(files) }
permission.addActionListener(object : AbstractAction() { permission.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) {
val last = attrs.last() val last = files.last()
if (last !is MySftpFileObject) return
val dialog = PosixFilePermissionDialog( val dialog = PosixFilePermissionDialog(
SwingUtilities.getWindowAncestor(table), SwingUtilities.getWindowAncestor(table),
last.posixFilePermissions model.getFilePermissions(last)
) )
val permissions = dialog.open() ?: return val permissions = dialog.open() ?: return
if (fileSystemViewPanel.requestLoading()) { if (fileSystemViewPanel.requestLoading()) {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
val c = runCatching { Files.setPosixFilePermissions(last.path, permissions) }.onFailure { val c = runCatching { last.setPosixFilePermissions(permissions) }.onFailure {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
owner, owner,
@@ -398,7 +390,7 @@ class FileSystemViewTable(
} }
}) })
refresh.addActionListener { fileSystemViewPanel.reload() } refresh.addActionListener { fileSystemViewPanel.reload() }
transfer.addActionListener { transfer(attrs) } transfer.addActionListener { transfer(files) }
if (rows.isEmpty() || hasParent) { if (rows.isEmpty() || hasParent) {
transfer.isEnabled = false transfer.isEnabled = false
@@ -419,13 +411,13 @@ class FileSystemViewTable(
private fun renameSelection() { private fun renameSelection() {
val index = selectedRow val index = selectedRow
if (index < 0) return if (index < 0) return
val attr = model.getAttr(index) val file = model.getFileObject(index)
val text = OptionPane.showInputDialog( val text = OptionPane.showInputDialog(
owner, owner,
value = attr.name, value = file.name.baseName,
title = I18n.getString("termora.transport.table.contextmenu.rename") title = I18n.getString("termora.transport.table.contextmenu.rename")
) ?: return ) ?: return
if (text.isBlank() || text == attr.name) return if (text.isBlank() || text == file.name.baseName) return
if (model.getPathNames().contains(text)) { if (model.getPathNames().contains(text)) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
owner, owner,
@@ -434,10 +426,11 @@ class FileSystemViewTable(
) )
return 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 (files.isEmpty()) return
if (SystemInfo.isLinux) { if (SystemInfo.isLinux) {
@@ -455,10 +448,11 @@ class FileSystemViewTable(
for (file in files) { for (file in files) {
val dir = Application.createSubTemporaryDir() 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) val newTransport = createTransport(file, false, 0L)
.apply { target = path } .apply { this.target = target }
transportManager.addTransportListener(object : TransportListener { transportManager.addTransportListener(object : TransportListener {
override fun onTransportChanged(transport: Transport) { override fun onTransportChanged(transport: Transport) {
@@ -467,7 +461,7 @@ class FileSystemViewTable(
transportManager.removeTransportListener(this) transportManager.removeTransportListener(this)
if (transport.status != TransportStatus.Done) return 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 { try {
val p = localPath.absolutePathString()
if (sftp.editCommand.isNotBlank()) { if (sftp.editCommand.isNotBlank()) {
ProcessBuilder( ProcessBuilder(parseCommand(MessageFormat.format(sftp.editCommand, p))).start()
parseCommand(
MessageFormat.format(
sftp.editCommand,
localPath.absolutePathString()
)
)
).start()
} else if (SystemInfo.isMacOS) { } else if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start() ProcessBuilder("open", "-a", "TextEdit", p).start()
} else if (SystemInfo.isWindows) { } else if (SystemInfo.isWindows) {
ProcessBuilder("notepad", localPath.absolutePathString()).start() ProcessBuilder("notepad", p).start()
} else { } else {
return return
} }
@@ -501,13 +489,17 @@ class FileSystemViewTable(
return return
} }
var lastModifiedTime = localPath.getLastModifiedTime().toMillis() var lastModifiedTime = localPath.content.lastModifiedTime
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
while (coroutineScope.isActive) { while (coroutineScope.isActive) {
try { 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) { if (nowModifiedTime != lastModifiedTime) {
lastModifiedTime = nowModifiedTime lastModifiedTime = nowModifiedTime
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
@@ -562,7 +554,7 @@ class FileSystemViewTable(
fileSystemViewPanel.newFolderOrFile(text, isFile) 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( if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this), SwingUtilities.getWindowAncestor(this),
I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"), I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"),
@@ -576,10 +568,10 @@ class FileSystemViewTable(
return return
} }
coroutineScope.launch { coroutineScope.launch(Dispatchers.IO) {
runCatching { runCatching {
if (fileSystem.isSFTP()) { if (fileSystem is MySftpFileSystem) {
deleteSftpPaths(paths, rm) deleteSftpPaths(paths, rm)
} else { } else {
deleteRecursively(paths) deleteRecursively(paths)
@@ -590,97 +582,74 @@ class FileSystemViewTable(
} }
} }
withContext(Dispatchers.Swing) {
// 停止加载 // 停止加载
fileSystemViewPanel.stopLoading() fileSystemViewPanel.stopLoading()
// 刷新 // 刷新
fileSystemViewPanel.reload() fileSystemViewPanel.reload()
}
} }
} }
private fun deleteSftpPaths(paths: Array<Path>, rm: Boolean = false) { private fun deleteSftpPaths(files: List<FileObject>, rm: Boolean = false) {
val fs = this.fileSystem as SftpFileSystem
if (rm) { if (rm) {
for (path in paths) { val session = (this.fileSystem as MySftpFileSystem).getClientSession()
fs.session.executeRemoteCommand( for (path in files) {
session.executeRemoteCommand(
"rm -rf '${path.absolutePathString()}'", "rm -rf '${path.absolutePathString()}'",
OutputStream.nullOutputStream(), OutputStream.nullOutputStream(),
Charsets.UTF_8 Charsets.UTF_8
) )
} }
} else { } else {
fs.client.use { deleteRecursively(files)
for (path in paths) {
deleteRecursivelySFTP(path as SftpPath, it)
}
}
} }
} }
private fun deleteRecursively(paths: Array<Path>) { private fun deleteRecursively(files: List<FileObject>) {
for (path in paths) { for (path in files) {
FileUtils.deleteQuietly(path.toFile()) 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( private fun transfer(
attrs: Array<FileSystemViewTableModel.Attr>, files: List<FileObject>,
fromLocalSystem: Boolean = false, fromLocalSystem: Boolean = false,
targetWorkdir: Path? = null targetWorkdir: FileObject? = null
) { ) {
assertEventDispatchThread() assertEventDispatchThread()
val target = sftpPanel.getTarget(table) ?: return val target = sftpPanel.getTarget(table) ?: return
val table = target.getData(SFTPDataProviders.FileSystemViewTable) ?: return val table = target.getData(SFTPDataProviders.FileSystemViewTable) ?: return
var overwriteAll = false var isApplyAll = false
var lastAction = Action.Overwrite
for (attr in attrs) { for (file in files) {
if (!isApplyAll && (targetWorkdir == null || target.getWorkdir() == targetWorkdir)) {
if (!overwriteAll) { val targetAttr = 0.rangeUntil(table.model.rowCount).map { table.model.getFileObject(it) }
val targetAttr = 0.rangeUntil(table.model.rowCount).map { table.model.getAttr(it) } .find { it.name.baseName == file.name.baseName }
.find { it.name == attr.name }
if (targetAttr != null) { if (targetAttr != null) {
val askTransfer = askTransfer(attr, targetAttr) val askTransfer = askTransfer(file, targetAttr)
if (askTransfer.option != JOptionPane.YES_OPTION) { if (askTransfer.option != JOptionPane.YES_OPTION) {
continue continue
} }
if (askTransfer.action == AskTransfer.Action.Skip) { if (askTransfer.action == Action.Skip) {
if (askTransfer.applyAll) break if (askTransfer.applyAll) break
continue continue
} else if (askTransfer.action == AskTransfer.Action.Overwrite) { } else {
overwriteAll = askTransfer.applyAll lastAction = askTransfer.action
isApplyAll = askTransfer.applyAll
} }
} }
} }
coroutineScope.launch { coroutineScope.launch {
try { try {
doTransfer(attr, fromLocalSystem, targetWorkdir) doTransfer(file, lastAction, fromLocalSystem, targetWorkdir)
} catch (e: Exception) { } catch (e: Exception) {
if (log.isErrorEnabled) { if (log.isErrorEnabled) {
log.error(e.message, e) log.error(e.message, e)
@@ -698,13 +667,14 @@ class FileSystemViewTable(
) { ) {
enum class Action { enum class Action {
Overwrite, Overwrite,
Append,
Skip Skip
} }
} }
private fun askTransfer( private fun askTransfer(
sourceAttr: FileSystemViewTableModel.Attr, sourceFile: FileObject,
targetAttr: FileSystemViewTableModel.Attr targetFile: FileObject
): AskTransfer { ): AskTransfer {
val formMargin = "7dlu" val formMargin = "7dlu"
val layout = FormLayout( val layout = FormLayout(
@@ -715,34 +685,29 @@ class FileSystemViewTable(
val iconSize = 36 val iconSize = 36
val targetIcon = if (SystemInfo.isWindows) val targetIcon = if (SystemInfo.isWindows)
NativeFileIcons.getIcon(targetAttr.name, targetAttr.isFile, iconSize, iconSize).first model.getFileIcon(targetFile, iconSize, iconSize)
else if (targetAttr.isDirectory) { else if (targetFile.isFolder) {
FlatSVGIcon(Icons.folder.name, iconSize, iconSize) FlatSVGIcon(Icons.folder.name, iconSize, iconSize)
} else { } else {
FlatSVGIcon(Icons.file.name, iconSize, iconSize) FlatSVGIcon(Icons.file.name, iconSize, iconSize)
} }
val sourceIcon = if (SystemInfo.isWindows) val sourceIcon = if (SystemInfo.isWindows)
NativeFileIcons.getIcon(sourceAttr.name, sourceAttr.isFile, iconSize, iconSize).first model.getFileIcon(sourceFile, iconSize, iconSize)
else if (sourceAttr.isDirectory) { else if (sourceFile.isFolder) {
FlatSVGIcon(Icons.folder.name, iconSize, iconSize) FlatSVGIcon(Icons.folder.name, iconSize, iconSize)
} else { } else {
FlatSVGIcon(Icons.file.name, iconSize, iconSize) 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( val sourceModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(sourceFile), "-")
Date(targetAttr.modified), val targetModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(targetFile), "-")
"yyyy/MM/dd HH:mm"
) else "-"
val actionsComBoBox = JComboBox<AskTransfer.Action>() val actionsComBoBox = JComboBox<Action>()
actionsComBoBox.addItem(AskTransfer.Action.Overwrite) actionsComBoBox.addItem(Action.Overwrite)
actionsComBoBox.addItem(AskTransfer.Action.Skip) actionsComBoBox.addItem(Action.Append)
actionsComBoBox.addItem(Action.Skip)
actionsComBoBox.renderer = object : DefaultListCellRenderer() { actionsComBoBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent( override fun getListCellRendererComponent(
list: JList<*>?, list: JList<*>?,
@@ -752,10 +717,12 @@ class FileSystemViewTable(
cellHasFocus: Boolean cellHasFocus: Boolean
): Component { ): Component {
var text = value?.toString() ?: StringUtils.EMPTY var text = value?.toString() ?: StringUtils.EMPTY
if (value == AskTransfer.Action.Overwrite) { if (value == Action.Overwrite) {
text = I18n.getString("termora.transport.sftp.already-exists.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") 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) return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
} }
@@ -781,11 +748,11 @@ class FileSystemViewTable(
val step = 2 val step = 2
val panel = FormBuilder.create().layout(layout) val panel = FormBuilder.create().layout(layout)
// tip // tip
.add(JLabel(warningIcon)).xy(1, rows, "center, fill") .add(JLabel(warningIcon)).xy(1, rows)
.add(ttBox).xyw(3, rows, 3).apply { rows += step } .add(ttBox).xyw(3, rows, 3).apply { rows += step }
// name // name
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.name")}:")).xy(1, rows) .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 // separator
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step } .addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
// Destination // Destination
@@ -813,12 +780,13 @@ class FileSystemViewTable(
owner, panel, owner, panel,
messageType = JOptionPane.PLAIN_MESSAGE, messageType = JOptionPane.PLAIN_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION, optionType = JOptionPane.OK_CANCEL_OPTION,
title = sourceAttr.name, title = sourceFile.name.baseName,
initialValue = JOptionPane.YES_OPTION, initialValue = JOptionPane.YES_OPTION,
) { ) {
it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height) 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 applyAll = applyAllCheckbox.isSelected
) )
@@ -829,9 +797,10 @@ class FileSystemViewTable(
* 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止 * 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止
*/ */
private fun doTransfer( private fun doTransfer(
attr: FileSystemViewTableModel.Attr, file: FileObject,
action: Action,
fromLocalSystem: Boolean, fromLocalSystem: Boolean,
targetWorkdir: Path? targetWorkdir: FileObject?
) { ) {
val sftpPanel = this.sftpPanel val sftpPanel = this.sftpPanel
val target = sftpPanel.getTarget(table) ?: return val target = sftpPanel.getTarget(table) ?: return
@@ -841,9 +810,14 @@ class FileSystemViewTable(
*/ */
val adder = object { val adder = object {
fun add(transport: Transport): Boolean { fun add(transport: Transport): Boolean {
if (action == Action.Append) {
transport.mode = StandardOpenOption.APPEND
} else {
transport.mode = StandardOpenOption.TRUNCATE_EXISTING
}
return addTransport( return addTransport(
sftpPanel, sftpPanel,
if (fromLocalSystem) attr.path.parent else null, if (fromLocalSystem) file.parent else null,
target, target,
targetWorkdir, targetWorkdir,
transport transport
@@ -851,8 +825,8 @@ class FileSystemViewTable(
} }
} }
if (attr.isFile) { if (file.isFile) {
adder.add(createTransport(attr.path, false, 0).apply { scanned() }) adder.add(createTransport(file, false, 0).apply { scanned() })
return return
} }
@@ -860,26 +834,26 @@ class FileSystemViewTable(
var isTerminate = false var isTerminate = false
try { try {
walk(attr.path, object : FileVisitor<Path> { walk(file, object : FileVisitor<FileObject> {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { override fun preVisitDirectory(dir: FileObject, attrs: BasicFileAttributes): FileVisitResult {
val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L) val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L)
.apply { queue.addLast(this) } .apply { queue.addLast(this) }
if (adder.add(transport)) return FileVisitResult.CONTINUE if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true } 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 if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS
val transport = createTransport(file, false, queue.last().id).apply { scanned() } val transport = createTransport(file, false, queue.last().id).apply { scanned() }
if (adder.add(transport)) return FileVisitResult.CONTINUE if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true } 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 return FileVisitResult.CONTINUE
} }
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { override fun postVisitDirectory(dir: FileObject, exc: IOException?): FileVisitResult {
// 标记为扫描完毕 // 标记为扫描完毕
queue.removeLast().scanned() queue.removeLast().scanned()
return FileVisitResult.CONTINUE return FileVisitResult.CONTINUE
@@ -890,6 +864,13 @@ class FileSystemViewTable(
if (log.isErrorEnabled) { if (log.isErrorEnabled) {
log.error(e.message, e) log.error(e.message, e)
} }
SwingUtilities.invokeLater {
OptionPane.showMessageDialog(
owner,
message = ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
isTerminate = true 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( private fun walk(
dir: Path, dir: FileObject,
attr: SftpPosixFileAttributes, visitor: FileVisitor<FileObject>,
visitor: FileVisitor<Path>,
client: SftpClient
): FileVisitResult { ): FileVisitResult {
if (visitor.preVisitDirectory(dir, attr) == FileVisitResult.TERMINATE) { // clear cache
if (visitor.preVisitDirectory(dir, EmptyBasicFileAttributes.INSTANCE) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE return FileVisitResult.TERMINATE
} }
val paths = client.readDir(dir.absolutePathString()) for (e in dir.children) {
for (e in paths) { if (e.name.baseName == ".." || e.name.baseName == ".") continue
if (e.filename == ".." || e.filename == ".") continue if (e.isFolder) {
if (e.attributes.isDirectory) { if (walk(dir.resolveFile(e.name.baseName), visitor) == FileVisitResult.TERMINATE) {
if (walkSFTP(dir.resolve(e.filename), attr, visitor, client) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE return FileVisitResult.TERMINATE
} }
} else { } 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) { if (result == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE return FileVisitResult.TERMINATE
} else if (result == FileVisitResult.SKIP_SUBTREE) { } else if (result == FileVisitResult.SKIP_SUBTREE) {
@@ -945,19 +919,22 @@ class FileSystemViewTable(
private fun addTransport( private fun addTransport(
sftpPanel: SFTPPanel, sftpPanel: SFTPPanel,
sourceWorkdir: Path?, sourceWorkdir: FileObject?,
target: FileSystemViewPanel, target: FileSystemViewPanel,
targetWorkdir: Path?, targetWorkdir: FileObject?,
transport: Transport transport: Transport
): Boolean { ): Boolean {
return try { return try {
sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport) sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport)
} catch (e: Exception) { } catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
false false
} }
} }
private fun createTransport(source: Path, isDirectory: Boolean, parentId: Long): Transport { private fun createTransport(source: FileObject, isDirectory: Boolean, parentId: Long): Transport {
val transport = Transport( val transport = Transport(
source = source, source = source,
target = source, target = source,
@@ -965,7 +942,7 @@ class FileSystemViewTable(
isDirectory = isDirectory, isDirectory = isDirectory,
) )
if (transport.isFile) { if (transport.isFile) {
transport.filesize.addAndGet(source.fileSize()) transport.filesize.addAndGet(source.content.size)
} }
return transport return transport
} }
@@ -973,7 +950,7 @@ class FileSystemViewTable(
private class FileSystemTableRowTransferable( private class FileSystemTableRowTransferable(
val source: FileSystemViewTable, val source: FileSystemViewTable,
val attrs: List<FileSystemViewTableModel.Attr> val files: List<FileObject>
) : Transferable { ) : Transferable {
companion object { companion object {
val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable") 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.I18n
import app.termora.NativeStringComparator import app.termora.NativeStringComparator
import app.termora.formatBytes import app.termora.formatBytes
import app.termora.vfs2.sftp.MySftpFileObject
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils 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 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.PosixFilePermission
import java.nio.file.attribute.PosixFilePermissions import java.nio.file.attribute.PosixFilePermissions
import java.util.* import java.util.*
import javax.swing.Icon
import javax.swing.SwingUtilities
import javax.swing.table.DefaultTableModel import javax.swing.table.DefaultTableModel
import kotlin.io.path.*
class FileSystemViewTableModel : DefaultTableModel() { class FileSystemViewTableModel : DefaultTableModel() {
@@ -29,9 +32,10 @@ class FileSystemViewTableModel : DefaultTableModel() {
const val COLUMN_LAST_MODIFIED_TIME = 3 const val COLUMN_LAST_MODIFIED_TIME = 3
const val COLUMN_ATTRS = 4 const val COLUMN_ATTRS = 4
const val COLUMN_OWNER = 5 const val COLUMN_OWNER = 5
private val log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java) 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>() val result = mutableSetOf<PosixFilePermission>()
// 将十进制权限转换为八进制字符串 // 将十进制权限转换为八进制字符串
@@ -68,21 +72,67 @@ class FileSystemViewTableModel : DefaultTableModel() {
} }
} }
override fun getValueAt(row: Int, column: Int): Any { var hasParent: Boolean = false
val attr = getAttr(row) private set
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
COLUMN_ATTRS -> attr.permissions override fun getValueAt(row: Int, column: Int): Any {
COLUMN_OWNER -> attr.owner 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 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>> { override fun getDataVector(): Vector<Vector<Any>> {
@@ -100,14 +150,14 @@ class FileSystemViewTableModel : DefaultTableModel() {
} }
} }
fun getAttr(row: Int): Attr { fun getFileObject(row: Int): FileObject {
return super.getValueAt(row, 0) as Attr return super.getValueAt(row, 0) as FileObject
} }
fun getPathNames(): Set<String> { fun getPathNames(): Set<String> {
val names = linkedSetOf<String>() val names = linkedSetOf<String>()
for (i in 0 until rowCount) { for (i in 0 until rowCount) {
names.add(getAttr(i).name) names.add(getFileObject(i).name.baseName)
} }
return names return names
} }
@@ -129,144 +179,40 @@ class FileSystemViewTableModel : DefaultTableModel() {
return false return false
} }
suspend fun reload(dir: Path, useFileHiding: Boolean) { suspend fun reload(dir: FileObject, useFileHiding: Boolean) {
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding) log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding)
} }
val attrs = mutableListOf<Attr>() val files = mutableListOf<FileObject>()
if (dir.parent != null) {
attrs.add(ParentAttr(dir.parent))
}
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Files.list(dir).use { paths -> dir.refresh()
for (path in paths) { for (file in dir.children) {
val attr = if (path is SftpPath) SftpAttr(path) else Attr(path) if (useFileHiding && file.isHidden) continue
if (useFileHiding && attr.isHidden) continue files.add(file)
attrs.add(attr)
}
} }
} }
attrs.sortWith(compareBy<Attr> { !it.isDirectory }.thenComparing { a, b -> files.sortWith(compareBy<FileObject> { !it.isFolder }.thenComparing { a, b ->
NativeStringComparator.getInstance().compare( NativeStringComparator.getInstance().compare(
a.name, a.name.baseName,
b.name b.name.baseName
) )
}) })
hasParent = dir.parent != null
if (hasParent) {
files.addFirst(dir.parent)
}
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
while (rowCount > 0) removeRow(0) 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.*
import app.termora.actions.DataProvider import app.termora.actions.DataProvider
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout 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.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils 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.SshClient
import org.apache.sshd.client.session.ClientSession 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.JXBusyLabel
import org.jdesktop.swingx.JXHyperlink import org.jdesktop.swingx.JXHyperlink
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -46,18 +48,16 @@ class SFTPFileSystemViewPanel(
private var state = State.Initialized private var state = State.Initialized
private val cardLayout = CardLayout() private val cardLayout = CardLayout()
private val cardPanel = JPanel(cardLayout) private val cardPanel = JPanel(cardLayout)
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
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 isDisposed = AtomicBoolean(false) private val isDisposed = AtomicBoolean(false)
private val that = this private val that = this
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val properties get() = Database.getDatabase().properties private val properties get() = Database.getDatabase().properties
private var client: SshClient? = null private var client: SshClient? = null
private var session: ClientSession? = null private var session: ClientSession? = null
private var fileSystem: SftpFileSystem? = null
private var fileSystemPanel: FileSystemViewPanel? = null private var fileSystemPanel: FileSystemViewPanel? = null
@@ -111,11 +111,17 @@ class SFTPFileSystemViewPanel(
closeIO() closeIO()
val mySftpFileSystem: FileSystem
try { try {
val owner = SwingUtilities.getWindowAncestor(that) val owner = SwingUtilities.getWindowAncestor(that)
val client = SshClients.openClient(thisHost, owner).apply { client = this } val client = SshClients.openClient(thisHost, owner).apply { client = this }
val session = SshClients.openSession(thisHost, client).apply { session = 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() } session.addCloseFutureListener { onClose() }
} catch (e: Exception) { } catch (e: Exception) {
closeIO() closeIO()
@@ -126,11 +132,10 @@ class SFTPFileSystemViewPanel(
throw IllegalStateException("Closed") throw IllegalStateException("Closed")
} }
val fileSystem = this.fileSystem ?: return
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
state = State.Connected state = State.Connected
val fileSystemPanel = FileSystemViewPanel(thisHost, fileSystem, transportManager, coroutineScope) val fileSystemPanel = FileSystemViewPanel(thisHost, mySftpFileSystem, transportManager, coroutineScope)
cardPanel.add(fileSystemPanel, State.Connected.name) cardPanel.add(fileSystemPanel, State.Connected.name)
cardLayout.show(cardPanel, State.Connected.name) cardLayout.show(cardPanel, State.Connected.name)
that.fileSystemPanel = fileSystemPanel that.fileSystemPanel = fileSystemPanel
@@ -157,7 +162,6 @@ class SFTPFileSystemViewPanel(
fileSystemPanel?.let { Disposer.dispose(it) } fileSystemPanel?.let { Disposer.dispose(it) }
fileSystemPanel = null fileSystemPanel = null
runCatching { IOUtils.closeQuietly(fileSystem) }
runCatching { IOUtils.closeQuietly(session) } runCatching { IOUtils.closeQuietly(session) }
runCatching { IOUtils.closeQuietly(client) } 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.actions.DataProviderSupport
import app.termora.findeverywhere.FindEverywhereProvider import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.terminal.DataKey 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 okio.withLock
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils 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.BorderLayout
import java.awt.event.ComponentAdapter import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent import java.awt.event.ComponentEvent
import java.nio.file.FileSystem
import java.nio.file.FileSystems import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.* 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 { class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val transportTable = TransportTable() private val transportTable = TransportTable()
private val transportManager get() = transportTable.model private val transportManager get() = transportTable.model
private val dataProviderSupport = DataProviderSupport() private val dataProviderSupport = DataProviderSupport()
private val leftComponent = SFTPTabbed(transportManager) private val leftComponent = SFTPTabbed(transportManager)
private val rightComponent = SFTPTabbed(transportManager) private val rightComponent = SFTPTabbed(transportManager)
private val localHost = Host(
id = "local",
name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local,
)
init { init {
initViews() initViews()
@@ -87,11 +90,10 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
leftComponent.addTab( leftComponent.addTab(
I18n.getString("termora.transport.local"), I18n.getString("termora.transport.local"),
FileSystemViewPanel( FileSystemViewPanel(
Host( localHost,
id = "local", VFS.getManager().resolveFile("file:///${SystemUtils.USER_HOME}").fileSystem,
name = I18n.getString("termora.transport.local"), transportManager,
protocol = Protocol.Local, coroutineScope
), FileSystems.getDefault(), transportManager
) )
) )
leftComponent.setTabClosable(0, false) leftComponent.setTabClosable(0, false)
@@ -165,9 +167,9 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
*/ */
fun addTransport( fun addTransport(
source: JComponent, source: JComponent,
sourceWorkdir: Path?, sourceWorkdir: FileObject?,
target: FileSystemViewPanel, target: FileSystemViewPanel,
targetWorkdir: Path?, targetWorkdir: FileObject?,
transport: Transport transport: Transport
): Boolean { ): Boolean {
@@ -175,15 +177,12 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
as? FileSystemViewPanel ?: return false as? FileSystemViewPanel ?: return false
val targetPanel = target as? FileSystemViewPanel ?: return false val targetPanel = target as? FileSystemViewPanel ?: return false
if (sourcePanel.isDisposed || targetPanel.isDisposed) return false if (sourcePanel.isDisposed || targetPanel.isDisposed) return false
val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir()).absolutePathString() val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir())
val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir()).absolutePathString() val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir())
val targetFileSystem = targetPanel.fileSystem val sourcePath = transport.source
val sourcePath = transport.source.absolutePathString()
transport.target = targetFileSystem.getPath( val relativeName = mySourceWorkdir.name.getRelativeName(sourcePath.name)
myTargetWorkdir, transport.target = myTargetWorkdir.resolveFile(relativeName)
StringUtils.removeStart(sourcePath, mySourceWorkdir)
)
return transportManager.addTransport(transport) return transportManager.addTransport(transport)
@@ -212,4 +211,8 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
return dataProviderSupport.getData(dataKey) 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.JToolBar
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import javax.swing.UIManager import javax.swing.UIManager
import kotlin.io.path.absolutePathString
import kotlin.math.max import kotlin.math.max
@Suppress("DuplicatedCode") @Suppress("DuplicatedCode")

View File

@@ -6,18 +6,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.net.io.Util import org.apache.commons.net.io.Util
import org.apache.commons.vfs2.FileObject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.file.Files import java.nio.file.StandardOpenOption
import java.nio.file.Path
import java.nio.file.attribute.BasicFileAttributeView
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong 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 { enum class TransportStatus {
Ready, 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 { companion object {
@@ -154,7 +156,7 @@ class Transport(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
if (!target.exists()) { if (!target.exists()) {
target.createDirectories() target.createFolder()
} }
} catch (e: FileAlreadyExistsException) { } catch (e: FileAlreadyExistsException) {
if (log.isWarnEnabled) { if (log.isWarnEnabled) {
@@ -169,8 +171,8 @@ class Transport(
} }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val input = Files.newInputStream(source) val input = source.content.inputStream
val output = Files.newOutputStream(target) val output = target.content.getOutputStream(mode == StandardOpenOption.APPEND)
try { try {
@@ -209,8 +211,7 @@ class Transport(
private fun preserveModificationTime() { private fun preserveModificationTime() {
// 设置修改时间 // 设置修改时间
if (isPreserveModificationTime) { if (isPreserveModificationTime) {
Files.getFileAttributeView(target, BasicFileAttributeView::class.java) target.content.lastModifiedTime = source.content.lastModifiedTime
.setTimes(source.getLastModifiedTime(), source.getLastModifiedTime(), null)
} }
} }

View File

@@ -3,12 +3,11 @@ package app.termora.sftp
import app.termora.I18n import app.termora.I18n
import app.termora.formatBytes import app.termora.formatBytes
import app.termora.formatSeconds import app.termora.formatSeconds
import org.apache.commons.io.file.PathUtils import app.termora.vfs2.sftp.MySftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpFileSystem import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder
import org.apache.commons.vfs2.FileObject
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import java.nio.file.Path
import kotlin.io.path.absolutePathString
class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) { class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) {
val transport get() = userObject as 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 (transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0
return when (column) { return when (column) {
TransportTableModel.COLUMN_NAME -> PathUtils.getFileNameString(transport.source) TransportTableModel.COLUMN_NAME -> transport.source.name.baseName
TransportTableModel.COLUMN_STATUS -> formatStatus(transport) TransportTableModel.COLUMN_STATUS -> formatStatus(transport)
TransportTableModel.COLUMN_SIZE -> size() TransportTableModel.COLUMN_SIZE -> size()
TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-" TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-"
@@ -31,12 +30,14 @@ class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode
} }
} }
private fun formatPath(path: Path): String { private fun formatPath(file: FileObject): String {
if (path.fileSystem.isSFTP()) { if (file.fileSystem is MySftpFileSystem) {
val hostname = ((path.fileSystem as SftpFileSystem).session as JGitClientSession).hostConfigEntry.hostName val session = MySftpFileSystemConfigBuilder.getInstance()
return hostname + ":" + path.absolutePathString() .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 { 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.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.message2=Select a task to do
termora.transport.sftp.already-exists.overwrite=Overwrite 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.skip=Skip
termora.transport.sftp.already-exists.apply-all=Apply all termora.transport.sftp.already-exists.apply-all=Apply all
termora.transport.sftp.already-exists.name=Name 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.message1=此文件夹已包含一下名称的对象
termora.transport.sftp.already-exists.message2=请选择要执行的操作 termora.transport.sftp.already-exists.message2=请选择要执行的操作
termora.transport.sftp.already-exists.overwrite=覆盖 termora.transport.sftp.already-exists.overwrite=覆盖
termora.transport.sftp.already-exists.append=追加
termora.transport.sftp.already-exists.skip=跳过 termora.transport.sftp.already-exists.skip=跳过
termora.transport.sftp.already-exists.apply-all=应用全部 termora.transport.sftp.already-exists.apply-all=应用全部
termora.transport.sftp.already-exists.name=名称 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.message1=此資料夾已包含一下名稱的對象
termora.transport.sftp.already-exists.message2=請選擇要執行的操作 termora.transport.sftp.already-exists.message2=請選擇要執行的操作
termora.transport.sftp.already-exists.overwrite=覆蓋 termora.transport.sftp.already-exists.overwrite=覆蓋
termora.transport.sftp.already-exists.append=追加
termora.transport.sftp.already-exists.skip=跳過 termora.transport.sftp.already-exists.skip=跳過
termora.transport.sftp.already-exists.apply-all=應用全部 termora.transport.sftp.already-exists.apply-all=應用全部
termora.transport.sftp.already-exists.name=名稱 termora.transport.sftp.already-exists.name=名稱

View File

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

View File

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