feat: SFTP file exists and prompts to overwrite (#426)

This commit is contained in:
hstyi
2025-03-29 13:41:02 +08:00
committed by GitHub
parent 614514c87e
commit 09b3655c4e
10 changed files with 252 additions and 34 deletions

View File

@@ -93,6 +93,7 @@ object Icons {
val sortedSet by lazy { DynamicIcon("icons/sortedSet.svg", "icons/sortedSet_dark.svg") }
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") }
val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
val file by lazy { DynamicIcon("icons/file.svg", "icons/file_dark.svg") }
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
val left by lazy { DynamicIcon("icons/left.svg", "icons/left_dark.svg") }
val right by lazy { DynamicIcon("icons/right.svg", "icons/right_dark.svg") }

View File

@@ -29,6 +29,7 @@ object OptionPane {
icon: Icon? = null,
options: Array<Any>? = null,
initialValue: Any? = null,
customizeDialog: (JDialog) -> Unit = {},
): Int {
val panel = if (message is JComponent) {
@@ -47,6 +48,9 @@ object OptionPane {
override fun selectInitialValue() {
super.selectInitialValue()
if (message is JComponent) {
if (message.getClientProperty("SKIP_requestFocusInWindow") == true) {
return
}
message.requestFocusInWindow()
}
}
@@ -58,6 +62,7 @@ object OptionPane {
}
})
dialog.setLocationRelativeTo(parentComponent)
customizeDialog.invoke(dialog)
dialog.isVisible = true
dialog.dispose()
val selectedValue = pane.value

View File

@@ -4,13 +4,17 @@ import app.termora.*
import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.util.SystemInfo
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
@@ -18,6 +22,7 @@ import org.apache.sshd.sftp.client.fs.SftpPosixFileAttributes
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.awt.Component
import java.awt.Dimension
import java.awt.Insets
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
@@ -37,6 +42,7 @@ import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import kotlin.collections.ArrayDeque
import kotlin.io.path.*
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
@@ -556,22 +562,6 @@ class FileSystemViewTable(
fileSystemViewPanel.newFolderOrFile(text, isFile)
}
private fun transfer(
attrs: Array<FileSystemViewTableModel.Attr>,
fromLocalSystem: Boolean = false,
targetWorkdir: Path? = null
) {
coroutineScope.launch {
try {
doTransfer(attrs, fromLocalSystem, targetWorkdir)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
private fun deletePaths(paths: Array<Path>, rm: Boolean = false) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
@@ -657,6 +647,183 @@ class FileSystemViewTable(
}
private fun transfer(
attrs: Array<FileSystemViewTableModel.Attr>,
fromLocalSystem: Boolean = false,
targetWorkdir: Path? = null
) {
assertEventDispatchThread()
val target = sftpPanel.getTarget(table) ?: return
val table = target.getData(SFTPDataProviders.FileSystemViewTable) ?: return
var overwriteAll = false
for (attr in attrs) {
if (!overwriteAll) {
val targetAttr = 0.rangeUntil(table.model.rowCount).map { table.model.getAttr(it) }
.find { it.name == attr.name }
if (targetAttr != null) {
val askTransfer = askTransfer(attr, targetAttr)
if (askTransfer.option != JOptionPane.YES_OPTION) {
continue
}
if (askTransfer.action == AskTransfer.Action.Skip) {
if (askTransfer.applyAll) break
continue
} else if (askTransfer.action == AskTransfer.Action.Overwrite) {
overwriteAll = askTransfer.applyAll
}
}
}
coroutineScope.launch {
try {
doTransfer(arrayOf(attr), fromLocalSystem, targetWorkdir)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
}
private data class AskTransfer(
val option: Int,
val action: Action,
val applyAll: Boolean
) {
enum class Action {
Overwrite,
Skip
}
}
private fun askTransfer(
sourceAttr: FileSystemViewTableModel.Attr,
targetAttr: FileSystemViewTableModel.Attr
): AskTransfer {
val formMargin = "7dlu"
val layout = FormLayout(
"left:pref, $formMargin, default:grow, 2dlu, left:pref",
"pref, 12dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val iconSize = 36
val targetIcon = if (SystemInfo.isWindows)
NativeFileIcons.getIcon(targetAttr.name, targetAttr.isFile, iconSize, iconSize).first
else if (targetAttr.isDirectory) {
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) {
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 actionsComBoBox = JComboBox<AskTransfer.Action>()
actionsComBoBox.addItem(AskTransfer.Action.Overwrite)
actionsComBoBox.addItem(AskTransfer.Action.Skip)
actionsComBoBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value?.toString() ?: StringUtils.EMPTY
if (value == AskTransfer.Action.Overwrite) {
text = I18n.getString("termora.transport.sftp.already-exists.overwrite")
} else if (value == AskTransfer.Action.Skip) {
text = I18n.getString("termora.transport.sftp.already-exists.skip")
}
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
}
}
val applyAllCheckbox = JCheckBox(I18n.getString("termora.transport.sftp.already-exists.apply-all"))
val box = Box.createHorizontalBox()
box.add(actionsComBoBox)
box.add(Box.createHorizontalStrut(8))
box.add(applyAllCheckbox)
box.add(Box.createHorizontalGlue())
val ttBox = Box.createVerticalBox()
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message1")))
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message2")))
val warningIcon = FlatSVGIcon(
Icons.warningIntroduction.name,
iconSize,
iconSize
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
// tip
.add(JLabel(warningIcon)).xy(1, rows, "center, fill")
.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 }
// separator
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
// Destination
.add("${I18n.getString("termora.transport.sftp.already-exists.destination")}:").xy(1, rows)
.apply { rows += step }
// Folder
.add(JLabel(targetIcon)).xy(1, rows, "center, fill")
.add(targetModified).xyw(3, rows, 3).apply { rows += step }
// Source
.add("${I18n.getString("termora.transport.sftp.already-exists.source")}:").xy(1, rows)
.apply { rows += step }
// Folder
.add(JLabel(sourceIcon)).xy(1, rows, "center, fill")
.add(sourceModified).xyw(3, rows, 3).apply { rows += step }
// separator
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
// name
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.actions")}:")).xy(1, rows)
.add(box).xyw(3, rows, 3).apply { rows += step }
.build()
panel.putClientProperty("SKIP_requestFocusInWindow", true)
return AskTransfer(
option = OptionPane.showConfirmDialog(
owner, panel,
messageType = JOptionPane.PLAIN_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION,
title = sourceAttr.name,
initialValue = JOptionPane.YES_OPTION,
) {
it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height)
},
action = actionsComBoBox.selectedItem as AskTransfer.Action,
applyAll = applyAllCheckbox.isSelected
)
}
/**
* 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止

View File

@@ -7,6 +7,7 @@ import com.formdev.flatlaf.icons.FlatTreeLeafIcon
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.FileUtils
import org.apache.commons.io.FilenameUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.eclipse.jgit.util.LRUMap
import java.util.*
@@ -24,7 +25,7 @@ object NativeFileIcons {
init {
if (SystemUtils.IS_OS_UNIX) {
cache[SystemUtils.USER_HOME] = Pair(FlatTreeClosedIcon(), I18n.getString("termora.folder"))
cache[SystemUtils.USER_HOME] = Pair(folderIcon, I18n.getString("termora.folder"))
}
}
@@ -36,35 +37,30 @@ object NativeFileIcons {
return getIcon(filename, true).first
}
fun getIcon(filename: String, isFile: Boolean = true): Pair<Icon, String> {
if (isFile) {
val extension = FilenameUtils.getExtension(filename)
if (cache.containsKey(extension)) {
return cache.getValue(extension)
}
} else {
if (cache.containsKey(SystemUtils.USER_HOME)) {
return cache.getValue(SystemUtils.USER_HOME)
}
fun getIcon(filename: String, isFile: Boolean = true, width: Int = 16, height: Int = 16): Pair<Icon, String> {
val key = if (isFile) FilenameUtils.getExtension(filename) + "." + width + "@" + height
else SystemUtils.USER_HOME + "." + width + "@" + height
if (cache.containsKey(key)) {
return cache.getValue(key)
}
val isDirectory = !isFile
if (SystemInfo.isWindows) {
val file = if (isDirectory) FileUtils.getFile(SystemUtils.USER_HOME) else
FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}.${filename}")
if (isFile && !file.exists()) {
file.createNewFile()
}
val icon = getFileSystemView().getSystemIcon(file, 16, 16)
val icon = getFileSystemView().getSystemIcon(file, width, height) ?: if (isFile) fileIcon else folderIcon
val description = getFileSystemView().getSystemTypeDescription(file)
?: StringUtils.defaultString(file.extension)
val pair = icon to description
if (isDirectory) {
cache[SystemUtils.USER_HOME] = pair
} else {
cache[FilenameUtils.getExtension(file.name)] = pair
}
cache[key] = pair
if (isFile) FileUtils.deleteQuietly(file)

View File

@@ -314,6 +314,16 @@ termora.transport.sftp.status.waiting=Waiting
termora.transport.sftp.status.done=Done
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.skip=Skip
termora.transport.sftp.already-exists.apply-all=Apply all
termora.transport.sftp.already-exists.name=Name
termora.transport.sftp.already-exists.destination=Destination
termora.transport.sftp.already-exists.source=Source
termora.transport.sftp.already-exists.actions=Actions
# transport job
termora.transport.jobs.table.name=Name

View File

@@ -293,6 +293,19 @@ termora.transport.sftp.status.done=已完成
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.skip=跳过
termora.transport.sftp.already-exists.apply-all=应用全部
termora.transport.sftp.already-exists.name=名称
termora.transport.sftp.already-exists.destination=目标文件
termora.transport.sftp.already-exists.source=源文件
termora.transport.sftp.already-exists.actions=操作
# Permission
termora.transport.permissions=更改权限
termora.transport.permissions.file-folder-permissions=文件/文件夹权限

View File

@@ -287,6 +287,16 @@ termora.transport.sftp.status.waiting=等待中
termora.transport.sftp.status.done=已完成
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.skip=跳過
termora.transport.sftp.already-exists.apply-all=應用全部
termora.transport.sftp.already-exists.name=名稱
termora.transport.sftp.already-exists.destination=目標文件
termora.transport.sftp.already-exists.source=原始檔
termora.transport.sftp.already-exists.actions=操作
# transport job
termora.transport.jobs.table.name=名稱
termora.transport.jobs.table.status=狀態

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" fill-rule="evenodd">
<rect width="11" height="13" x="2.5" y="1.5" stroke="#6C707E" rx="1.5"/>
<line x1="5.5" x2="10.5" y1="5.5" y2="5.5" stroke="#6C707E" stroke-linecap="round"/>
<line x1="5.5" x2="10.5" y1="8" y2="8" stroke="#6C707E" stroke-linecap="round"/>
<line x1="5.5" x2="10.5" y1="10.5" y2="10.5" stroke="#6C707E" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 497 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" fill-rule="evenodd">
<rect width="11" height="13" x="2.5" y="1.5" stroke="#CED0D6" rx="1.5"/>
<line x1="5.5" x2="10.5" y1="5.5" y2="5.5" stroke="#CED0D6" stroke-linecap="round"/>
<line x1="5.5" x2="10.5" y1="8" y2="8" stroke="#CED0D6" stroke-linecap="round"/>
<line x1="5.5" x2="10.5" y1="10.5" y2="10.5" stroke="#CED0D6" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 497 B