diff --git a/THIRDPARTY b/THIRDPARTY index 965e9a1..8b0672c 100644 --- a/THIRDPARTY +++ b/THIRDPARTY @@ -22,6 +22,10 @@ commons-compress Apache License 2.0 https://github.com/apache/commons-compress/blob/master/LICENSE.txt +commons-vfs2 +Apache License 2.0 +https://github.com/apache/commons-vfs/blob/master/LICENSE.txt + commons-io Apache License 2.0 https://github.com/apache/commons-io/blob/master/LICENSE.txt diff --git a/build.gradle.kts b/build.gradle.kts index 534585c..ebb1bfc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(libs.commons.net) implementation(libs.commons.text) implementation(libs.commons.compress) + implementation(libs.commons.vfs2) { exclude(group = "*", module = "*") } implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.coroutines.core) @@ -125,6 +126,7 @@ application { "-XX:+ZUncommit", "-XX:+ZGenerational", "-XX:ZUncommitDelay=60", + "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" ) if (os.isMacOsX) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ad92c2..4776cca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ commons-csv = "1.14.0" commons-net = "3.11.1" commons-text = "1.13.0" commons-compress = "1.27.1" +commons-vfs2="2.10.0" swingx = "1.6.5-1" jgoodies-forms = "1.9.0" jfa = "1.2.0" @@ -54,6 +55,7 @@ commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version. commons-csv = { group = "org.apache.commons", name = "commons-csv", version.ref = "commons-csv" } commons-text = { group = "org.apache.commons", name = "commons-text", version.ref = "commons-text" } commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" } +commons-vfs2 = { group = "org.apache.commons", name = "commons-vfs2", version.ref = "commons-vfs2" } pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" } ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" } flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" } diff --git a/src/main/kotlin/app/termora/ApplicationRunner.kt b/src/main/kotlin/app/termora/ApplicationRunner.kt index 916fde1..f9a74c5 100644 --- a/src/main/kotlin/app/termora/ApplicationRunner.kt +++ b/src/main/kotlin/app/termora/ApplicationRunner.kt @@ -2,6 +2,7 @@ package app.termora import app.termora.actions.ActionManager import app.termora.keymap.KeymapManager +import app.termora.vfs2.sftp.MySftpFileProvider import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatSystemProperties import com.formdev.flatlaf.extras.FlatDesktop @@ -17,6 +18,10 @@ import kotlinx.coroutines.launch import org.apache.commons.io.FileUtils import org.apache.commons.lang3.LocaleUtils import org.apache.commons.lang3.SystemUtils +import org.apache.commons.vfs2.VFS +import org.apache.commons.vfs2.cache.WeakRefFilesCache +import org.apache.commons.vfs2.impl.DefaultFileSystemManager +import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider import org.json.JSONObject import org.slf4j.LoggerFactory import java.awt.MenuItem @@ -48,10 +53,17 @@ class ApplicationRunner { // 统计 val enableAnalytics = measureTimeMillis { enableAnalytics() } - // init ActionManager、KeymapManager + // init ActionManager、KeymapManager、VFS swingCoroutineScope.launch(Dispatchers.IO) { ActionManager.getInstance() KeymapManager.getInstance() + + val fileSystemManager = DefaultFileSystemManager() + fileSystemManager.addProvider("sftp", MySftpFileProvider()) + fileSystemManager.addProvider("file", DefaultLocalFileProvider()) + fileSystemManager.filesCache = WeakRefFilesCache() + fileSystemManager.init() + VFS.setManager(fileSystemManager) } // 设置 LAF diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt index 652aaca..e39c25c 100644 --- a/src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt +++ b/src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt @@ -4,7 +4,12 @@ import app.termora.Icons import app.termora.assertEventDispatchThread import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.components.FlatTextField +import com.formdev.flatlaf.util.SystemInfo import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.SystemUtils +import org.apache.commons.vfs2.FileObject +import org.apache.commons.vfs2.VFS +import org.apache.commons.vfs2.provider.local.LocalFileSystem import org.slf4j.LoggerFactory import java.awt.BorderLayout import java.awt.Component @@ -14,8 +19,7 @@ import java.awt.event.ActionEvent import java.awt.event.ActionListener import java.awt.event.ItemEvent import java.awt.event.ItemListener -import java.nio.file.FileSystem -import java.nio.file.Path +import java.nio.file.FileSystems import javax.swing.* import javax.swing.event.PopupMenuEvent import javax.swing.event.PopupMenuListener @@ -23,8 +27,8 @@ import javax.swing.filechooser.FileSystemView import kotlin.io.path.absolutePathString class FileSystemViewNav( - private val fileSystem: FileSystem, - private val homeDirectory: Path + private val fileSystem: org.apache.commons.vfs2.FileSystem, + private val homeDirectory: FileObject ) : JPanel(BorderLayout()) { companion object { @@ -38,7 +42,7 @@ class FileSystemViewNav( private val history = linkedSetOf() private val layeredPane = LayeredPane() private val downBtn = JButton(Icons.chevronDown) - private val comboBox = object : JComboBox() { + private val comboBox = object : JComboBox() { override fun getLocationOnScreen(): Point { val point = super.getLocationOnScreen() point.y -= 1 @@ -80,7 +84,7 @@ class FileSystemViewNav( ): Component { val c = super.getListCellRendererComponent( list, - value, + if (value is FileObject) formatDisplayPath(value) else value.toString(), index, isSelected, cellHasFocus @@ -99,12 +103,12 @@ class FileSystemViewNav( add(layeredPane, BorderLayout.CENTER) - if (fileSystem.isWindows()) { + if (SystemInfo.isWindows && fileSystem is LocalFileSystem) { try { for (root in fileSystemView.roots) { history.add(root.absolutePath) } - for (rootDirectory in fileSystem.rootDirectories) { + for (rootDirectory in FileSystems.getDefault().rootDirectories) { history.add(rootDirectory.absolutePathString()) } } catch (e: Exception) { @@ -115,12 +119,16 @@ class FileSystemViewNav( } } + private fun formatDisplayPath(file: FileObject): String { + return file.absolutePathString() + } + private fun initEvents() { val itemListener = ItemListener { e -> if (e.stateChange == ItemEvent.SELECTED) { val item = comboBox.selectedItem - if (item is Path) { + if (item is FileObject) { changeSelectedPath(item) } } @@ -167,7 +175,11 @@ class FileSystemViewNav( val name = textField.text.trim() if (name.isBlank()) return try { - changeSelectedPath(fileSystem.getPath(name)) + if (fileSystem is LocalFileSystem && SystemUtils.IS_OS_WINDOWS) { + changeSelectedPath(fileSystem.resolveFile("file://${name}")) + } else { + changeSelectedPath(fileSystem.resolveFile(name)) + } } catch (e: Exception) { if (log.isErrorEnabled) { log.error(e.message, e) @@ -182,7 +194,11 @@ class FileSystemViewNav( comboBox.removeAllItems() for (text in history) { - val path = fileSystem.getPath(text) + val path = if (SystemInfo.isWindows && fileSystem is LocalFileSystem) { + VFS.getManager().resolveFile("file://${text}") + } else { + fileSystem.resolveFile(text) + } comboBox.addItem(path) if (text == textField.text) { comboBox.selectedItem = path @@ -218,15 +234,15 @@ class FileSystemViewNav( } } - fun getSelectedPath(): Path { - return textField.getClientProperty(PATH) as Path + fun getSelectedPath(): FileObject { + return textField.getClientProperty(PATH) as FileObject } - fun changeSelectedPath(path: Path) { + fun changeSelectedPath(file: FileObject) { assertEventDispatchThread() - textField.text = path.absolutePathString() - textField.putClientProperty(PATH, path) + textField.text = formatDisplayPath(file) + textField.putClientProperty(PATH, file) for (listener in listenerList.getListeners(ActionListener::class.java)) { listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY)) diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt index 92f0ca8..5df4f1a 100644 --- a/src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt +++ b/src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt @@ -3,30 +3,28 @@ package app.termora.sftp import app.termora.* import app.termora.actions.DataProvider import app.termora.terminal.DataKey +import app.termora.vfs2.sftp.MySftpFileSystem import com.formdev.flatlaf.extras.components.FlatToolBar -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.swing.Swing +import kotlinx.coroutines.withContext import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.exception.ExceptionUtils -import org.apache.sshd.sftp.client.fs.SftpFileSystem +import org.apache.commons.vfs2.FileObject import org.jdesktop.swingx.JXBusyLabel import java.awt.BorderLayout import java.awt.event.* -import java.nio.file.FileSystem -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.StandardCopyOption import java.util.concurrent.atomic.AtomicBoolean import java.util.function.Consumer import javax.swing.* -import kotlin.io.path.absolutePathString -import kotlin.io.path.name class FileSystemViewPanel( val host: Host, - val fileSystem: FileSystem, + val fileSystem: org.apache.commons.vfs2.FileSystem, private val transportManager: TransportManager, - private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private val coroutineScope: CoroutineScope, ) : JPanel(BorderLayout()), Disposable, DataProvider { private val properties get() = Database.getDatabase().properties @@ -100,7 +98,7 @@ class FileSystemViewPanel( override fun onTransportChanged(transport: Transport) { val path = transport.target.parent ?: return if (path.fileSystem != fileSystem) return - if (path.absolutePathString() != workdir.absolutePathString()) return + if (path.name.path != workdir.name.path) return // 立即刷新 reload(true) } @@ -123,19 +121,19 @@ class FileSystemViewPanel( private fun enterTableSelectionFolder(row: Int = table.selectedRow) { if (row < 0 || isLoading.get()) return - val attr = model.getAttr(row) - if (attr.isFile) return + val file = model.getFileObject(row) + if (file.isFile) return // 当前工作目录 val workdir = getWorkdir() // 返回上级之后,选中上级目录 - if (attr.name == "..") { + if (row == 0 && model.hasParent) { val workdirName = workdir.name - nextReloadTickSelection(workdirName) + nextReloadTickSelection(workdirName.baseName) } - changeWorkdir(attr.path) + changeWorkdir(file) } @@ -169,13 +167,13 @@ class FileSystemViewPanel( bookmarkBtn.addActionListener { e -> if (e.actionCommand.isNullOrBlank()) { if (bookmarkBtn.isBookmark) { - bookmarkBtn.deleteBookmark(workdir.toString()) + bookmarkBtn.deleteBookmark(workdir.absolutePathString()) } else { - bookmarkBtn.addBookmark(workdir.toString()) + bookmarkBtn.addBookmark(workdir.absolutePathString()) } bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark } else { - changeWorkdir(fileSystem.getPath(e.actionCommand)) + changeWorkdir(fileSystem.resolveFile(e.actionCommand)) } } @@ -194,14 +192,13 @@ class FileSystemViewPanel( button.addActionListener(object : AbstractAction() { override fun actionPerformed(e: ActionEvent) { if (model.rowCount < 1) return - val attr = model.getAttr(0) - if (attr !is FileSystemViewTableModel.ParentAttr) return + if (model.hasParent) return enterTableSelectionFolder(0) } }) addPropertyChangeListener("workdir") { - button.isEnabled = model.rowCount > 0 && model.getAttr(0) is FileSystemViewTableModel.ParentAttr + button.isEnabled = model.rowCount > 0 && model.hasParent } return button @@ -211,7 +208,7 @@ class FileSystemViewPanel( // 创建成功之后需要修改和选中 registerNextReloadTick { for (i in 0 until table.rowCount) { - if (model.getAttr(i).name == name) { + if (model.getFileObject(i).name.baseName == name) { table.addRowSelectionInterval(i, i) table.scrollRectToVisible(table.getCellRect(i, 0, true)) consumer.accept(i) @@ -221,18 +218,19 @@ class FileSystemViewPanel( } } - private fun changeWorkdir(workdir: Path) { + private fun changeWorkdir(workdir: FileObject) { assertEventDispatchThread() nav.changeSelectedPath(workdir) } - fun renameTo(oldPath: Path, newPath: Path) { + fun renameTo(oldPath: FileObject, newPath: FileObject) { // 新建文件夹 coroutineScope.launch { + if (requestLoading()) { try { - Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE) + oldPath.moveTo(newPath) } catch (e: Exception) { withContext(Dispatchers.Swing) { OptionPane.showMessageDialog( @@ -247,7 +245,7 @@ class FileSystemViewPanel( } // 创建成功之后需要选中 - nextReloadTickSelection(newPath.name) + nextReloadTickSelection(newPath.name.baseName) // 立即刷新 reload() @@ -258,7 +256,7 @@ class FileSystemViewPanel( coroutineScope.launch { if (requestLoading()) { try { - doNewFolderOrFile(getWorkdir().resolve(name), isFile) + doNewFolderOrFile(getWorkdir().resolveFile(name), isFile) } finally { stopLoading() } @@ -273,9 +271,9 @@ class FileSystemViewPanel( } - private suspend fun doNewFolderOrFile(path: Path, isFile: Boolean) { + private suspend fun doNewFolderOrFile(path: FileObject, isFile: Boolean) { - if (Files.exists(path)) { + if (path.exists()) { withContext(Dispatchers.Swing) { OptionPane.showMessageDialog( owner, @@ -288,7 +286,7 @@ class FileSystemViewPanel( // 创建文件夹 withContext(Dispatchers.IO) { - runCatching { if (isFile) Files.createFile(path) else Files.createDirectories(path) }.onFailure { + runCatching { if (isFile) path.createFile() else path.createFolder() }.onFailure { withContext(Dispatchers.Swing) { if (it is Exception) { OptionPane.showMessageDialog( @@ -329,7 +327,7 @@ class FileSystemViewPanel( fun reload(rememberSelection: Boolean = false) { if (!requestLoading()) return - if (fileSystem.isSFTP()) loadingPanel.start() + if (fileSystem is MySftpFileSystem) loadingPanel.start() val oldWorkdir = workdir val path = nav.getSelectedPath() @@ -338,7 +336,7 @@ class FileSystemViewPanel( if (rememberSelection) { withContext(Dispatchers.Swing) { - table.selectedRows.sortedDescending().map { model.getAttr(it).name } + table.selectedRows.sortedDescending().map { model.getFileObject(it).name.baseName } .forEach { nextReloadTickSelection(it) } } } @@ -347,7 +345,7 @@ class FileSystemViewPanel( if (it is Exception) { withContext(Dispatchers.Swing) { OptionPane.showMessageDialog( - owner, ExceptionUtils.getMessage(it), + owner, ExceptionUtils.getRootCauseMessage(it), messageType = JOptionPane.ERROR_MESSAGE ) } @@ -367,34 +365,35 @@ class FileSystemViewPanel( } finally { stopLoading() - if (fileSystem.isSFTP()) { + if (fileSystem is MySftpFileSystem) { withContext(Dispatchers.Swing) { loadingPanel.stop() } } } } } - private fun getHomeDirectory(): Path { - if (fileSystem.isSFTP()) { - val fs = fileSystem as SftpFileSystem - val host = fs.session.getAttribute(SshClients.HOST_KEY) ?: return fs.defaultDir + private fun getHomeDirectory(): FileObject { + if (fileSystem is MySftpFileSystem) { + val host = fileSystem.getClientSession().getAttribute(SshClients.HOST_KEY) + ?: return fileSystem.resolveFile(fileSystem.getDefaultDir()) val defaultDirectory = host.options.sftpDefaultDirectory if (defaultDirectory.isNotBlank()) { - return runCatching { fs.getPath(defaultDirectory) } - .getOrElse { fs.defaultDir } + return fileSystem.resolveFile(defaultDirectory) } - return fs.defaultDir + return fileSystem.resolveFile(fileSystem.getDefaultDir()) } if (sftp.defaultDirectory.isNotBlank()) { - return runCatching { fileSystem.getPath(sftp.defaultDirectory) } - .getOrElse { fileSystem.getPath(SystemUtils.USER_HOME) } + val resolveFile = fileSystem.resolveFile("file://${sftp.defaultDirectory}") + if (resolveFile.exists()) { + return resolveFile + } } - return fileSystem.getPath(SystemUtils.USER_HOME) + return fileSystem.resolveFile("file://${SystemUtils.USER_HOME}") } - fun getWorkdir(): Path { + fun getWorkdir(): FileObject { return workdir } diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt index b1dc981..4233aa9 100644 --- a/src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt +++ b/src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt @@ -3,6 +3,9 @@ package app.termora.sftp import app.termora.* import app.termora.actions.AnActionEvent import app.termora.actions.SettingsAction +import app.termora.sftp.FileSystemViewTable.AskTransfer.Action +import app.termora.vfs2.sftp.MySftpFileObject +import app.termora.vfs2.sftp.MySftpFileSystem import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.components.FlatPopupMenu @@ -11,14 +14,11 @@ import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout import kotlinx.coroutines.* import kotlinx.coroutines.swing.Swing -import org.apache.commons.io.FileUtils import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.exception.ExceptionUtils -import org.apache.commons.lang3.time.DateFormatUtils -import org.apache.sshd.sftp.client.SftpClient -import org.apache.sshd.sftp.client.fs.SftpFileSystem -import org.apache.sshd.sftp.client.fs.SftpPath -import org.apache.sshd.sftp.client.fs.SftpPosixFileAttributes +import org.apache.commons.vfs2.FileObject +import org.apache.commons.vfs2.VFS +import org.apache.commons.vfs2.provider.local.LocalFileSystem import org.jdesktop.swingx.action.ActionManager import org.slf4j.LoggerFactory import java.awt.Component @@ -32,8 +32,12 @@ import java.awt.event.* import java.io.File import java.io.IOException import java.io.OutputStream -import java.nio.file.* +import java.nio.file.FileVisitResult +import java.nio.file.FileVisitor +import java.nio.file.Paths +import java.nio.file.StandardOpenOption import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.FileTime import java.text.MessageFormat import java.util.* import java.util.concurrent.atomic.AtomicBoolean @@ -41,14 +45,14 @@ import java.util.regex.Pattern import javax.swing.* import javax.swing.table.DefaultTableCellRenderer import kotlin.collections.ArrayDeque -import kotlin.io.path.* +import kotlin.io.path.absolutePathString import kotlin.math.max import kotlin.time.Duration.Companion.milliseconds -@Suppress("DuplicatedCode") +@Suppress("DuplicatedCode", "CascadeIf") class FileSystemViewTable( - private val fileSystem: FileSystem, + private val fileSystem: org.apache.commons.vfs2.FileSystem, private val transportManager: TransportManager, private val coroutineScope: CoroutineScope ) : JTable(), Disposable { @@ -105,8 +109,8 @@ class FileSystemViewTable( ): Component { foreground = null val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) - icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getAttr(row).icon else null - foreground = if (!isSelected && model.getAttr(row).isHidden) + icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getFileIcon(row) else null + foreground = if (!isSelected && model.getFileObject(row).isHidden) UIManager.getColor("textInactiveText") else foreground return c } @@ -141,10 +145,10 @@ class FileSystemViewTable( } else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { val row = table.selectedRow if (row <= 0 || row >= table.rowCount) return - val attr = model.getAttr(row) - if (attr.isDirectory) return + val file = model.getFileObject(row) + if (file.isFolder) return // 传输 - transfer(arrayOf(attr)) + transfer(listOf(file)) } } }) @@ -156,8 +160,7 @@ class FileSystemViewTable( if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) { val rows = selectedRows if (rows.contains(0)) return - val attrs = rows.map { model.getAttr(it) }.toTypedArray() - val files = attrs.map { it.path }.toTypedArray() + val files = rows.map { model.getFileObject(it) } deletePaths(files, false) } else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) { fileSystemViewPanel.reload(true) @@ -173,13 +176,15 @@ class FileSystemViewTable( // 如果不是新增行,并且光标不在第一列,那么不允许 if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false // 如果不是新增行,如果在一个文件上,那么不允许 - if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false + if (!dropLocation.isInsertRow && model.getFileObject(dropLocation.row).isFile) return false + // 如果不是新增行,在 .. 上面,不允许 + if (!dropLocation.isInsertRow && model.hasParent && dropLocation.row == 0) return false if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) return data is FileSystemTableRowTransferable && data.source != table } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { - return !fileSystem.isLocal() + return fileSystem !is LocalFileSystem } return false @@ -190,27 +195,25 @@ class FileSystemViewTable( // 如果不是新增行,并且光标不在第一列,那么不允许 if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false // 如果不是新增行,如果在一个文件上,那么不允许 - if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false + if (!dropLocation.isInsertRow && model.getFileObject(dropLocation.row).isFile) return false - var targetWorkdir: Path? = null + var targetWorkdir: FileObject? = null // 变更工作目录 if (!dropLocation.isInsertRow) { - targetWorkdir = model.getAttr(dropLocation.row).path + targetWorkdir = model.getFileObject(dropLocation.row) } if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) if (data !is FileSystemTableRowTransferable) return false // 委托源表开始传输 - data.source.transfer(data.attrs.toTypedArray(), false, targetWorkdir) + data.source.transfer(data.files, false, targetWorkdir) return true } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*> if (files.isEmpty()) return false - val paths = files.filterIsInstance() - .map { FileSystemViewTableModel.Attr(it.toPath()) } - .toTypedArray() + val paths = files.filterIsInstance().map { VFS.getManager().resolveFile(it.toURI()) } if (paths.isEmpty()) return false val localTarget = sftpPanel.getLocalTarget() val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false @@ -226,9 +229,9 @@ class FileSystemViewTable( } override fun createTransferable(c: JComponent?): Transferable? { - val attrs = table.selectedRows.filter { it != 0 }.map { model.getAttr(it) } - if (attrs.isEmpty()) return null - return FileSystemTableRowTransferable(table, attrs) + val files = table.selectedRows.filter { it != 0 }.map { model.getFileObject(it) } + if (files.isEmpty()) return null + return FileSystemTableRowTransferable(table, files) } } @@ -243,7 +246,7 @@ class FileSystemViewTable( } private fun navigate(row: Int, c: Char): Boolean { - val name = model.getAttr(row).name + val name = model.getFileObject(row).name.baseName if (name.startsWith(c, true)) { clearSelection() addRowSelectionInterval(row, row) @@ -255,18 +258,8 @@ class FileSystemViewTable( }) } - - override fun dispose() { - if (isDisposed.compareAndSet(false, true)) { - if (!fileSystem.isSFTP()) { - coroutineScope.cancel() - } - } - } - private fun showContextMenu(rows: IntArray, e: MouseEvent) { - val attrs = rows.map { model.getAttr(it) }.toTypedArray() - val files = attrs.map { it.path }.toTypedArray() + val files = rows.map { model.getFileObject(it) } val hasParent = rows.contains(0) val popupMenu = FlatPopupMenu() @@ -279,13 +272,13 @@ class FileSystemViewTable( val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer")) // 编辑 val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit")) - edit.isEnabled = fileSystem.isSFTP() && attrs.all { it.isFile } + edit.isEnabled = fileSystem is MySftpFileSystem && files.all { it.isFile } popupMenu.addSeparator() // 复制路径 val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path")) // 如果是本地,那么支持打开本地路径 - if (fileSystem.isLocal()) { + if (fileSystem is LocalFileSystem) { popupMenu.add( I18n.getString( "termora.transport.table.contextmenu.open-in-folder", @@ -294,7 +287,7 @@ class FileSystemViewTable( else I18n.getString("termora.folder") ) ).addActionListener { - Application.browseInFolder(files.last().toFile()) + Application.browseInFolder(File(files.last().absolutePathString())) } } @@ -307,18 +300,15 @@ class FileSystemViewTable( val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete")) // rm -rf val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction)) - // 只有 SFTP 可以 - if (!fileSystem.isSFTP()) { - rmrf.isVisible = false - } + rmrf.isVisible = fileSystem is MySftpFileSystem // 修改权限 val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions")) permission.isEnabled = false // 如果是本地系统文件,那么不允许修改权限,用户应该自己修改 - if (fileSystem.isSFTP() && rows.isNotEmpty()) { + if (fileSystem is MySftpFileSystem && rows.isNotEmpty()) { permission.isEnabled = true } popupMenu.addSeparator() @@ -360,23 +350,25 @@ class FileSystemViewTable( }) copyPath.addActionListener { val sb = StringBuilder() - attrs.forEach { sb.append(it.path.absolutePathString()).appendLine() } + files.forEach { sb.append(it.absolutePathString()).appendLine() } sb.deleteCharAt(sb.length - 1) toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null) } edit.addActionListener { if (files.isNotEmpty()) editFiles(files) } permission.addActionListener(object : AbstractAction() { override fun actionPerformed(e: ActionEvent) { - val last = attrs.last() + val last = files.last() + if (last !is MySftpFileObject) return + val dialog = PosixFilePermissionDialog( SwingUtilities.getWindowAncestor(table), - last.posixFilePermissions + model.getFilePermissions(last) ) val permissions = dialog.open() ?: return if (fileSystemViewPanel.requestLoading()) { coroutineScope.launch(Dispatchers.IO) { - val c = runCatching { Files.setPosixFilePermissions(last.path, permissions) }.onFailure { + val c = runCatching { last.setPosixFilePermissions(permissions) }.onFailure { withContext(Dispatchers.Swing) { OptionPane.showMessageDialog( owner, @@ -398,7 +390,7 @@ class FileSystemViewTable( } }) refresh.addActionListener { fileSystemViewPanel.reload() } - transfer.addActionListener { transfer(attrs) } + transfer.addActionListener { transfer(files) } if (rows.isEmpty() || hasParent) { transfer.isEnabled = false @@ -419,13 +411,13 @@ class FileSystemViewTable( private fun renameSelection() { val index = selectedRow if (index < 0) return - val attr = model.getAttr(index) + val file = model.getFileObject(index) val text = OptionPane.showInputDialog( owner, - value = attr.name, + value = file.name.baseName, title = I18n.getString("termora.transport.table.contextmenu.rename") ) ?: return - if (text.isBlank() || text == attr.name) return + if (text.isBlank() || text == file.name.baseName) return if (model.getPathNames().contains(text)) { OptionPane.showMessageDialog( owner, @@ -434,10 +426,11 @@ class FileSystemViewTable( ) return } - fileSystemViewPanel.renameTo(attr.path, attr.path.parent.resolve(text)) + + fileSystemViewPanel.renameTo(file, file.parent.resolveFile(text)) } - private fun editFiles(files: Array) { + private fun editFiles(files: List) { if (files.isEmpty()) return if (SystemInfo.isLinux) { @@ -455,10 +448,11 @@ class FileSystemViewTable( for (file in files) { val dir = Application.createSubTemporaryDir() - val path = Paths.get(dir.absolutePathString(), file.name) + val path = Paths.get(dir.absolutePathString(), file.name.baseName) + val target = VFS.getManager().resolveFile("file://" + path.absolutePathString()) val newTransport = createTransport(file, false, 0L) - .apply { target = path } + .apply { this.target = target } transportManager.addTransportListener(object : TransportListener { override fun onTransportChanged(transport: Transport) { @@ -467,7 +461,7 @@ class FileSystemViewTable( transportManager.removeTransportListener(this) if (transport.status != TransportStatus.Done) return // 监听文件变动 - listenFileChange(path, file) + listenFileChange(target, file) } }) @@ -476,21 +470,15 @@ class FileSystemViewTable( } } - private fun listenFileChange(localPath: Path, remotePath: Path) { + private fun listenFileChange(localPath: FileObject, remotePath: FileObject) { try { + val p = localPath.absolutePathString() if (sftp.editCommand.isNotBlank()) { - ProcessBuilder( - parseCommand( - MessageFormat.format( - sftp.editCommand, - localPath.absolutePathString() - ) - ) - ).start() + ProcessBuilder(parseCommand(MessageFormat.format(sftp.editCommand, p))).start() } else if (SystemInfo.isMacOS) { - ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start() + ProcessBuilder("open", "-a", "TextEdit", p).start() } else if (SystemInfo.isWindows) { - ProcessBuilder("notepad", localPath.absolutePathString()).start() + ProcessBuilder("notepad", p).start() } else { return } @@ -501,13 +489,17 @@ class FileSystemViewTable( return } - var lastModifiedTime = localPath.getLastModifiedTime().toMillis() + var lastModifiedTime = localPath.content.lastModifiedTime coroutineScope.launch(Dispatchers.IO) { while (coroutineScope.isActive) { try { - if (isDisposed.get() || !Files.exists(localPath)) break - val nowModifiedTime = localPath.getLastModifiedTime().toMillis() + + if (isDisposed.get()) break + localPath.refresh() + if (!localPath.exists()) break + + val nowModifiedTime = localPath.content.lastModifiedTime if (nowModifiedTime != lastModifiedTime) { lastModifiedTime = nowModifiedTime if (log.isDebugEnabled) { @@ -562,7 +554,7 @@ class FileSystemViewTable( fileSystemViewPanel.newFolderOrFile(text, isFile) } - private fun deletePaths(paths: Array, rm: Boolean = false) { + private fun deletePaths(paths: List, rm: Boolean = false) { if (OptionPane.showConfirmDialog( SwingUtilities.getWindowAncestor(this), I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"), @@ -576,10 +568,10 @@ class FileSystemViewTable( return } - coroutineScope.launch { + coroutineScope.launch(Dispatchers.IO) { runCatching { - if (fileSystem.isSFTP()) { + if (fileSystem is MySftpFileSystem) { deleteSftpPaths(paths, rm) } else { deleteRecursively(paths) @@ -590,97 +582,74 @@ class FileSystemViewTable( } } - // 停止加载 - fileSystemViewPanel.stopLoading() - - // 刷新 - fileSystemViewPanel.reload() + withContext(Dispatchers.Swing) { + // 停止加载 + fileSystemViewPanel.stopLoading() + // 刷新 + fileSystemViewPanel.reload() + } } } - private fun deleteSftpPaths(paths: Array, rm: Boolean = false) { - val fs = this.fileSystem as SftpFileSystem + private fun deleteSftpPaths(files: List, rm: Boolean = false) { if (rm) { - for (path in paths) { - fs.session.executeRemoteCommand( + val session = (this.fileSystem as MySftpFileSystem).getClientSession() + for (path in files) { + session.executeRemoteCommand( "rm -rf '${path.absolutePathString()}'", OutputStream.nullOutputStream(), Charsets.UTF_8 ) } } else { - fs.client.use { - for (path in paths) { - deleteRecursivelySFTP(path as SftpPath, it) - } - } + deleteRecursively(files) } } - private fun deleteRecursively(paths: Array) { - for (path in paths) { - FileUtils.deleteQuietly(path.toFile()) + private fun deleteRecursively(files: List) { + for (path in files) { + path.deleteAll() + path.close() } } - /** - * 优化删除效率,采用一个连接 - */ - private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) { - val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory() - if (isDirectory) { - for (e in sftpClient.readDir(path.toString())) { - if (e.filename == ".." || e.filename == ".") { - continue - } - if (e.attributes.isDirectory) { - deleteRecursivelySFTP(path.resolve(e.filename), sftpClient) - } else { - sftpClient.remove(path.resolve(e.filename).toString()) - } - } - sftpClient.rmdir(path.toString()) - } else { - sftpClient.remove(path.toString()) - } - - } private fun transfer( - attrs: Array, + files: List, fromLocalSystem: Boolean = false, - targetWorkdir: Path? = null + targetWorkdir: FileObject? = null ) { assertEventDispatchThread() val target = sftpPanel.getTarget(table) ?: return val table = target.getData(SFTPDataProviders.FileSystemViewTable) ?: return - var overwriteAll = false + var isApplyAll = false + var lastAction = Action.Overwrite - for (attr in attrs) { - - if (!overwriteAll) { - val targetAttr = 0.rangeUntil(table.model.rowCount).map { table.model.getAttr(it) } - .find { it.name == attr.name } + for (file in files) { + if (!isApplyAll && (targetWorkdir == null || target.getWorkdir() == targetWorkdir)) { + val targetAttr = 0.rangeUntil(table.model.rowCount).map { table.model.getFileObject(it) } + .find { it.name.baseName == file.name.baseName } if (targetAttr != null) { - val askTransfer = askTransfer(attr, targetAttr) + val askTransfer = askTransfer(file, targetAttr) if (askTransfer.option != JOptionPane.YES_OPTION) { continue } - if (askTransfer.action == AskTransfer.Action.Skip) { + if (askTransfer.action == Action.Skip) { if (askTransfer.applyAll) break continue - } else if (askTransfer.action == AskTransfer.Action.Overwrite) { - overwriteAll = askTransfer.applyAll + } else { + lastAction = askTransfer.action + isApplyAll = askTransfer.applyAll } } } coroutineScope.launch { try { - doTransfer(attr, fromLocalSystem, targetWorkdir) + doTransfer(file, lastAction, fromLocalSystem, targetWorkdir) } catch (e: Exception) { if (log.isErrorEnabled) { log.error(e.message, e) @@ -698,13 +667,14 @@ class FileSystemViewTable( ) { enum class Action { Overwrite, + Append, Skip } } private fun askTransfer( - sourceAttr: FileSystemViewTableModel.Attr, - targetAttr: FileSystemViewTableModel.Attr + sourceFile: FileObject, + targetFile: FileObject ): AskTransfer { val formMargin = "7dlu" val layout = FormLayout( @@ -715,34 +685,29 @@ class FileSystemViewTable( val iconSize = 36 val targetIcon = if (SystemInfo.isWindows) - NativeFileIcons.getIcon(targetAttr.name, targetAttr.isFile, iconSize, iconSize).first - else if (targetAttr.isDirectory) { + model.getFileIcon(targetFile, iconSize, iconSize) + else if (targetFile.isFolder) { FlatSVGIcon(Icons.folder.name, iconSize, iconSize) } else { FlatSVGIcon(Icons.file.name, iconSize, iconSize) } val sourceIcon = if (SystemInfo.isWindows) - NativeFileIcons.getIcon(sourceAttr.name, sourceAttr.isFile, iconSize, iconSize).first - else if (sourceAttr.isDirectory) { + model.getFileIcon(sourceFile, iconSize, iconSize) + else if (sourceFile.isFolder) { FlatSVGIcon(Icons.folder.name, iconSize, iconSize) } else { FlatSVGIcon(Icons.file.name, iconSize, iconSize) } - val sourceModified = if (sourceAttr.modified > 0) DateFormatUtils.format( - Date(sourceAttr.modified), - "yyyy/MM/dd HH:mm" - ) else "-" - val targetModified = if (targetAttr.modified > 0) DateFormatUtils.format( - Date(targetAttr.modified), - "yyyy/MM/dd HH:mm" - ) else "-" + val sourceModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(sourceFile), "-") + val targetModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(targetFile), "-") - val actionsComBoBox = JComboBox() - actionsComBoBox.addItem(AskTransfer.Action.Overwrite) - actionsComBoBox.addItem(AskTransfer.Action.Skip) + val actionsComBoBox = JComboBox() + actionsComBoBox.addItem(Action.Overwrite) + actionsComBoBox.addItem(Action.Append) + actionsComBoBox.addItem(Action.Skip) actionsComBoBox.renderer = object : DefaultListCellRenderer() { override fun getListCellRendererComponent( list: JList<*>?, @@ -752,10 +717,12 @@ class FileSystemViewTable( cellHasFocus: Boolean ): Component { var text = value?.toString() ?: StringUtils.EMPTY - if (value == AskTransfer.Action.Overwrite) { + if (value == Action.Overwrite) { text = I18n.getString("termora.transport.sftp.already-exists.overwrite") - } else if (value == AskTransfer.Action.Skip) { + } else if (value == Action.Skip) { text = I18n.getString("termora.transport.sftp.already-exists.skip") + } else if (value == Action.Append) { + text = I18n.getString("termora.transport.sftp.already-exists.append") } return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus) } @@ -781,11 +748,11 @@ class FileSystemViewTable( val step = 2 val panel = FormBuilder.create().layout(layout) // tip - .add(JLabel(warningIcon)).xy(1, rows, "center, fill") + .add(JLabel(warningIcon)).xy(1, rows) .add(ttBox).xyw(3, rows, 3).apply { rows += step } // name .add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.name")}:")).xy(1, rows) - .add(sourceAttr.name).xyw(3, rows, 3).apply { rows += step } + .add(sourceFile.name.baseName).xyw(3, rows, 3).apply { rows += step } // separator .addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step } // Destination @@ -813,12 +780,13 @@ class FileSystemViewTable( owner, panel, messageType = JOptionPane.PLAIN_MESSAGE, optionType = JOptionPane.OK_CANCEL_OPTION, - title = sourceAttr.name, + title = sourceFile.name.baseName, initialValue = JOptionPane.YES_OPTION, ) { it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height) + it.setLocationRelativeTo(it.owner) }, - action = actionsComBoBox.selectedItem as AskTransfer.Action, + action = actionsComBoBox.selectedItem as Action, applyAll = applyAllCheckbox.isSelected ) @@ -829,9 +797,10 @@ class FileSystemViewTable( * 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止 */ private fun doTransfer( - attr: FileSystemViewTableModel.Attr, + file: FileObject, + action: Action, fromLocalSystem: Boolean, - targetWorkdir: Path? + targetWorkdir: FileObject? ) { val sftpPanel = this.sftpPanel val target = sftpPanel.getTarget(table) ?: return @@ -841,9 +810,14 @@ class FileSystemViewTable( */ val adder = object { fun add(transport: Transport): Boolean { + if (action == Action.Append) { + transport.mode = StandardOpenOption.APPEND + } else { + transport.mode = StandardOpenOption.TRUNCATE_EXISTING + } return addTransport( sftpPanel, - if (fromLocalSystem) attr.path.parent else null, + if (fromLocalSystem) file.parent else null, target, targetWorkdir, transport @@ -851,8 +825,8 @@ class FileSystemViewTable( } } - if (attr.isFile) { - adder.add(createTransport(attr.path, false, 0).apply { scanned() }) + if (file.isFile) { + adder.add(createTransport(file, false, 0).apply { scanned() }) return } @@ -860,26 +834,26 @@ class FileSystemViewTable( var isTerminate = false try { - walk(attr.path, object : FileVisitor { - override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { + walk(file, object : FileVisitor { + override fun preVisitDirectory(dir: FileObject, attrs: BasicFileAttributes): FileVisitResult { val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L) .apply { queue.addLast(this) } if (adder.add(transport)) return FileVisitResult.CONTINUE return FileVisitResult.TERMINATE.apply { isTerminate = true } } - override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + override fun visitFile(file: FileObject, attrs: BasicFileAttributes): FileVisitResult { if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS val transport = createTransport(file, false, queue.last().id).apply { scanned() } if (adder.add(transport)) return FileVisitResult.CONTINUE return FileVisitResult.TERMINATE.apply { isTerminate = true } } - override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + override fun visitFileFailed(file: FileObject, exc: IOException): FileVisitResult { return FileVisitResult.CONTINUE } - override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + override fun postVisitDirectory(dir: FileObject, exc: IOException?): FileVisitResult { // 标记为扫描完毕 queue.removeLast().scanned() return FileVisitResult.CONTINUE @@ -890,6 +864,13 @@ class FileSystemViewTable( if (log.isErrorEnabled) { log.error(e.message, e) } + SwingUtilities.invokeLater { + OptionPane.showMessageDialog( + owner, + message = ExceptionUtils.getRootCauseMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + } isTerminate = true } @@ -899,35 +880,28 @@ class FileSystemViewTable( } } - private fun walk(dir: Path, visitor: FileVisitor) { - if (fileSystem is SftpFileSystem) { - val attr = SftpPosixFileAttributes(dir, SftpClient.Attributes()) - fileSystem.client.use { walkSFTP(dir, attr, visitor, it) } - } else { - Files.walkFileTree(dir, setOf(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, visitor) - } - } - private fun walkSFTP( - dir: Path, - attr: SftpPosixFileAttributes, - visitor: FileVisitor, - client: SftpClient + private fun walk( + dir: FileObject, + visitor: FileVisitor, ): FileVisitResult { - if (visitor.preVisitDirectory(dir, attr) == FileVisitResult.TERMINATE) { + // clear cache + if (visitor.preVisitDirectory(dir, EmptyBasicFileAttributes.INSTANCE) == FileVisitResult.TERMINATE) { return FileVisitResult.TERMINATE } - val paths = client.readDir(dir.absolutePathString()) - for (e in paths) { - if (e.filename == ".." || e.filename == ".") continue - if (e.attributes.isDirectory) { - if (walkSFTP(dir.resolve(e.filename), attr, visitor, client) == FileVisitResult.TERMINATE) { + for (e in dir.children) { + if (e.name.baseName == ".." || e.name.baseName == ".") continue + if (e.isFolder) { + if (walk(dir.resolveFile(e.name.baseName), visitor) == FileVisitResult.TERMINATE) { return FileVisitResult.TERMINATE } } else { - val result = visitor.visitFile(dir.resolve(e.filename), attr) + val result = visitor.visitFile( + dir.resolveFile(e.name.baseName), + EmptyBasicFileAttributes.INSTANCE + ) if (result == FileVisitResult.TERMINATE) { return FileVisitResult.TERMINATE } else if (result == FileVisitResult.SKIP_SUBTREE) { @@ -945,19 +919,22 @@ class FileSystemViewTable( private fun addTransport( sftpPanel: SFTPPanel, - sourceWorkdir: Path?, + sourceWorkdir: FileObject?, target: FileSystemViewPanel, - targetWorkdir: Path?, + targetWorkdir: FileObject?, transport: Transport ): Boolean { return try { sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport) } catch (e: Exception) { + if (log.isErrorEnabled) { + log.error(e.message, e) + } false } } - private fun createTransport(source: Path, isDirectory: Boolean, parentId: Long): Transport { + private fun createTransport(source: FileObject, isDirectory: Boolean, parentId: Long): Transport { val transport = Transport( source = source, target = source, @@ -965,7 +942,7 @@ class FileSystemViewTable( isDirectory = isDirectory, ) if (transport.isFile) { - transport.filesize.addAndGet(source.fileSize()) + transport.filesize.addAndGet(source.content.size) } return transport } @@ -973,7 +950,7 @@ class FileSystemViewTable( private class FileSystemTableRowTransferable( val source: FileSystemViewTable, - val attrs: List + val files: List ) : Transferable { companion object { val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable") @@ -996,4 +973,47 @@ class FileSystemViewTable( } + private class EmptyBasicFileAttributes : BasicFileAttributes { + companion object { + val INSTANCE = EmptyBasicFileAttributes() + } + + override fun lastModifiedTime(): FileTime { + TODO("Not yet implemented") + } + + override fun lastAccessTime(): FileTime { + TODO("Not yet implemented") + } + + override fun creationTime(): FileTime { + TODO("Not yet implemented") + } + + override fun isRegularFile(): Boolean { + TODO("Not yet implemented") + } + + override fun isDirectory(): Boolean { + TODO("Not yet implemented") + } + + override fun isSymbolicLink(): Boolean { + TODO("Not yet implemented") + } + + override fun isOther(): Boolean { + TODO("Not yet implemented") + } + + override fun size(): Long { + TODO("Not yet implemented") + } + + override fun fileKey(): Any { + TODO("Not yet implemented") + } + + } + } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt b/src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt index 2e6d213..492641c 100644 --- a/src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt +++ b/src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt @@ -3,21 +3,24 @@ package app.termora.sftp import app.termora.I18n import app.termora.NativeStringComparator import app.termora.formatBytes +import app.termora.vfs2.sftp.MySftpFileObject +import com.formdev.flatlaf.util.SystemInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.withContext import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.time.DateFormatUtils -import org.apache.sshd.sftp.client.fs.SftpPath +import org.apache.commons.vfs2.FileObject +import org.apache.commons.vfs2.FileType +import org.apache.commons.vfs2.provider.local.LocalFileSystem import org.slf4j.LoggerFactory -import java.io.File -import java.nio.file.Files -import java.nio.file.Path import java.nio.file.attribute.PosixFilePermission import java.nio.file.attribute.PosixFilePermissions import java.util.* +import javax.swing.Icon +import javax.swing.SwingUtilities import javax.swing.table.DefaultTableModel -import kotlin.io.path.* class FileSystemViewTableModel : DefaultTableModel() { @@ -29,9 +32,10 @@ class FileSystemViewTableModel : DefaultTableModel() { const val COLUMN_LAST_MODIFIED_TIME = 3 const val COLUMN_ATTRS = 4 const val COLUMN_OWNER = 5 + private val log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java) - private fun fromSftpPermissions(sftpPermissions: Int): Set { + fun fromSftpPermissions(sftpPermissions: Int): Set { val result = mutableSetOf() // 将十进制权限转换为八进制字符串 @@ -68,23 +72,69 @@ class FileSystemViewTableModel : DefaultTableModel() { } } - override fun getValueAt(row: Int, column: Int): Any { - val attr = getAttr(row) - return when (column) { - COLUMN_NAME -> attr.name - COLUMN_FILE_SIZE -> if (attr.isDirectory) StringUtils.EMPTY else formatBytes(attr.size) - COLUMN_TYPE -> attr.type - COLUMN_LAST_MODIFIED_TIME -> if (attr.modified > 0) DateFormatUtils.format( - Date(attr.modified), - "yyyy/MM/dd HH:mm" - ) else StringUtils.EMPTY + var hasParent: Boolean = false + private set - COLUMN_ATTRS -> attr.permissions - COLUMN_OWNER -> attr.owner - else -> StringUtils.EMPTY + override fun getValueAt(row: Int, column: Int): Any { + val file = getFileObject(row) + val isParentRow = hasParent && row == 0 + + try { + if (file.type == FileType.IMAGINARY) return StringUtils.EMPTY + return when (column) { + COLUMN_NAME -> if (isParentRow) ".." else file.name.baseName + COLUMN_FILE_SIZE -> if (isParentRow || file.isFolder) StringUtils.EMPTY else formatBytes(file.content.size) + COLUMN_TYPE -> if (isParentRow) StringUtils.EMPTY else getFileType(file) + COLUMN_LAST_MODIFIED_TIME -> if (isParentRow) StringUtils.EMPTY else getLastModifiedTime(file) + COLUMN_ATTRS -> if (isParentRow) StringUtils.EMPTY else getAttrs(file) + COLUMN_OWNER -> StringUtils.EMPTY + else -> StringUtils.EMPTY + } + } catch (e: Exception) { + if (file.fileSystem is LocalFileSystem) { + if (ExceptionUtils.getRootCause(e) is java.nio.file.NoSuchFileException) { + SwingUtilities.invokeLater { removeRow(row) } + return StringUtils.EMPTY + } + } + if (log.isWarnEnabled) { + log.warn(e.message, e) + } + return StringUtils.EMPTY } } + private fun getFileType(file: FileObject): String { + return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile).second + else if (file.isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link") + else NativeFileIcons.getIcon(file.name.baseName, file.isFile).second + } + + fun getFileIcon(file: FileObject, width: Int = 16, height: Int = 16): Icon { + return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile, width, height).first + else NativeFileIcons.getIcon(file.name.baseName, file.isFile).first + } + + fun getFileIcon(row: Int): Icon { + return getFileIcon(getFileObject(row)) + } + + fun getLastModifiedTime(file: FileObject): String { + if (file.content.lastModifiedTime < 1) return "-" + return DateFormatUtils.format(Date(file.content.lastModifiedTime), "yyyy/MM/dd HH:mm") + } + + private fun getAttrs(file: FileObject): String { + if (file.fileSystem is LocalFileSystem) return StringUtils.EMPTY + return PosixFilePermissions.toString(getFilePermissions(file)) + } + + fun getFilePermissions(file: FileObject): Set { + val permissions = file.content.getAttribute(MySftpFileObject.POSIX_FILE_PERMISSIONS) + as Int? ?: return emptySet() + return fromSftpPermissions(permissions) + } + override fun getDataVector(): Vector> { return super.getDataVector() } @@ -100,14 +150,14 @@ class FileSystemViewTableModel : DefaultTableModel() { } } - fun getAttr(row: Int): Attr { - return super.getValueAt(row, 0) as Attr + fun getFileObject(row: Int): FileObject { + return super.getValueAt(row, 0) as FileObject } fun getPathNames(): Set { val names = linkedSetOf() for (i in 0 until rowCount) { - names.add(getAttr(i).name) + names.add(getFileObject(i).name.baseName) } return names } @@ -129,144 +179,40 @@ class FileSystemViewTableModel : DefaultTableModel() { return false } - suspend fun reload(dir: Path, useFileHiding: Boolean) { + suspend fun reload(dir: FileObject, useFileHiding: Boolean) { if (log.isDebugEnabled) { log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding) } - val attrs = mutableListOf() - if (dir.parent != null) { - attrs.add(ParentAttr(dir.parent)) - } + val files = mutableListOf() withContext(Dispatchers.IO) { - Files.list(dir).use { paths -> - for (path in paths) { - val attr = if (path is SftpPath) SftpAttr(path) else Attr(path) - if (useFileHiding && attr.isHidden) continue - attrs.add(attr) - } + dir.refresh() + for (file in dir.children) { + if (useFileHiding && file.isHidden) continue + files.add(file) } } - attrs.sortWith(compareBy { !it.isDirectory }.thenComparing { a, b -> + files.sortWith(compareBy { !it.isFolder }.thenComparing { a, b -> NativeStringComparator.getInstance().compare( - a.name, - b.name + a.name.baseName, + b.name.baseName ) }) + hasParent = dir.parent != null + if (hasParent) { + files.addFirst(dir.parent) + } + withContext(Dispatchers.Swing) { while (rowCount > 0) removeRow(0) - attrs.forEach { addRow(arrayOf(it)) } + files.forEach { addRow(arrayOf(it)) } } - } - - open class Attr(val path: Path) { - - /** - * 名称 - */ - open val name by lazy { path.name } - - /** - * 文件类型 - */ - open val type by lazy { - if (path.fileSystem.isWindows()) NativeFileIcons.getIcon(name, isFile).second - else if (isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link") - else NativeFileIcons.getIcon(name, isFile).second - } - - /** - * 大小 - */ - open val size by lazy { path.fileSize() } - - /** - * 修改时间 - */ - open val modified by lazy { path.getLastModifiedTime().toMillis() } - - /** - * 获取所有者 - */ - open val owner by lazy { StringUtils.EMPTY } - - /** - * 获取操作系统图标 - */ - open val icon by lazy { NativeFileIcons.getIcon(name, isFile).first } - - /** - * 是否是文件夹 - */ - open val isDirectory by lazy { path.isDirectory() } - - /** - * 是否是文件 - */ - open val isFile by lazy { !isDirectory } - - /** - * 是否是文件夹 - */ - open val isHidden by lazy { path.isHidden() } - - open val isSymbolicLink by lazy { path.isSymbolicLink() } - - /** - * 获取权限 - */ - open val permissions: String by lazy { - posixFilePermissions.let { - if (it.isNotEmpty()) PosixFilePermissions.toString( - it - ) else StringUtils.EMPTY - } - } - open val posixFilePermissions by lazy { if (path.fileSystem.isUnix()) path.getPosixFilePermissions() else emptySet() } - - open fun toFile(): File { - if (path.fileSystem.isSFTP()) { - return File(path.absolutePathString()) - } - return path.toFile() - } - } - - class ParentAttr(path: Path) : Attr(path) { - override val name by lazy { ".." } - override val isDirectory = true - override val isFile = false - override val isHidden = false - override val permissions = StringUtils.EMPTY - override val modified = 0L - override val type = StringUtils.EMPTY - override val icon by lazy { NativeFileIcons.getFolderIcon() } - override val isSymbolicLink = false - - } - - - class SftpAttr(sftpPath: SftpPath) : Attr(sftpPath) { - private val attributes = sftpPath.attributes - - override val isSymbolicLink = attributes.isSymbolicLink - override val isDirectory = if (isSymbolicLink) sftpPath.isDirectory() else attributes.isDirectory - override val isHidden = name.startsWith(".") - override val size = attributes.size - override val owner: String = StringUtils.defaultString(attributes.owner) - override val modified = attributes.modifyTime.toMillis() - override val permissions: String = PosixFilePermissions.toString(fromSftpPermissions(attributes.permissions)) - override val posixFilePermissions = fromSftpPermissions(attributes.permissions) - - override fun toFile(): File { - return File(path.absolutePathString()) - } } diff --git a/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt b/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt index f745f37..685dfdc 100644 --- a/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt +++ b/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt @@ -3,6 +3,7 @@ package app.termora.sftp import app.termora.* import app.termora.actions.DataProvider import app.termora.terminal.DataKey +import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout @@ -11,10 +12,11 @@ import kotlinx.coroutines.swing.Swing import org.apache.commons.io.IOUtils import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.exception.ExceptionUtils +import org.apache.commons.vfs2.FileSystem +import org.apache.commons.vfs2.FileSystemOptions +import org.apache.commons.vfs2.VFS import org.apache.sshd.client.SshClient import org.apache.sshd.client.session.ClientSession -import org.apache.sshd.sftp.client.SftpClientFactory -import org.apache.sshd.sftp.client.fs.SftpFileSystem import org.jdesktop.swingx.JXBusyLabel import org.jdesktop.swingx.JXHyperlink import org.slf4j.LoggerFactory @@ -46,18 +48,16 @@ class SFTPFileSystemViewPanel( private var state = State.Initialized private val cardLayout = CardLayout() private val cardPanel = JPanel(cardLayout) - + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val connectingPanel = ConnectingPanel() private val selectHostPanel = SelectHostPanel() private val connectFailedPanel = ConnectFailedPanel() private val isDisposed = AtomicBoolean(false) private val that = this - private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val properties get() = Database.getDatabase().properties private var client: SshClient? = null private var session: ClientSession? = null - private var fileSystem: SftpFileSystem? = null private var fileSystemPanel: FileSystemViewPanel? = null @@ -111,11 +111,17 @@ class SFTPFileSystemViewPanel( closeIO() + val mySftpFileSystem: FileSystem + try { val owner = SwingUtilities.getWindowAncestor(that) val client = SshClients.openClient(thisHost, owner).apply { client = this } val session = SshClients.openSession(thisHost, client).apply { session = this } - fileSystem = SftpClientFactory.instance().createSftpFileSystem(session) + + val options = FileSystemOptions() + MySftpFileSystemConfigBuilder.getInstance() + .setClientSession(options, session) + mySftpFileSystem = VFS.getManager().resolveFile("sftp:///", options).fileSystem session.addCloseFutureListener { onClose() } } catch (e: Exception) { closeIO() @@ -126,11 +132,10 @@ class SFTPFileSystemViewPanel( throw IllegalStateException("Closed") } - val fileSystem = this.fileSystem ?: return withContext(Dispatchers.Swing) { state = State.Connected - val fileSystemPanel = FileSystemViewPanel(thisHost, fileSystem, transportManager, coroutineScope) + val fileSystemPanel = FileSystemViewPanel(thisHost, mySftpFileSystem, transportManager, coroutineScope) cardPanel.add(fileSystemPanel, State.Connected.name) cardLayout.show(cardPanel, State.Connected.name) that.fileSystemPanel = fileSystemPanel @@ -157,7 +162,6 @@ class SFTPFileSystemViewPanel( fileSystemPanel?.let { Disposer.dispose(it) } fileSystemPanel = null - runCatching { IOUtils.closeQuietly(fileSystem) } runCatching { IOUtils.closeQuietly(session) } runCatching { IOUtils.closeQuietly(client) } diff --git a/src/main/kotlin/app/termora/sftp/SFTPKit.kt b/src/main/kotlin/app/termora/sftp/SFTPKit.kt new file mode 100644 index 0000000..a4c5f09 --- /dev/null +++ b/src/main/kotlin/app/termora/sftp/SFTPKit.kt @@ -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 +} diff --git a/src/main/kotlin/app/termora/sftp/SFTPPanel.kt b/src/main/kotlin/app/termora/sftp/SFTPPanel.kt index ab5f38e..5b5a5a7 100644 --- a/src/main/kotlin/app/termora/sftp/SFTPPanel.kt +++ b/src/main/kotlin/app/termora/sftp/SFTPPanel.kt @@ -5,31 +5,34 @@ import app.termora.actions.DataProvider import app.termora.actions.DataProviderSupport import app.termora.findeverywhere.FindEverywhereProvider import app.termora.terminal.DataKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import okio.withLock -import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.SystemUtils -import org.apache.sshd.sftp.client.fs.SftpFileSystem +import org.apache.commons.vfs2.FileObject +import org.apache.commons.vfs2.VFS import java.awt.BorderLayout import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent -import java.nio.file.FileSystem import java.nio.file.FileSystems -import java.nio.file.Path import javax.swing.* -import kotlin.io.path.absolutePathString -fun FileSystem.isSFTP() = this is SftpFileSystem -fun FileSystem.isUnix() = isLocal() && SystemUtils.IS_OS_UNIX -fun FileSystem.isWindows() = isLocal() && SystemUtils.IS_OS_WINDOWS -fun FileSystem.isLocal() = StringUtils.startsWithAny(javaClass.name, "java", "sun") class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable { + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val transportTable = TransportTable() private val transportManager get() = transportTable.model private val dataProviderSupport = DataProviderSupport() private val leftComponent = SFTPTabbed(transportManager) private val rightComponent = SFTPTabbed(transportManager) + private val localHost = Host( + id = "local", + name = I18n.getString("termora.transport.local"), + protocol = Protocol.Local, + ) init { initViews() @@ -87,11 +90,10 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable { leftComponent.addTab( I18n.getString("termora.transport.local"), FileSystemViewPanel( - Host( - id = "local", - name = I18n.getString("termora.transport.local"), - protocol = Protocol.Local, - ), FileSystems.getDefault(), transportManager + localHost, + VFS.getManager().resolveFile("file:///${SystemUtils.USER_HOME}").fileSystem, + transportManager, + coroutineScope ) ) leftComponent.setTabClosable(0, false) @@ -165,9 +167,9 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable { */ fun addTransport( source: JComponent, - sourceWorkdir: Path?, + sourceWorkdir: FileObject?, target: FileSystemViewPanel, - targetWorkdir: Path?, + targetWorkdir: FileObject?, transport: Transport ): Boolean { @@ -175,15 +177,12 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable { as? FileSystemViewPanel ?: return false val targetPanel = target as? FileSystemViewPanel ?: return false if (sourcePanel.isDisposed || targetPanel.isDisposed) return false - val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir()).absolutePathString() - val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir()).absolutePathString() - val targetFileSystem = targetPanel.fileSystem - val sourcePath = transport.source.absolutePathString() + val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir()) + val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir()) + val sourcePath = transport.source - transport.target = targetFileSystem.getPath( - myTargetWorkdir, - StringUtils.removeStart(sourcePath, mySourceWorkdir) - ) + val relativeName = mySourceWorkdir.name.getRelativeName(sourcePath.name) + transport.target = myTargetWorkdir.resolveFile(relativeName) return transportManager.addTransport(transport) @@ -212,4 +211,8 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable { return dataProviderSupport.getData(dataKey) } + override fun dispose() { + coroutineScope.cancel() + } + } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sftp/SFTPTabbed.kt b/src/main/kotlin/app/termora/sftp/SFTPTabbed.kt index 5f9aca8..6fe69a1 100644 --- a/src/main/kotlin/app/termora/sftp/SFTPTabbed.kt +++ b/src/main/kotlin/app/termora/sftp/SFTPTabbed.kt @@ -13,7 +13,6 @@ import javax.swing.JButton import javax.swing.JToolBar import javax.swing.SwingUtilities import javax.swing.UIManager -import kotlin.io.path.absolutePathString import kotlin.math.max @Suppress("DuplicatedCode") diff --git a/src/main/kotlin/app/termora/sftp/Transport.kt b/src/main/kotlin/app/termora/sftp/Transport.kt index 5581718..917d303 100644 --- a/src/main/kotlin/app/termora/sftp/Transport.kt +++ b/src/main/kotlin/app/termora/sftp/Transport.kt @@ -6,18 +6,13 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.apache.commons.io.IOUtils import org.apache.commons.net.io.Util +import org.apache.commons.vfs2.FileObject import org.slf4j.LoggerFactory -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.attribute.BasicFileAttributeView +import java.nio.file.StandardOpenOption import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong -import kotlin.io.path.createDirectories -import kotlin.io.path.exists -import kotlin.io.path.getLastModifiedTime -import kotlin.io.path.name enum class TransportStatus { Ready, @@ -48,12 +43,19 @@ class Transport( /** * 源 */ - val source: Path, + val source: FileObject, /** * 目标 */ - var target: Path, + var target: FileObject, + /** + * 仅对文件生效,切只有两个选项 + * + * 1. [StandardOpenOption.APPEND] + * 2. [StandardOpenOption.TRUNCATE_EXISTING] + */ + var mode: StandardOpenOption = StandardOpenOption.TRUNCATE_EXISTING ) { companion object { @@ -154,7 +156,7 @@ class Transport( withContext(Dispatchers.IO) { try { if (!target.exists()) { - target.createDirectories() + target.createFolder() } } catch (e: FileAlreadyExistsException) { if (log.isWarnEnabled) { @@ -169,8 +171,8 @@ class Transport( } withContext(Dispatchers.IO) { - val input = Files.newInputStream(source) - val output = Files.newOutputStream(target) + val input = source.content.inputStream + val output = target.content.getOutputStream(mode == StandardOpenOption.APPEND) try { @@ -209,8 +211,7 @@ class Transport( private fun preserveModificationTime() { // 设置修改时间 if (isPreserveModificationTime) { - Files.getFileAttributeView(target, BasicFileAttributeView::class.java) - .setTimes(source.getLastModifiedTime(), source.getLastModifiedTime(), null) + target.content.lastModifiedTime = source.content.lastModifiedTime } } diff --git a/src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt b/src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt index 7799470..d966cc8 100644 --- a/src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt +++ b/src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt @@ -3,12 +3,11 @@ package app.termora.sftp import app.termora.I18n import app.termora.formatBytes import app.termora.formatSeconds -import org.apache.commons.io.file.PathUtils -import org.apache.sshd.sftp.client.fs.SftpFileSystem +import app.termora.vfs2.sftp.MySftpFileSystem +import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder +import org.apache.commons.vfs2.FileObject import org.eclipse.jgit.internal.transport.sshd.JGitClientSession import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode -import java.nio.file.Path -import kotlin.io.path.absolutePathString class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) { val transport get() = userObject as Transport @@ -20,7 +19,7 @@ class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode (transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0 return when (column) { - TransportTableModel.COLUMN_NAME -> PathUtils.getFileNameString(transport.source) + TransportTableModel.COLUMN_NAME -> transport.source.name.baseName TransportTableModel.COLUMN_STATUS -> formatStatus(transport) TransportTableModel.COLUMN_SIZE -> size() TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-" @@ -31,12 +30,14 @@ class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode } } - private fun formatPath(path: Path): String { - if (path.fileSystem.isSFTP()) { - val hostname = ((path.fileSystem as SftpFileSystem).session as JGitClientSession).hostConfigEntry.hostName - return hostname + ":" + path.absolutePathString() + private fun formatPath(file: FileObject): String { + if (file.fileSystem is MySftpFileSystem) { + val session = MySftpFileSystemConfigBuilder.getInstance() + .getClientSession(file.fileSystem.fileSystemOptions) as JGitClientSession + val hostname = session.hostConfigEntry.hostName + return hostname + ":" + file.name.path } - return path.toUri().scheme + ":" + path.absolutePathString() + return file.name.toString() } private fun formatStatus(transport: Transport): String { diff --git a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileObject.kt b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileObject.kt new file mode 100644 index 0000000..07aa665 --- /dev/null +++ b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileObject.kt @@ -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(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() + + 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? { + return null + } + + override fun doListChildrenResolved(): Array? { + if (isFile) return null + + val children = mutableListOf() + + 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 { + 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) { + 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 { + return FileSystemViewTableModel.fromSftpPermissions(getAttributes()?.permissions ?: return setOf()) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileProvider.kt b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileProvider.kt new file mode 100644 index 0000000..dd4faad --- /dev/null +++ b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileProvider.kt @@ -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 { + 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 + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystem.kt b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystem.kt new file mode 100644 index 0000000..5bea61b --- /dev/null +++ b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystem.kt @@ -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) { + 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystemConfigBuilder.kt b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystemConfigBuilder.kt new file mode 100644 index 0000000..b508976 --- /dev/null +++ b/src/main/kotlin/app/termora/vfs2/sftp/MySftpFileSystemConfigBuilder.kt @@ -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 { + return MySftpFileSystem::class.java + } + + + fun setClientSession(options: FileSystemOptions, session: ClientSession) { + setParam(options, "session", session) + } + + fun getClientSession(options: FileSystemOptions): ClientSession? { + return getParam(options, "session") + } +} \ No newline at end of file diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index b2a0449..de3d0ef 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -320,6 +320,7 @@ termora.transport.sftp.status.failed=Failed termora.transport.sftp.already-exists.message1=This folder already contains an object named as below termora.transport.sftp.already-exists.message2=Select a task to do termora.transport.sftp.already-exists.overwrite=Overwrite +termora.transport.sftp.already-exists.append=Append termora.transport.sftp.already-exists.skip=Skip termora.transport.sftp.already-exists.apply-all=Apply all termora.transport.sftp.already-exists.name=Name diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index ff04528..3f5007d 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -298,6 +298,7 @@ termora.transport.sftp.status.failed=已失败 termora.transport.sftp.already-exists.message1=此文件夹已包含一下名称的对象 termora.transport.sftp.already-exists.message2=请选择要执行的操作 termora.transport.sftp.already-exists.overwrite=覆盖 +termora.transport.sftp.already-exists.append=追加 termora.transport.sftp.already-exists.skip=跳过 termora.transport.sftp.already-exists.apply-all=应用全部 termora.transport.sftp.already-exists.name=名称 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 86c7fcd..68276e9 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -292,6 +292,7 @@ termora.transport.sftp.status.failed=已失敗 termora.transport.sftp.already-exists.message1=此資料夾已包含一下名稱的對象 termora.transport.sftp.already-exists.message2=請選擇要執行的操作 termora.transport.sftp.already-exists.overwrite=覆蓋 +termora.transport.sftp.already-exists.append=追加 termora.transport.sftp.already-exists.skip=跳過 termora.transport.sftp.already-exists.apply-all=應用全部 termora.transport.sftp.already-exists.name=名稱 diff --git a/src/test/kotlin/app/termora/SFTPTest.kt b/src/test/kotlin/app/termora/SFTPTest.kt index 6b7de16..c992f69 100644 --- a/src/test/kotlin/app/termora/SFTPTest.kt +++ b/src/test/kotlin/app/termora/SFTPTest.kt @@ -11,11 +11,9 @@ class SFTPTest : SSHDTest() { @Test fun test() { - val client = SshClients.openClient(host) - val session = SshClients.openSession(host, client) + val session = newClientSession() assertTrue(session.isOpen) - val fileSystem = DefaultSftpClientFactory.INSTANCE.createSftpFileSystem(session) for (path in Files.list(fileSystem.rootDirectories.first())) { println(path) diff --git a/src/test/kotlin/app/termora/SSHDTest.kt b/src/test/kotlin/app/termora/SSHDTest.kt index c7d2ac2..af3c453 100644 --- a/src/test/kotlin/app/termora/SSHDTest.kt +++ b/src/test/kotlin/app/termora/SSHDTest.kt @@ -1,8 +1,10 @@ package app.termora +import org.apache.sshd.client.session.ClientSession import org.testcontainers.containers.GenericContainer import kotlin.test.AfterTest import kotlin.test.BeforeTest +import kotlin.test.assertTrue abstract class SSHDTest { @@ -17,8 +19,8 @@ abstract class SSHDTest { .withEnv("SUDO_ACCESS", "true") .withExposedPorts(2222) - protected val host by lazy { - Host( + protected val host + get() = Host( name = sshd.containerName, protocol = Protocol.SSH, host = "127.0.0.1", @@ -26,7 +28,6 @@ abstract class SSHDTest { username = "foo", authentication = Authentication.No.copy(type = AuthenticationType.Password, password = "pass"), ) - } @BeforeTest @@ -38,4 +39,11 @@ abstract class SSHDTest { fun teardown() { sshd.stop() } + + fun newClientSession(): ClientSession { + val client = SshClients.openClient(host) + val session = SshClients.openSession(host, client) + assertTrue(session.isOpen) + return session + } } \ No newline at end of file diff --git a/src/test/kotlin/app/termora/vfs2/sftp/MySftpFileProviderTest.kt b/src/test/kotlin/app/termora/vfs2/sftp/MySftpFileProviderTest.kt new file mode 100644 index 0000000..84ea55d --- /dev/null +++ b/src/test/kotlin/app/termora/vfs2/sftp/MySftpFileProviderTest.kt @@ -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) + } + + +} \ No newline at end of file