Compare commits

...

15 Commits

Author SHA1 Message Date
hstyi
a4364bcd6a release: 2.0.0-beta.3 2025-07-01 11:00:55 +08:00
hstyi
d0827c3b0c chore: improve connect-with 2025-07-01 10:52:45 +08:00
hstyi
036a04b0b3 feat: support SMB 2025-07-01 10:49:27 +08:00
hstyi
eee016c643 chore: supports retaining file modification date 2025-07-01 10:46:17 +08:00
hstyi
472bf6e81f feat: support login scripts 2025-07-01 10:40:06 +08:00
hstyi
21229e352f chore: do not refresh during installation 2025-06-30 17:34:02 +08:00
hstyi
1138f48a6e fix: host deletion query error 2025-06-30 17:18:05 +08:00
hstyi
f044e0480e chore: password show caps lock 2025-06-30 17:16:52 +08:00
hstyi
7047f17783 fix: quick open transfer failure 2025-06-30 14:33:55 +08:00
dependabot[bot]
9308f15abb chore(deps): bump com.qcloud:cos_api from 5.6.245 to 5.6.247
Bumps [com.qcloud:cos_api](https://github.com/tencentyun/cos-java-sdk-v5) from 5.6.245 to 5.6.247.
- [Release notes](https://github.com/tencentyun/cos-java-sdk-v5/releases)
- [Changelog](https://github.com/tencentyun/cos-java-sdk-v5/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tencentyun/cos-java-sdk-v5/commits)

---
updated-dependencies:
- dependency-name: com.qcloud:cos_api
  dependency-version: 5.6.247
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-30 14:29:41 +08:00
dependabot[bot]
b2672f11fc chore(deps): bump org.testcontainers:testcontainers-bom
Bumps [org.testcontainers:testcontainers-bom](https://github.com/testcontainers/testcontainers-java) from 1.21.2 to 1.21.3.
- [Release notes](https://github.com/testcontainers/testcontainers-java/releases)
- [Changelog](https://github.com/testcontainers/testcontainers-java/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testcontainers/testcontainers-java/compare/1.21.2...1.21.3)

---
updated-dependencies:
- dependency-name: org.testcontainers:testcontainers-bom
  dependency-version: 1.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-30 14:29:29 +08:00
dependabot[bot]
f92c6586b2 chore(deps): bump org.semver4j:semver4j from 5.8.0 to 6.0.0
Bumps [org.semver4j:semver4j](https://github.com/semver4j/semver4j) from 5.8.0 to 6.0.0.
- [Release notes](https://github.com/semver4j/semver4j/releases)
- [Commits](https://github.com/semver4j/semver4j/compare/v5.8.0...v6.0.0)

---
updated-dependencies:
- dependency-name: org.semver4j:semver4j
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-30 14:29:20 +08:00
dependabot[bot]
69e07a9bd9 chore(deps): bump com.huaweicloud:esdk-obs-java-bundle
Bumps [com.huaweicloud:esdk-obs-java-bundle](https://github.com/huaweicloud/huaweicloud-sdk-java-obs) from 3.25.4 to 3.25.5.
- [Release notes](https://github.com/huaweicloud/huaweicloud-sdk-java-obs/releases)
- [Commits](https://github.com/huaweicloud/huaweicloud-sdk-java-obs/compare/v3.25.4...v3.25.5)

---
updated-dependencies:
- dependency-name: com.huaweicloud:esdk-obs-java-bundle
  dependency-version: 3.25.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-30 14:29:07 +08:00
dependabot[bot]
cdec60fd25 chore(deps): bump org.xerial:sqlite-jdbc from 3.50.1.0 to 3.50.2.0
Bumps [org.xerial:sqlite-jdbc](https://github.com/xerial/sqlite-jdbc) from 3.50.1.0 to 3.50.2.0.
- [Release notes](https://github.com/xerial/sqlite-jdbc/releases)
- [Changelog](https://github.com/xerial/sqlite-jdbc/blob/master/CHANGELOG)
- [Commits](https://github.com/xerial/sqlite-jdbc/compare/3.50.1.0...3.50.2.0)

---
updated-dependencies:
- dependency-name: org.xerial:sqlite-jdbc
  dependency-version: 3.50.2.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-30 14:28:47 +08:00
hstyi
7c30933794 fix: wsl reg exception 2025-06-30 13:36:35 +08:00
42 changed files with 1064 additions and 56 deletions

View File

@@ -1 +1 @@
2.0.0-beta.2
2.0.0-beta.3

View File

@@ -35,7 +35,7 @@ bip39 = "1.0.9"
colorpicker = "2.0.1"
rhino = "1.8.0"
delight-rhino-sandbox = "0.0.17"
testcontainers = "1.21.2"
testcontainers = "1.21.3"
mixpanel = "1.5.3"
jSerialComm = "2.11.0"
ini4j = "0.5.5-2"
@@ -43,9 +43,9 @@ restart4j = "0.0.1"
eddsa = "0.3.0"
exposed = "1.0.0-beta-2"
h2 = "2.3.232"
sqlite = "3.50.1.0"
sqlite = "3.50.2.0"
jug = "5.1.0"
semver4j = "5.8.0"
semver4j = "6.0.0"
jsvg = "1.4.0"
dom4j = "2.1.4"

View File

@@ -73,3 +73,7 @@ https://www.apache.org/licenses/LICENSE-2.0.html
GeoLite2 (https://www.maxmind.com)
Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)
https://creativecommons.org/licenses/by-sa/4.0/
smbj
Apache License, Version 2.0
https://github.com/hierynomus/smbj/blob/master/LICENSE_HEADER

View File

@@ -8,7 +8,7 @@ project.version = "0.0.2"
dependencies {
testImplementation(kotlin("test"))
implementation("com.qcloud:cos_api:5.6.245")
implementation("com.qcloud:cos_api:5.6.247")
compileOnly(project(":"))
}

View File

@@ -8,7 +8,7 @@ project.version = "0.0.1"
dependencies {
testImplementation(kotlin("test"))
implementation("com.huaweicloud:esdk-obs-java-bundle:3.25.4")
implementation("com.huaweicloud:esdk-obs-java-bundle:3.25.5")
compileOnly(project(":"))
}

View File

@@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
project.version = "0.0.1"
dependencies {
testImplementation(kotlin("test"))
implementation("com.hierynomus:smbj:0.14.0")
compileOnly(project(":"))
}
apply(from = "$rootDir/plugins/common.gradle.kts")

View File

@@ -0,0 +1,15 @@
package app.termora.plugins.smb
import app.termora.transfer.s3.S3FileSystem
import com.hierynomus.smbj.session.Session
import com.hierynomus.smbj.share.DiskShare
class SMBFileSystem(private val share: DiskShare, session: Session) :
S3FileSystem(SMBFileSystemProvider(share, session)) {
override fun close() {
share.close()
super.close()
}
}

View File

@@ -0,0 +1,111 @@
package app.termora.plugins.smb
import app.termora.transfer.s3.S3FileSystemProvider
import app.termora.transfer.s3.S3Path
import com.hierynomus.msdtyp.AccessMask
import com.hierynomus.msfscc.FileAttributes
import com.hierynomus.mssmb2.SMB2CreateDisposition
import com.hierynomus.mssmb2.SMB2CreateOptions
import com.hierynomus.mssmb2.SMB2ShareAccess
import com.hierynomus.smbj.session.Session
import com.hierynomus.smbj.share.DiskShare
import org.apache.commons.io.FilenameUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.AccessMode
import java.nio.file.NoSuchFileException
import java.nio.file.Path
import java.nio.file.attribute.FileAttribute
import kotlin.io.path.absolutePathString
class SMBFileSystemProvider(private val share: DiskShare, private val session: Session) : S3FileSystemProvider() {
override fun getScheme(): String? {
return "smb"
}
override fun getOutputStream(path: S3Path): OutputStream {
val file = share.openFile(
path.absolutePathString(),
setOf(AccessMask.GENERIC_WRITE),
setOf(FileAttributes.FILE_ATTRIBUTE_NORMAL),
setOf(SMB2ShareAccess.FILE_SHARE_READ),
SMB2CreateDisposition.FILE_OVERWRITE_IF,
setOf(SMB2CreateOptions.FILE_NON_DIRECTORY_FILE)
)
val os = file.outputStream
return object : OutputStream() {
override fun write(b: Int) {
os.write(b)
}
override fun close() {
IOUtils.closeQuietly(os)
file.closeNoWait()
}
}
}
override fun getInputStream(path: S3Path): InputStream {
val file = share.openFile(
path.absolutePathString(),
setOf(AccessMask.GENERIC_READ),
setOf(FileAttributes.FILE_ATTRIBUTE_NORMAL),
setOf(SMB2ShareAccess.FILE_SHARE_READ),
SMB2CreateDisposition.FILE_OPEN,
setOf(SMB2CreateOptions.FILE_NON_DIRECTORY_FILE)
)
val input = file.inputStream
return object : InputStream() {
override fun read(): Int = input.read()
override fun close() {
IOUtils.closeQuietly(input)
file.closeNoWait()
}
}
}
override fun fetchChildren(path: S3Path): MutableList<S3Path> {
val paths = mutableListOf<S3Path>()
val absolutePath = FilenameUtils.separatorsToUnix(path.absolutePathString())
for (information in share.list(if (absolutePath == path.fileSystem.separator) StringUtils.EMPTY else absolutePath)) {
if (information.fileName == "." || information.fileName == "..") continue
val isDir = information.fileAttributes and FileAttributes.FILE_ATTRIBUTE_DIRECTORY.value != 0L
val path = path.resolve(information.fileName)
path.attributes = path.attributes.copy(
directory = isDir, regularFile = isDir.not(),
size = information.endOfFile,
lastModifiedTime = information.lastWriteTime.toDate().time,
lastAccessTime = information.lastAccessTime.toDate().time,
)
paths.add(path)
}
return paths
}
override fun createDirectory(dir: Path, vararg attrs: FileAttribute<*>) {
share.mkdir(dir.absolutePathString())
}
override fun delete(path: S3Path, isDirectory: Boolean) {
if (isDirectory) {
share.rmdir(path.absolutePathString(), false)
} else {
share.rm(path.absolutePathString())
}
}
override fun checkAccess(path: S3Path, vararg modes: AccessMode) {
if (share.fileExists(path.absolutePathString()) || share.folderExists(path.absolutePathString())) {
return
}
throw NoSuchFileException(path.absolutePathString())
}
}

View File

@@ -0,0 +1,261 @@
package app.termora.plugins.smb
import app.termora.*
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.ui.FlatTextBorder
import com.hierynomus.smbj.SMBClient
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout
import java.awt.KeyboardFocusManager
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import javax.swing.*
class SMBHostOptionsPane : OptionsPane() {
private val generalOption = GeneralOption()
private val sftpOption = SFTPOption()
init {
addOption(generalOption)
addOption(sftpOption)
}
fun getHost(): Host {
val name = generalOption.nameTextField.text
val protocol = SMBProtocolProvider.PROTOCOL
val host = generalOption.hostTextField.text
val port = generalOption.portTextField.value as Int
var authentication = Authentication.Companion.No
val authenticationType = AuthenticationType.Password
authentication = authentication.copy(
type = authenticationType,
password = String(generalOption.passwordTextField.password)
)
val options = Options.Default.copy(
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text,
extras = mutableMapOf(
"smb.share" to generalOption.shareTextField.text,
)
)
return Host(
name = name,
protocol = protocol,
host = host,
port = port,
username = generalOption.usernameTextField.selectedItem as String,
authentication = authentication,
sort = System.currentTimeMillis(),
remark = generalOption.remarkTextArea.text,
options = options,
)
}
fun setHost(host: Host) {
generalOption.nameTextField.text = host.name
generalOption.usernameTextField.selectedItem = host.username
generalOption.hostTextField.text = host.host
generalOption.portTextField.value = host.port
generalOption.remarkTextArea.text = host.remark
generalOption.passwordTextField.text = host.authentication.password
generalOption.shareTextField.text = host.options.extras["smb.share"] ?: StringUtils.EMPTY
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
}
fun validateFields(): Boolean {
// general
if (validateField(generalOption.nameTextField)
|| validateField(generalOption.hostTextField)
|| validateField(generalOption.shareTextField)
) {
return false
}
val username = generalOption.usernameTextField.selectedItem as String?
if (username.isNullOrBlank()) {
setOutlineError(generalOption.usernameTextField)
return false
}
return true
}
/**
* 返回 true 表示有错误
*/
private fun validateField(textField: JTextField): Boolean {
if (textField.isEnabled && textField.text.isBlank()) {
setOutlineError(textField)
return true
}
return false
}
private fun setOutlineError(textField: JComponent) {
selectOptionJComponent(textField)
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
textField.requestFocusInWindow()
}
private inner class GeneralOption : JPanel(BorderLayout()), Option {
val portTextField = PortSpinner(SMBClient.DEFAULT_PORT)
val nameTextField = OutlineTextField(128)
val shareTextField = OutlineTextField(256)
val usernameTextField = OutlineComboBox<String>()
val hostTextField = OutlineTextField(255)
val passwordTextField = OutlinePasswordField(255)
val remarkTextArea = FixedLengthTextArea(512)
init {
initView()
initEvents()
}
private fun initView() {
usernameTextField.isEditable = true
usernameTextField.addItem("Guest")
usernameTextField.addItem("Anonymous")
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
SwingUtilities.invokeLater { nameTextField.requestFocusInWindow() }
removeComponentListener(this)
}
})
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.settings
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.general")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, pref, $FORM_MARGIN, default",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS)
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS)
)
remarkTextArea.rows = 8
remarkTextArea.lineWrap = true
remarkTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.new-host.general.name")}:").xy(1, rows)
.add(nameTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.host")}:").xy(1, rows)
.add(hostTextField).xy(3, rows)
.add("${I18n.getString("termora.new-host.general.port")}:").xy(5, rows)
.add(portTextField).xy(7, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.username")}:").xy(1, rows)
.add(usernameTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, rows)
.add(passwordTextField).xyw(3, rows, 5).apply { rows += step }
.add("${SMBI18n.getString("termora.plugins.smb.share")}:").xy(1, rows)
.add(shareTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.remark")}:").xy(1, rows)
.add(JScrollPane(remarkTextArea).apply { border = FlatTextBorder() })
.xyw(3, rows, 5).apply { rows += step }
.build()
return panel
}
}
private inner class SFTPOption : JPanel(BorderLayout()), Option {
val defaultDirectoryField = OutlineTextField(255)
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.folder
}
override fun getTitle(): String {
return I18n.getString("termora.transport.sftp")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
.add(defaultDirectoryField).xy(3, rows).apply { rows += step }
.build()
return panel
}
}
}

View File

@@ -0,0 +1,24 @@
package app.termora.plugins.smb
import app.termora.I18n
import app.termora.NamedI18n
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.*
object SMBI18n : NamedI18n("i18n/messages") {
private val log = LoggerFactory.getLogger(SMBI18n::class.java)
override fun getLogger(): Logger {
return log
}
override fun getString(key: String): String {
return try {
substitutor.replace(getBundle().getString(key))
} catch (_: MissingResourceException) {
I18n.getString(key)
}
}
}

View File

@@ -0,0 +1,20 @@
package app.termora.plugins.smb
import app.termora.protocol.PathHandler
import com.hierynomus.smbj.SMBClient
import com.hierynomus.smbj.session.Session
import org.apache.commons.io.IOUtils
import java.nio.file.FileSystem
import java.nio.file.Path
class SMBPathHandler(
private val client: SMBClient,
private val session: Session,
fileSystem: FileSystem, path: Path
) : PathHandler(fileSystem, path) {
override fun dispose() {
super.dispose()
session.close()
IOUtils.closeQuietly(client)
}
}

View File

@@ -0,0 +1,31 @@
package app.termora.plugins.smb
import app.termora.plugin.Extension
import app.termora.plugin.ExtensionSupport
import app.termora.plugin.PaidPlugin
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProviderExtension
class SMBPlugin : PaidPlugin {
private val support = ExtensionSupport()
init {
support.addExtension(ProtocolProviderExtension::class.java) { SMBProtocolProviderExtension.instance }
support.addExtension(ProtocolHostPanelExtension::class.java) { SMBProtocolHostPanelExtension.instance }
}
override fun getAuthor(): String {
return "TermoraDev"
}
override fun getName(): String {
return "SMB"
}
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
return support.getExtensions(clazz)
}
}

View File

@@ -0,0 +1,36 @@
package app.termora.plugins.smb
import app.termora.Disposer
import app.termora.Host
import app.termora.protocol.ProtocolHostPanel
import java.awt.BorderLayout
class SMBProtocolHostPanel : ProtocolHostPanel() {
private val pane = SMBHostOptionsPane()
init {
initView()
initEvents()
}
private fun initView() {
add(pane, BorderLayout.CENTER)
Disposer.register(this, pane)
}
private fun initEvents() {}
override fun getHost(): Host {
return pane.getHost()
}
override fun setHost(host: Host) {
pane.setHost(host)
}
override fun validateFields(): Boolean {
return pane.validateFields()
}
}

View File

@@ -0,0 +1,19 @@
package app.termora.plugins.smb
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
class SMBProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension {
companion object {
val instance by lazy { SMBProtocolHostPanelExtension() }
}
override fun getProtocolProvider(): ProtocolProvider {
return SMBProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
return SMBProtocolHostPanel()
}
}

View File

@@ -0,0 +1,57 @@
package app.termora.plugins.smb
import app.termora.DynamicIcon
import app.termora.Icons
import app.termora.protocol.PathHandler
import app.termora.protocol.PathHandlerRequest
import app.termora.protocol.TransferProtocolProvider
import com.hierynomus.smbj.SMBClient
import com.hierynomus.smbj.auth.AuthenticationContext
import com.hierynomus.smbj.share.DiskShare
import org.apache.commons.io.FilenameUtils
import org.apache.commons.lang3.StringUtils
class SMBProtocolProvider private constructor() : TransferProtocolProvider {
companion object {
val instance by lazy { SMBProtocolProvider() }
const val PROTOCOL = "SMB"
}
override fun getProtocol(): String {
return PROTOCOL
}
override fun getIcon(width: Int, height: Int): DynamicIcon {
return Icons.windows7
}
override fun createPathHandler(requester: PathHandlerRequest): PathHandler {
val client = SMBClient()
val host = requester.host
val connection = client.connect(host.host, host.port)
val session = when (host.username) {
"Guest" -> connection.authenticate(AuthenticationContext.guest())
"Anonymous" -> connection.authenticate(AuthenticationContext.anonymous())
else -> connection.authenticate(
AuthenticationContext(
host.username,
host.authentication.password.toCharArray(),
null
)
)
}
val share = session.connectShare(host.options.extras["smb.share"] ?: StringUtils.EMPTY) as DiskShare
var sftpDefaultDirectory = StringUtils.defaultString(host.options.sftpDefaultDirectory)
sftpDefaultDirectory = if (sftpDefaultDirectory.isNotBlank()) {
FilenameUtils.separatorsToUnix(sftpDefaultDirectory)
} else {
"/"
}
val fs = SMBFileSystem(share, session)
return SMBPathHandler(client, session, fs, fs.getPath(sftpDefaultDirectory))
}
}

View File

@@ -0,0 +1,14 @@
package app.termora.plugins.smb
import app.termora.protocol.ProtocolProvider
import app.termora.protocol.ProtocolProviderExtension
class SMBProtocolProviderExtension private constructor() : ProtocolProviderExtension {
companion object {
val instance by lazy { SMBProtocolProviderExtension() }
}
override fun getProtocolProvider(): ProtocolProvider {
return SMBProtocolProvider.instance
}
}

View File

@@ -0,0 +1,24 @@
<termora-plugin>
<id>smb</id>
<name>SMB</name>
<paid/>
<version>${projectVersion}</version>
<termora-version since=">=${rootProjectVersion}" until=""/>
<entry>app.termora.plugins.smb.SMBPlugin</entry>
<descriptions>
<description>Connecting to SMB</description>
<description language="zh_CN">支持连接到 SMB</description>
<description language="zh_TW">支援連接到 SMB</description>
</descriptions>
<vendor url="https://github.com/TermoraDev">TermoraDev</vendor>
</termora-plugin>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1 @@
termora.plugins.smb.share=Share name

View File

@@ -0,0 +1 @@
termora.plugins.smb.share=共享名称

View File

@@ -0,0 +1 @@
termora.plugins.smb.share=共享名稱

View File

@@ -14,3 +14,4 @@ include("plugins:migration")
include("plugins:editor")
include("plugins:geo")
include("plugins:webdav")
include("plugins:smb")

View File

@@ -2,7 +2,7 @@ package app.termora
import com.formdev.flatlaf.extras.FlatSVGIcon
open class DynamicIcon(name: String, private val darkName: String, val allowColorFilter: Boolean = true) :
open class DynamicIcon(name: String, private val darkName: String = name, val allowColorFilter: Boolean = true) :
FlatSVGIcon(name) {
constructor(name: String) : this(name, name)

View File

@@ -88,7 +88,27 @@ data class SerialComm(
)
@Serializable
data class HostTag(val text: String)
data class LoginScript(
/**
* 等待字符串
*/
val expect: String,
/**
* 等待之后发送
*/
val send: String,
/**
* [expect] 是否是正则
*/
val regex: Boolean = false,
/**
* [expect] 是否大小写匹配,如果为 true 表示不忽略大小写,也就是:'A != a';如果为 false 那么 'A == a'
*/
val matchCase: Boolean = false,
)
@Serializable
@@ -97,6 +117,10 @@ data class Options(
* 跳板机
*/
val jumpHosts: List<String> = mutableListOf(),
/**
* 登录脚本
*/
val loginScripts: List<LoginScript> = emptyList(),
/**
* 编码
*/

View File

@@ -55,6 +55,7 @@ class HostManager private constructor() : Disposable {
fun getHost(id: String): Host? {
val data = databaseManager.data(id) ?: return null
if (data.type != DataType.Host.name) return null
if (data.deleted) return null
return ohMyJson.decodeFromString(data.data)
}

View File

@@ -80,6 +80,7 @@ object Icons {
val ssh by lazy { DynamicIcon("icons/ssh.svg", "icons/ssh_dark.svg") }
val ftp by lazy { DynamicIcon("icons/ftp.svg", "icons/ftp_dark.svg") }
val minio by lazy { DynamicIcon("icons/minio.svg", "icons/minio_dark.svg") }
val windows7 by lazy { DynamicIcon("icons/windows7.svg", allowColorFilter = false) }
val powershell by lazy { DynamicIcon("icons/powershell.svg", "icons/powershell_dark.svg") }
val serial by lazy { DynamicIcon("icons/serial.svg", "icons/serial_dark.svg") }
val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_dark.svg") }

View File

@@ -37,7 +37,7 @@ abstract class PtyHostTerminalTab(
}
// 开启 PTY
val ptyConnector = openPtyConnector()
val ptyConnector = loginScriptsPtyConnector(host, openPtyConnector())
ptyConnectorDelegate.ptyConnector = ptyConnector
// 开启 reader
@@ -81,6 +81,73 @@ abstract class PtyHostTerminalTab(
}
}
/**
* 登录脚本
*/
open fun loginScriptsPtyConnector(host: Host, ptyConnector: PtyConnector): PtyConnector {
val loginScripts = host.options.loginScripts.toMutableList()
if (loginScripts.isEmpty()) {
return ptyConnector
}
return object : PtyConnectorDelegate(ptyConnector) {
override fun read(buffer: CharArray): Int {
val len = super.read(buffer)
// 获取一个匹配的登录脚本
val scripts = runCatching { popLoginScript(buffer, len) }.getOrNull() ?: return len
if (scripts.isEmpty()) return len
for (script in scripts) {
// send
write(script.send.toByteArray(getCharset()))
// send \r or \n
val enter = terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
.toByteArray(getCharset())
write(enter)
}
return len
}
private fun popLoginScript(buffer: CharArray, len: Int): List<LoginScript> {
if (loginScripts.isEmpty()) return emptyList()
if (len < 1) return emptyList()
val scripts = mutableListOf<LoginScript>()
val text = String(buffer, 0, len)
val iterator = loginScripts.iterator()
while (iterator.hasNext()) {
val script = iterator.next()
if (script.expect.isEmpty()) {
scripts.add(script)
iterator.remove()
continue
} else if (script.regex) {
val regex = if (script.matchCase) script.expect.toRegex()
else script.expect.toRegex(RegexOption.IGNORE_CASE)
if (regex.matches(text)) {
scripts.add(script)
iterator.remove()
continue
}
} else {
if (text.contains(script.expect, script.matchCase.not())) {
scripts.add(script)
iterator.remove()
continue
}
}
break
}
return scripts
}
}
}
open fun sendStartupCommand(ptyConnector: PtyConnector, bytes: ByteArray) {
ptyConnector.write(bytes)
}

View File

@@ -59,6 +59,7 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) {
size = Dimension(max(preferredSize.width, UIManager.getInt("Dialog.width") - 250), preferredSize.height)
setLocationRelativeTo(owner)
passwordField.putClientProperty(FlatClientProperties.STYLE, mapOf("showCapsLock" to true))
addWindowListener(object : WindowAdapter() {
override fun windowOpened(e: WindowEvent) {

View File

@@ -45,8 +45,8 @@ class NewKeywordHighlightDialog(
Color(colorPalette.getColor(TerminalColor.Basic.BACKGROUND)),
I18n.getString("termora.highlight.background-color")
)
val matchCaseBtn = JToggleButton(Icons.matchCase)
val regexBtn = JToggleButton(Icons.regex)
val matchCaseBtn = JToggleButton(Icons.matchCase).apply { toolTipText = I18n.getString("termora.match-case") }
val regexBtn = JToggleButton(Icons.regex).apply { toolTipText = I18n.getString("termora.regex") }
private val textColorRevert = JButton(Icons.revert)

View File

@@ -15,10 +15,17 @@ import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.event.ActionEvent
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import javax.swing.*
class MarketplacePanel : JPanel(BorderLayout()), Disposable {
companion object {
/**
* 正在安装的数量
*/
val installing = AtomicInteger(0)
}
private val pluginsPanel = JPanel(VerticalFlowLayout(VerticalFlowLayout.TOP, 0, 8))
private val cardLayout = CardLayout()
@@ -93,6 +100,7 @@ class MarketplacePanel : JPanel(BorderLayout()), Disposable {
}
fun reload() {
if (installing.get() > 0) return
if (isLoading.compareAndSet(false, true)) {
coroutineScope.launch {
withContext(Dispatchers.Swing) {

View File

@@ -20,7 +20,6 @@ import org.apache.commons.net.io.Util
import org.jdesktop.swingx.JXLabel
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import javax.swing.*
@@ -33,11 +32,6 @@ class PluginPanel(val descriptor: PluginPluginDescriptor) : JPanel(), Disposable
private val log = LoggerFactory.getLogger(PluginPanel::class.java)
private val installed = mutableSetOf<String>()
private val uninstalled = mutableSetOf<String>()
/**
* 正在安装的数量
*/
private val installing = AtomicInteger(0)
private val publicKey = Ed25519.generatePublic(
Base64.decodeBase64("MCowBQYDK2VwAyEAHPyJ5kt2UHWYUPnWU84DOEhCCUE5FEpzdAbeTCNV31A")
)
@@ -47,7 +41,7 @@ class PluginPanel(val descriptor: PluginPluginDescriptor) : JPanel(), Disposable
private val updateButton = InstallButton().apply { update = true }
private val installButton = InstallButton()
private val uninstallButton = JButton(I18n.getString("termora.settings.plugin.uninstall"))
private val installing get() = MarketplacePanel.installing
private val restarter get() = TermoraRestarter.getInstance()
private val pluginManager get() = PluginManager.getInstance()
private val owner get() = SwingUtilities.getWindowAncestor(this)

View File

@@ -188,6 +188,11 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
// Nothing
}
override fun loginScriptsPtyConnector(host: Host, ptyConnector: PtyConnector): PtyConnector {
// Nothing
return ptyConnector
}
private inner class PasswordReporterDataListener(private val host: Host) : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
if (key == VisualTerminal.Companion.Written && data is String) {

View File

@@ -9,6 +9,9 @@ import app.termora.tree.HostTreeNode
import app.termora.tree.NewHostTreeDialog
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.extras.components.FlatTable
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.ui.FlatTextBorder
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
@@ -23,6 +26,7 @@ import java.nio.charset.Charset
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel
import kotlin.math.max
@Suppress("CascadeIf")
open class SSHHostOptionsPane : OptionsPane() {
@@ -95,6 +99,7 @@ open class SSHHostOptionsPane : OptionsPane() {
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text,
enableX11Forwarding = tunnelingOption.x11ForwardingCheckBox.isSelected,
x11Forwarding = tunnelingOption.x11ServerTextField.text,
loginScripts = terminalOption.loginScripts,
)
return Host(
@@ -138,6 +143,7 @@ open class SSHHostOptionsPane : OptionsPane() {
terminalOption.environmentTextArea.text = host.options.env
terminalOption.startupCommandTextField.text = host.options.startupCommand
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
terminalOption.loginScripts.addAll(host.options.loginScripts)
tunnelingOption.tunnelings.addAll(host.tunnelings)
tunnelingOption.x11ForwardingCheckBox.isSelected = host.options.enableX11Forwarding
@@ -367,11 +373,11 @@ open class SSHHostOptionsPane : OptionsPane() {
private fun chooseKeyPair() {
val dialog = KeyManagerDialog(
SwingUtilities.getWindowAncestor(this),
owner,
selectMode = true,
)
dialog.pack()
dialog.setLocationRelativeTo(null)
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
val selectedItem = publicKeyComboBox.selectedItem
@@ -486,15 +492,61 @@ open class SSHHostOptionsPane : OptionsPane() {
val startupCommandTextField = OutlineTextField()
val heartbeatIntervalTextField = IntSpinner(30, minimum = 3, maximum = Int.MAX_VALUE)
val environmentTextArea = FixedLengthTextArea(2048)
val loginScripts = mutableListOf<LoginScript>()
private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add"))
private val editBtn = JButton(I18n.getString("termora.new-host.tunneling.edit"))
private val deleteBtn = JButton(I18n.getString("termora.new-host.tunneling.delete"))
private val table = FlatTable()
private val model = object : DefaultTableModel() {
override fun getRowCount(): Int {
return loginScripts.size
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
fun addRow(loginScript: LoginScript) {
val rowCount = super.getRowCount()
loginScripts.add(loginScript)
super.fireTableRowsInserted(rowCount, rowCount + 1)
}
override fun getValueAt(row: Int, column: Int): Any {
val loginScript = loginScripts[row]
return when (column) {
0 -> loginScript.expect
1 -> loginScript.send
else -> super.getValueAt(row, column)
}
}
}
private val tabbed = FlatTabbedPane()
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
addBtn.isFocusable = false
editBtn.isFocusable = false
deleteBtn.isFocusable = false
deleteBtn.isEnabled = false
editBtn.isEnabled = false
tabbed.styleMap = mapOf(
"focusColor" to DynamicColor("TabbedPane.background"),
"hoverColor" to DynamicColor("TabbedPane.background"),
)
tabbed.tabHeight = UIManager.getInt("TabbedPane.tabHeight") - 4
putClientProperty("ContentPanelBorder", BorderFactory.createEmptyBorder())
tabbed.addTab(I18n.getString("termora.new-host.general"), getCenterComponent())
tabbed.addTab(I18n.getString("termora.new-host.terminal.login-scripts"), getLoginScriptsComponent())
add(tabbed, BorderLayout.CENTER)
environmentTextArea.setFocusTraversalKeys(
@@ -521,7 +573,39 @@ open class SSHHostOptionsPane : OptionsPane() {
}
private fun initEvents() {
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = LoginScriptDialog(owner)
dialog.isVisible = true
model.addRow(dialog.loginScript ?: return)
}
})
editBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = LoginScriptDialog(owner, loginScripts[table.selectedRow])
dialog.isVisible = true
loginScripts[table.selectedRow] = dialog.loginScript ?: return
model.fireTableRowsUpdated(table.selectedRow, table.selectedRow)
}
})
deleteBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val rows = table.selectedRows
if (rows.isEmpty()) return
rows.sortDescending()
for (row in rows) {
loginScripts.removeAt(row)
model.fireTableRowsDeleted(row, row)
}
}
})
table.selectionModel.addListSelectionListener {
deleteBtn.isEnabled = table.selectedRowCount > 0
editBtn.isEnabled = deleteBtn.isEnabled
}
}
@@ -546,6 +630,7 @@ open class SSHHostOptionsPane : OptionsPane() {
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.border(BorderFactory.createEmptyBorder(6, 8, 6, 8))
.add("${I18n.getString("termora.new-host.terminal.encoding")}:").xy(1, rows)
.add(charsetComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.heartbeat-interval")}:").xy(1, rows)
@@ -560,6 +645,124 @@ open class SSHHostOptionsPane : OptionsPane() {
return panel
}
private fun getLoginScriptsComponent(): JComponent {
val panel = JPanel(BorderLayout())
val scrollPane = JScrollPane(table)
model.addColumn(I18n.getString("termora.new-host.terminal.expect"))
model.addColumn(I18n.getString("termora.new-host.terminal.send"))
table.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
)
)
table.model = model
table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS
table.setDefaultRenderer(
Any::class.java,
DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER })
table.fillsViewportHeight = true
scrollPane.border = BorderFactory.createCompoundBorder(
BorderFactory.createEmptyBorder(4, 0, 4, 0),
BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.Companion.BorderColor)
)
table.border = BorderFactory.createEmptyBorder()
val box = Box.createHorizontalBox()
box.add(addBtn)
box.add(Box.createHorizontalStrut(4))
box.add(editBtn)
box.add(Box.createHorizontalStrut(4))
box.add(deleteBtn)
panel.add(scrollPane, BorderLayout.CENTER)
panel.add(box, BorderLayout.SOUTH)
panel.border = BorderFactory.createEmptyBorder(6, 8, 6, 8)
return panel
}
private inner class LoginScriptDialog(
owner: Window,
var loginScript: LoginScript? = null
) : DialogWrapper(owner) {
private val formMargin = "4dlu"
private val expectTextField = OutlineTextField()
private val sendTextField = OutlineTextField()
private val regexToggleBtn = JToggleButton(Icons.regex)
.apply { toolTipText = I18n.getString("termora.regex") }
private val matchCaseToggleBtn = JToggleButton(Icons.matchCase)
.apply { toolTipText = I18n.getString("termora.match-case") }
init {
isModal = true
title = I18n.getString("termora.new-host.terminal.login-scripts")
controlsVisible = false
init()
pack()
size = Dimension(max(UIManager.getInt("Dialog.width") - 300, 250), preferredSize.height)
setLocationRelativeTo(owner)
val toolbar = FlatToolBar().apply { isFloatable = false }
toolbar.add(regexToggleBtn)
toolbar.add(matchCaseToggleBtn)
expectTextField.trailingComponent = toolbar
expectTextField.placeholderText = I18n.getString("termora.optional")
val script = loginScript
if (script != null) {
expectTextField.text = script.expect
sendTextField.text = script.send
matchCaseToggleBtn.isSelected = script.matchCase
regexToggleBtn.isSelected = script.regex
}
}
override fun doOKAction() {
if (sendTextField.text.isBlank()) {
sendTextField.outline = "error"
sendTextField.requestFocusInWindow()
return
}
loginScript = LoginScript(
expect = expectTextField.text,
send = sendTextField.text,
matchCase = matchCaseToggleBtn.isSelected,
regex = regexToggleBtn.isSelected,
)
super.doOKAction()
}
override fun doCancelAction() {
loginScript = null
super.doCancelAction()
}
override fun createCenterPanel(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref"
)
var rows = 1
val step = 2
return FormBuilder.create().layout(layout).padding("0dlu, $formMargin, $formMargin, $formMargin")
.add("${I18n.getString("termora.new-host.terminal.expect")}:").xy(1, rows)
.add(expectTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.send")}:").xy(1, rows)
.add(sendTextField).xy(3, rows).apply { rows += step }
.build()
}
}
}
protected inner class SFTPOption : JPanel(BorderLayout()), Option {
@@ -720,7 +923,7 @@ open class SSHHostOptionsPane : OptionsPane() {
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent?) {
val dialog = PortForwardingDialog(SwingUtilities.getWindowAncestor(this@SSHHostOptionsPane))
val dialog = PortForwardingDialog(owner)
dialog.isVisible = true
val tunneling = dialog.tunneling ?: return
model.addRow(tunneling)
@@ -735,7 +938,7 @@ open class SSHHostOptionsPane : OptionsPane() {
return
}
val dialog = PortForwardingDialog(
SwingUtilities.getWindowAncestor(this@SSHHostOptionsPane),
owner,
tunnelings[row]
)
dialog.isVisible = true
@@ -835,7 +1038,7 @@ open class SSHHostOptionsPane : OptionsPane() {
init()
pack()
size = Dimension(UIManager.getInt("Dialog.width") - 300, size.height)
setLocationRelativeTo(null)
setLocationRelativeTo(owner)
}

View File

@@ -21,34 +21,40 @@ object WSLSupport {
fun getDistributions(): List<WSLDistribution> {
if (isSupported.not()) return emptyList()
val baseKeyPath = "Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"
val guids = Advapi32Util.registryGetKeys(WinReg.HKEY_CURRENT_USER, baseKeyPath)
val distributions = mutableListOf<WSLDistribution>()
for (guid in guids) {
val key = baseKeyPath + "\\" + guid
try {
if (Advapi32Util.registryKeyExists(WinReg.HKEY_CURRENT_USER, key)) {
val distroName =
Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "DistributionName")
val basePath = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "BasePath")
val flavor = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "Flavor")
if (StringUtils.isAnyBlank(distroName, guid, basePath, flavor)) continue
distributions.add(
WSLDistribution(
guid = guid,
flavor = flavor,
basePath = basePath,
distributionName = distroName
try {
val baseKeyPath = "Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"
val guids = Advapi32Util.registryGetKeys(WinReg.HKEY_CURRENT_USER, baseKeyPath)
for (guid in guids) {
val key = baseKeyPath + "\\" + guid
try {
if (Advapi32Util.registryKeyExists(WinReg.HKEY_CURRENT_USER, key)) {
val distroName =
Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "DistributionName")
val basePath = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "BasePath")
val flavor = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "Flavor")
if (StringUtils.isAnyBlank(distroName, guid, basePath, flavor)) continue
distributions.add(
WSLDistribution(
guid = guid,
flavor = flavor,
basePath = basePath,
distributionName = distroName
)
)
)
}
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn(e.message, e)
}
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn(e.message, e)
}
}
}
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn(e.message, e)
}
}
return distributions

View File

@@ -1,12 +1,19 @@
package app.termora.transfer
import app.termora.database.DatabaseManager
import org.apache.commons.io.IOUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.common.SftpConstants
import org.slf4j.LoggerFactory
import java.io.Closeable
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributeView
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.inputStream
import kotlin.io.path.outputStream
@@ -15,10 +22,13 @@ class FileTransfer(
private val action: TransferAction,
priority: Transfer.Priority = Transfer.Priority.Normal,
) : AbstractTransfer(parentId, source, target, false, priority), Closeable {
companion object {
private val log = LoggerFactory.getLogger(FileTransfer::class.java)
}
private lateinit var input: InputStream
private lateinit var output: OutputStream
private val isPreserveModificationTime get() = DatabaseManager.getInstance().sftp.preserveModificationTime
private val closed = AtomicBoolean(false)
override suspend fun transfer(bufferSize: Int): Long {
@@ -31,7 +41,7 @@ class FileTransfer(
if (::output.isInitialized.not()) {
output = if (action == TransferAction.Overwrite) {
target().outputStream(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)
target().outputStream()
} else {
target().outputStream(StandardOpenOption.WRITE, StandardOpenOption.APPEND)
}
@@ -40,6 +50,7 @@ class FileTransfer(
val buffer = ByteArray(bufferSize)
val len = input.read(buffer)
if (len <= 0) return 0
output.write(buffer, 0, len)
return len.toLong()
}
@@ -61,6 +72,26 @@ class FileTransfer(
if (::output.isInitialized) {
IOUtils.closeQuietly(output)
}
if (isPreserveModificationTime) {
runCatching {
val time = source().getLastModifiedTime()
val fs = target().fileSystem
// SFTP 比较特殊
if (fs is SftpFileSystem && fs.version == SftpConstants.SFTP_V3) {
val view = Files.getFileAttributeView(target(), BasicFileAttributeView::class.java)
view.setTimes(time, time, null)
} else {
Files.setLastModifiedTime(target(), time)
}
}.onFailure {
if (log.isWarnEnabled) {
if (it !is UnsupportedOperationException) {
log.warn(it.message, it)
}
}
}
}
}
}

View File

@@ -56,17 +56,24 @@ class TransferAnAction : AnAction(I18n.getString("termora.transport.sftp"), Icon
}
val host = hostManager.getHost(hostId) ?: return
var selectionPane: TransportSelectionPanel? = null
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getComponentAt(i)
if (c is TransportSelectionPanel) {
if (c.state == TransportSelectionPanel.State.Initialized) {
c.connect(host)
return
selectionPane = c
break
}
}
}
tabbed.addSelectionTab()
if (selectionPane == null) {
selectionPane = tabbed.addSelectionTab()
}
selectionPane.connect(host)
}
}

View File

@@ -11,6 +11,7 @@ import com.formdev.flatlaf.extras.components.FlatTextField
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.ui.FlatLineBorder
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.FilenameUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
@@ -205,7 +206,7 @@ class TransportNavigationPanel(
if (path.fileSystem.isWindowsFileSystem() && path.pathString == path.fileSystem.separator) {
textField.text = StringUtils.EMPTY
} else {
textField.text = path.absolutePathString()
textField.text = FilenameUtils.separatorsToUnix(path.absolutePathString())
}
}

View File

@@ -332,8 +332,8 @@ class NewHostTree : SimpleTree(), Disposable {
importMenu.isEnabled = lastNode.isFolder
// 如果选中了 SSH 服务器,那么才启用
openWithSFTP.isEnabled = fullNodes.map { it.host }.any { it.protocol == SSHProtocolProvider.PROTOCOL }
openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled
openWithSFTP.isEnabled = fullNodes.map { it.host }.any { TransferProtocolProvider.valueOf(it.protocol) != null }
openWithSFTPCommand.isEnabled = fullNodes.map { it.host }.any { it.protocol == SSHProtocolProvider.PROTOCOL }
openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled }

View File

@@ -14,6 +14,9 @@ termora.file=File
termora.explorer=Explorer
termora.quit-confirm=Quit {0}?
termora.regex=Regex
termora.match-case=Match Case
termora.optional=Optional
# update
termora.update.title=New version
@@ -192,6 +195,9 @@ termora.new-host.terminal.encoding=Encoding
termora.new-host.terminal.heartbeat-interval=Heartbeat Interval
termora.new-host.terminal.startup-commands=Startup Command
termora.new-host.terminal.env=Environment
termora.new-host.terminal.login-scripts=Login Scripts
termora.new-host.terminal.expect=Expect
termora.new-host.terminal.send=Send
termora.new-host.serial=Serial
termora.new-host.serial.port=Port

View File

@@ -13,6 +13,12 @@ termora.file=文件
termora.explorer=文件管理器
termora.quit-confirm=你要退出 {0} 吗?
termora.regex=正则表达式
termora.match-case=匹配大小写
termora.optional=可选的
# update
termora.update.title=新版本
termora.update.update=更新
@@ -180,6 +186,10 @@ termora.new-host.terminal.encoding=编码
termora.new-host.terminal.heartbeat-interval=心跳间隔
termora.new-host.terminal.startup-commands=启动命令
termora.new-host.terminal.env=环境
termora.new-host.terminal.login-scripts=登录脚本
termora.new-host.terminal.expect=预期
termora.new-host.terminal.send=发送
termora.new-host.serial=串口

View File

@@ -12,6 +12,10 @@ termora.file=文件
termora.explorer=檔案管理器
termora.quit-confirm=你要退出 {0} 嗎?
termora.regex=正規表示式
termora.match-case=匹配大小寫
termora.optional=可選的
# update
termora.update.title=新版本
termora.update.update=更新
@@ -181,6 +185,9 @@ termora.new-host.terminal.encoding=編碼
termora.new-host.terminal.startup-commands=啟動命令
termora.new-host.terminal.heartbeat-interval=心跳間隔
termora.new-host.terminal.env=環境
termora.new-host.terminal.login-scripts=登入腳本
termora.new-host.terminal.expect=預期
termora.new-host.terminal.send=發送
termora.new-host.serial=串口
termora.new-host.serial.port=端口

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB