From cee0c863f8b55d5745edf998356658de9142cb2f Mon Sep 17 00:00:00 2001 From: hstyi Date: Wed, 25 Jun 2025 16:36:48 +0800 Subject: [PATCH] feat: support S3 transfer protocol --- plugins/s3/build.gradle.kts | 2 +- .../termora/plugins/s3/S3FileAttributes.kt | 52 +++ .../app/termora/plugins/s3/S3FileObject.kt | 223 ------------- .../app/termora/plugins/s3/S3FileProvider.kt | 47 --- .../app/termora/plugins/s3/S3FileSystem.kt | 54 ++-- .../plugins/s3/S3FileSystemConfigBuilder.kt | 14 - .../plugins/s3/S3FileSystemProvider.kt | 299 ++++++++++++++++++ .../termora/plugins/s3/S3HostOptionsPane.kt | 1 + .../kotlin/app/termora/plugins/s3/S3Path.kt | 54 ++++ .../termora/plugins/s3/S3ProtocolProvider.kt | 28 +- .../plugins/s3/S3ProtocolProviderExtension.kt | 2 +- .../plugins/s3/S3ReadSeekableByteChannel.kt | 51 +++ .../plugins/s3/S3WriteSeekableByteChannel.kt | 44 +++ .../termora/plugins/s3/S3FileProviderTest.kt | 112 ------- .../termora/plugins/s3/S3FileSystemTest.kt | 103 ++++++ settings.gradle.kts | 2 +- .../kotlin/app/termora/NewHostDialogV2.kt | 58 ++-- .../app/termora/actions/OpenHostAction.kt | 2 +- .../kotlin/app/termora/transfer/ScaleIcon.kt | 3 +- .../app/termora/transfer/TransportPanel.kt | 4 +- .../transfer/TransportSelectionPanel.kt | 2 +- src/main/resources/i18n/messages.properties | 3 + .../resources/i18n/messages_zh_CN.properties | 4 + .../resources/i18n/messages_zh_TW.properties | 3 + 24 files changed, 701 insertions(+), 466 deletions(-) create mode 100644 plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileAttributes.kt delete mode 100644 plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileObject.kt delete mode 100644 plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileProvider.kt delete mode 100644 plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemConfigBuilder.kt create mode 100644 plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemProvider.kt create mode 100644 plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3Path.kt create mode 100644 plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ReadSeekableByteChannel.kt create mode 100644 plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3WriteSeekableByteChannel.kt delete mode 100644 plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt create mode 100644 plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileSystemTest.kt diff --git a/plugins/s3/build.gradle.kts b/plugins/s3/build.gradle.kts index 2f1f6cc..2d5db64 100644 --- a/plugins/s3/build.gradle.kts +++ b/plugins/s3/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } -project.version = "0.0.1" +project.version = "0.0.2" dependencies { diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileAttributes.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileAttributes.kt new file mode 100644 index 0000000..b70687a --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileAttributes.kt @@ -0,0 +1,52 @@ +package app.termora.plugins.s3 + +import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.FileTime + +data class S3FileAttributes( + private val lastModifiedTime: Long = 0, + private val lastAccessTime: Long = 0, + private val creationTime: Long = 0, + + private val regularFile: Boolean = false, + private val directory: Boolean = false, + private val symbolicLink: Boolean = false, + private val other: Boolean = false, + private val size: Long = 0, +) : BasicFileAttributes { + override fun lastModifiedTime(): FileTime { + return FileTime.fromMillis(lastModifiedTime) + } + + override fun lastAccessTime(): FileTime { + return FileTime.fromMillis(lastAccessTime) + } + + override fun creationTime(): FileTime { + return FileTime.fromMillis(creationTime) + } + + override fun isRegularFile(): Boolean { + return regularFile + } + + override fun isDirectory(): Boolean { + return directory + } + + override fun isSymbolicLink(): Boolean { + return symbolicLink + } + + override fun isOther(): Boolean { + return other + } + + override fun size(): Long { + return size + } + + override fun fileKey(): Any? { + return null + } +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileObject.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileObject.kt deleted file mode 100644 index 88b39e1..0000000 --- a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileObject.kt +++ /dev/null @@ -1,223 +0,0 @@ -package app.termora.plugins.s3 - -import app.termora.DynamicIcon -import app.termora.Icons -import app.termora.vfs2.FileObjectDescriptor -import io.minio.* -import org.apache.commons.io.IOUtils -import org.apache.commons.lang3.StringUtils -import org.apache.commons.vfs2.FileObject -import org.apache.commons.vfs2.FileType -import org.apache.commons.vfs2.provider.AbstractFileName -import org.apache.commons.vfs2.provider.AbstractFileObject -import java.io.InputStream -import java.io.OutputStream -import java.io.PipedInputStream -import java.io.PipedOutputStream - -class S3FileObject( - private val minio: MinioClient, - fileName: AbstractFileName, - fileSystem: S3FileSystem -) : AbstractFileObject(fileName, fileSystem), FileObjectDescriptor { - private var attributes = Attributes() - - init { - attributes = attributes.copy(isRoot = name.path == fileSystem.getDelimiter()) - } - - override fun doGetContentSize(): Long { - return attributes.size - } - - override fun doGetType(): FileType { - return if (attributes.isRoot || attributes.isBucket) FileType.FOLDER - else if (attributes.isDirectory && attributes.isFile) FileType.FILE_OR_FOLDER - else if (attributes.isFile) FileType.FILE - else if (attributes.isDirectory) FileType.FOLDER - else FileType.IMAGINARY - } - - override fun doListChildren(): Array? { - return null - } - - override fun doCreateFolder() { - // Nothing - } - - private fun getBucketName(): String { - if (StringUtils.isNotBlank(attributes.bucket)) { - return attributes.bucket - } - if (parent is S3FileObject) { - return (parent as S3FileObject).getBucketName() - } - throw IllegalArgumentException("Bucket must be a S3 file object") - } - - override fun doListChildrenResolved(): Array? { - if (isFile) return null - - val children = mutableListOf() - - if (attributes.isRoot) { - val buckets = minio.listBuckets() - for (bucket in buckets) { - val file = resolveFile(bucket.name()) - if (file is S3FileObject) { - file.attributes = file.attributes.copy( - isBucket = true, - bucket = bucket.name(), - isDirectory = false, - isFile = false, - lastModified = bucket.creationDate().toInstant().toEpochMilli() - ) - children.add(file) - } - } - } else if (attributes.isBucket || attributes.isDirectory) { - val builder = ListObjectsArgs.builder().bucket(getBucketName()) - .delimiter(fileSystem.getDelimiter()) - var prefix = StringUtils.EMPTY - if (attributes.isDirectory) { - // remove first delimiter - prefix = StringUtils.removeStart(name.path, fileSystem.getDelimiter()) - // remove bucket - prefix = StringUtils.removeStart(prefix, getBucketName()) - // remove first delimiter - prefix = StringUtils.removeStart(prefix, fileSystem.getDelimiter()) - // remove last delimiter - prefix = StringUtils.removeEnd(prefix, fileSystem.getDelimiter()) - prefix = prefix + fileSystem.getDelimiter() - } - builder.prefix(prefix) - - for (e in minio.listObjects(builder.build())) { - val item = e.get() - val objectName = StringUtils.removeStart(item.objectName(), prefix) - val file = resolveFile(objectName) - if (file is S3FileObject) { - val lastModified = if (item.lastModified() != null) item.lastModified() - .toInstant().toEpochMilli() else 0 - val owner = if (item.owner() != null) item.owner().displayName() else StringUtils.EMPTY - file.attributes = file.attributes.copy( - bucket = attributes.bucket, - isDirectory = item.isDir, - isFile = item.isDir.not(), - lastModified = lastModified, - size = if (item.isDir.not()) item.size() else 0, - owner = owner - ) - children.add(file) - } - } - - } - - return children.toTypedArray() - } - - override fun getFileSystem(): S3FileSystem { - return super.getFileSystem() as S3FileSystem - } - - override fun doGetLastModifiedTime(): Long { - return attributes.lastModified - } - - override fun getIcon(width: Int, height: Int): DynamicIcon? { - if (attributes.isBucket) { - return Icons.dbms - } - return super.getIcon(width, height) - } - - override fun getTypeDescription(): String? { - if (attributes.isBucket) { - return "Bucket" - } - return null - } - - override fun getLastModified(): Long? { - return attributes.lastModified - } - - override fun getOwner(): String? { - return attributes.owner - } - - override fun doDelete() { - if (isFile) { - minio.removeObject( - RemoveObjectArgs.builder() - .bucket(getBucketName()).`object`(getObjectName()).build() - ) - } - } - - override fun doGetOutputStream(bAppend: Boolean): OutputStream? { - return createStreamer() - } - - private fun createStreamer(): OutputStream { - val pis = PipedInputStream() - val pos = PipedOutputStream(pis) - - val thread = Thread.ofVirtual().start { - minio.putObject( - PutObjectArgs.builder() - .bucket(getBucketName()) - .stream(pis, -1, 32 * 1024 * 1024) - .`object`(getObjectName()).build() - ) - IOUtils.closeQuietly(pis) - } - - return object : OutputStream() { - override fun write(b: Int) { - pos.write(b) - } - - override fun close() { - pos.close() - thread.join() - } - } - } - - override fun doGetInputStream(bufferSize: Int): InputStream? { - return minio.getObject(GetObjectArgs.builder().bucket(getBucketName()).`object`(getObjectName()).build()) - } - - private fun getObjectName(): String { - var objectName = StringUtils.removeStart(name.path, fileSystem.getDelimiter()) - objectName = StringUtils.removeStart(objectName, getBucketName()) - objectName = StringUtils.removeStart(objectName, fileSystem.getDelimiter()) - return objectName - } - - private data class Attributes( - val isRoot: Boolean = false, - val isBucket: Boolean = false, - val isDirectory: Boolean = false, - val isFile: Boolean = false, - /** - * 只要不是 root 那么一定存在 bucket - */ - val bucket: String = StringUtils.EMPTY, - /** - * 最后修改时间 - */ - val lastModified: Long = 0, - /** - * 文件大小 - */ - val size: Long = 0, - /** - * 所有者 - */ - val owner: String = StringUtils.EMPTY - ) -} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileProvider.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileProvider.kt deleted file mode 100644 index 5620d8b..0000000 --- a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileProvider.kt +++ /dev/null @@ -1,47 +0,0 @@ -package app.termora.plugins.s3 - -import io.minio.MinioClient -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 - -class S3FileProvider private constructor() : AbstractOriginatingFileProvider() { - - companion object { - val instance by lazy { S3FileProvider() } - val capabilities = listOf( - Capability.CREATE, - Capability.DELETE, - Capability.RENAME, - Capability.GET_TYPE, - Capability.LIST_CHILDREN, - Capability.READ_CONTENT, - Capability.WRITE_CONTENT, - Capability.GET_LAST_MODIFIED, - Capability.RANDOM_ACCESS_READ, - ) - } - - override fun getCapabilities(): Collection { - return S3FileProvider.capabilities - } - - override fun doCreateFileSystem( - rootFileName: FileName, - options: FileSystemOptions - ): FileSystem { - val region = S3FileSystemConfigBuilder.instance.getRegion(options) - val endpoint = S3FileSystemConfigBuilder.instance.getEndpoint(options) - val accessKey = S3FileSystemConfigBuilder.instance.getAccessKey(options) - val secretKey = S3FileSystemConfigBuilder.instance.getSecretKey(options) - val builder = MinioClient.builder() - builder.endpoint(endpoint) - builder.credentials(accessKey, secretKey) - if (region.isNotBlank()) builder.region(region) - return S3FileSystem(builder.build(), rootFileName, options) - } - - -} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystem.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystem.kt index 63db266..fdf6117 100644 --- a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystem.kt +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystem.kt @@ -1,32 +1,44 @@ package app.termora.plugins.s3 import io.minio.MinioClient -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.common.file.util.BaseFileSystem +import java.nio.file.Path +import java.nio.file.attribute.UserPrincipalLookupService +import java.util.concurrent.atomic.AtomicBoolean class S3FileSystem( - private val minio: MinioClient, - rootName: FileName, - fileSystemOptions: FileSystemOptions -) : AbstractFileSystem(rootName, null, fileSystemOptions) { + private val minioClient: MinioClient, +) : BaseFileSystem(S3FileSystemProvider(minioClient)) { - override fun addCapabilities(caps: MutableCollection) { - caps.addAll(S3FileProvider.capabilities) - } + private val isOpen = AtomicBoolean(true) - override fun createFile(name: AbstractFileName): FileObject? { - return S3FileObject(minio, name, this) - } - - fun getDelimiter(): String { - return S3FileSystemConfigBuilder.instance.getDelimiter(fileSystemOptions) + override fun create(root: String?, names: List): S3Path { + val path = S3Path(this, root, names) + if (names.isEmpty()) { + path.attributes = path.attributes.copy(directory = true) + } + return path } override fun close() { - minio.close() + if (isOpen.compareAndSet(false, true)) { + minioClient.close() + } } -} \ No newline at end of file + + override fun isOpen(): Boolean { + return isOpen.get() + } + + override fun getRootDirectories(): Iterable { + return mutableSetOf(create(separator)) + } + + override fun supportedFileAttributeViews(): Set { + TODO("Not yet implemented") + } + + override fun getUserPrincipalLookupService(): UserPrincipalLookupService { + throw UnsupportedOperationException() + } +} diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemConfigBuilder.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemConfigBuilder.kt deleted file mode 100644 index e6c0139..0000000 --- a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemConfigBuilder.kt +++ /dev/null @@ -1,14 +0,0 @@ -package app.termora.plugins.s3 - -import app.termora.vfs2.s3.AbstractS3FileSystemConfigBuilder -import org.apache.commons.vfs2.FileSystem - -class S3FileSystemConfigBuilder private constructor() : AbstractS3FileSystemConfigBuilder() { - companion object { - val instance by lazy { S3FileSystemConfigBuilder() } - } - - override fun getConfigClass(): Class { - return S3FileSystem::class.java - } -} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemProvider.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemProvider.kt new file mode 100644 index 0000000..8dc404c --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3FileSystemProvider.kt @@ -0,0 +1,299 @@ +package app.termora.plugins.s3 + +import io.minio.* +import io.minio.errors.ErrorResponseException +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.StringUtils +import java.io.OutputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.net.URI +import java.nio.channels.Channels +import java.nio.channels.SeekableByteChannel +import java.nio.file.* +import java.nio.file.attribute.* +import java.nio.file.spi.FileSystemProvider +import kotlin.io.path.absolutePathString +import kotlin.io.path.name + +class S3FileSystemProvider(private val minioClient: MinioClient) : FileSystemProvider() { + + /** + * 因为 S3 协议不存在文件夹,所以用户新建的文件夹先保存到内存中 + */ + private val directories = mutableMapOf>() + + override fun getScheme(): String? { + return "s3" + } + + override fun newFileSystem( + uri: URI, + env: Map + ): FileSystem { + TODO("Not yet implemented") + } + + override fun getFileSystem(uri: URI): FileSystem { + TODO("Not yet implemented") + } + + override fun getPath(uri: URI): Path { + TODO("Not yet implemented") + } + + override fun newByteChannel( + path: Path, + options: Set, + vararg attrs: FileAttribute<*> + ): SeekableByteChannel { + if (path !is S3Path) throw UnsupportedOperationException("path must be a S3Path") + if (options.contains(StandardOpenOption.WRITE)) { + return S3WriteSeekableByteChannel(Channels.newChannel(createStreamer(path))) + } else { + val response = minioClient.getObject( + GetObjectArgs.builder().bucket(path.bucketName) + .`object`(path.objectName).build() + ) + return S3ReadSeekableByteChannel(Channels.newChannel(response), stat(path)) + } + } + + + private fun createStreamer(path: S3Path): OutputStream { + val pis = PipedInputStream() + val pos = PipedOutputStream(pis) + + val thread = Thread.ofVirtual().start { + minioClient.putObject( + PutObjectArgs.builder() + .bucket(path.bucketName) + .stream(pis, -1, 32 * 1024 * 1024) + .`object`(path.objectName).build() + ) + IOUtils.closeQuietly(pis) + } + + return object : OutputStream() { + override fun write(b: Int) { + pos.write(b) + } + + override fun close() { + pos.close() + thread.join() + } + } + } + + override fun newDirectoryStream( + dir: Path, + filter: DirectoryStream.Filter + ): DirectoryStream { + return object : DirectoryStream { + override fun iterator(): MutableIterator { + return files(dir as S3Path).iterator() + } + + override fun close() { + } + + } + } + + private fun files(path: S3Path): MutableList { + val paths = mutableListOf() + + // root + if (path.isRoot) { + for (bucket in minioClient.listBuckets()) { + val p = path.resolve(bucket.name()) + p.attributes = S3FileAttributes( + directory = true, + lastModifiedTime = bucket.creationDate().toInstant().toEpochMilli() + ) + paths.add(p) + } + return paths + } + + var startAfter = StringUtils.EMPTY + val maxKeys = 100 + + while (true) { + val builder = ListObjectsArgs.builder() + .bucket(path.bucketName) + .maxKeys(maxKeys) + .delimiter(path.fileSystem.separator) + + if (path.objectName.isNotBlank()) builder.prefix(path.objectName + path.fileSystem.separator) + if (startAfter.isNotBlank()) builder.startAfter(startAfter) + + + val subPaths = mutableListOf() + for (e in minioClient.listObjects(builder.build())) { + val item = e.get() + val p = path.bucket.resolve(item.objectName()) + var attributes = p.attributes.copy( + directory = item.isDir, + regularFile = item.isDir.not(), + size = item.size() + ) + if (item.lastModified() != null) { + attributes = attributes.copy(lastModifiedTime = item.lastModified().toInstant().toEpochMilli()) + } + p.attributes = attributes + + // 如果是文件夹,那么就要删除内存中的 + if (attributes.isDirectory) { + delete(p) + } + + subPaths.add(p) + startAfter = item.objectName() + } + + paths.addAll(subPaths) + + if (subPaths.size < maxKeys) + break + + + } + + paths.addAll(directories[path.absolutePathString()] ?: emptyList()) + + return paths + } + + override fun createDirectory(dir: Path, vararg attrs: FileAttribute<*>) { + synchronized(this) { + if (dir !is S3Path) throw UnsupportedOperationException("dir must be a S3Path") + if (dir.isRoot || dir.isBucket) throw UnsupportedOperationException("No operation permission") + val parent = dir.parent ?: throw UnsupportedOperationException("No operation permission") + directories.computeIfAbsent(parent.absolutePathString()) { mutableListOf() } + .add(dir.apply { + attributes = attributes.copy(directory = true, lastModifiedTime = System.currentTimeMillis()) + }) + } + } + + override fun delete(path: Path) { + if (path !is S3Path) throw UnsupportedOperationException("path must be a S3Path") + if (path.attributes.isDirectory) { + val parent = path.parent + if (parent != null) { + synchronized(this) { + directories[parent.absolutePathString()]?.removeIf { it.name == path.name } + } + } + return + } + minioClient.removeObject( + RemoveObjectArgs.builder().bucket(path.bucketName).`object`(path.objectName).build() + ) + } + + override fun copy( + source: Path?, + target: Path?, + vararg options: CopyOption? + ) { + throw UnsupportedOperationException() + } + + override fun move( + source: Path?, + target: Path?, + vararg options: CopyOption? + ) { + throw UnsupportedOperationException() + } + + override fun isSameFile(path: Path?, path2: Path?): Boolean { + throw UnsupportedOperationException() + } + + override fun isHidden(path: Path?): Boolean { + throw UnsupportedOperationException() + } + + override fun getFileStore(path: Path?): FileStore? { + throw UnsupportedOperationException() + } + + override fun checkAccess(path: Path, vararg modes: AccessMode) { + if (path !is S3Path) throw UnsupportedOperationException("path must be a S3Path") + + try { + stat(path) + } catch (e: ErrorResponseException) { + throw NoSuchFileException(e.errorResponse().message()) + } + } + + + private fun stat(path: S3Path): StatObjectResponse { + return minioClient.statObject( + StatObjectArgs.builder() + .`object`(path.objectName) + .bucket(path.bucketName).build() + ) + } + + override fun getFileAttributeView( + path: Path, + type: Class, + vararg options: LinkOption? + ): V { + if (path is S3Path) { + return type.cast(object : BasicFileAttributeView { + override fun name(): String { + return "basic" + } + + override fun readAttributes(): BasicFileAttributes { + return path.attributes + } + + override fun setTimes( + lastModifiedTime: FileTime?, + lastAccessTime: FileTime?, + createTime: FileTime? + ) { + throw UnsupportedOperationException() + } + + }) + } + throw UnsupportedOperationException() + } + + override fun readAttributes( + path: Path, + type: Class, + vararg options: LinkOption + ): A { + if (path is S3Path) { + return type.cast(getFileAttributeView(path, BasicFileAttributeView::class.java).readAttributes()) + } + throw UnsupportedOperationException() + } + + override fun readAttributes( + path: Path?, + attributes: String?, + vararg options: LinkOption? + ): Map? { + throw UnsupportedOperationException() + } + + override fun setAttribute( + path: Path?, + attribute: String?, + value: Any?, + vararg options: LinkOption? + ) { + throw UnsupportedOperationException() + } +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3HostOptionsPane.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3HostOptionsPane.kt index 426fa12..a1c1053 100644 --- a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3HostOptionsPane.kt +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3HostOptionsPane.kt @@ -168,6 +168,7 @@ class S3HostOptionsPane : OptionsPane() { private fun initView() { delimiterTextField.text = "/" + delimiterTextField.isEditable = false add(getCenterComponent(), BorderLayout.CENTER) } diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3Path.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3Path.kt new file mode 100644 index 0000000..8a3dca9 --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3Path.kt @@ -0,0 +1,54 @@ +package app.termora.plugins.s3 + +import org.apache.sshd.common.file.util.BasePath +import java.nio.file.LinkOption +import java.nio.file.Path +import kotlin.io.path.absolutePathString + +class S3Path( + fileSystem: S3FileSystem, + root: String?, + names: List, +) : BasePath(fileSystem, root, names) { + + + private val separator get() = fileSystem.separator + + var attributes = S3FileAttributes() + + /** + * 是否是 Bucket + */ + val isBucket get() = parent != null && parent?.parent == null + + /** + * 是否是根 + */ + val isRoot get() = absolutePathString() == separator + + /** + * Bucket Name + */ + val bucketName: String get() = names.first() + + /** + * 获取 Bucket + */ + val bucket: S3Path get() = fileSystem.getPath(root, bucketName) + + /** + * 获取所在 Bucket 的路径 + */ + val objectName: String get() = names.subList(1, names.size).joinToString(separator) + + override fun toRealPath(vararg options: LinkOption): Path { + return toAbsolutePath() + } + + override fun getParent(): S3Path? { + val path = super.getParent() ?: return null + path.attributes = path.attributes.copy(directory = true) + return path + } + +} \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt index 1edc719..9d4404f 100644 --- a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProvider.kt @@ -7,9 +7,6 @@ import app.termora.protocol.PathHandlerRequest import app.termora.protocol.TransferProtocolProvider import io.minio.MinioClient import org.apache.commons.lang3.StringUtils -import org.apache.commons.vfs2.FileSystemOptions -import org.apache.commons.vfs2.VFS -import org.apache.commons.vfs2.provider.FileProvider class S3ProtocolProvider private constructor() : TransferProtocolProvider { @@ -26,11 +23,7 @@ class S3ProtocolProvider private constructor() : TransferProtocolProvider { return Icons.minio } - override fun getFileProvider(): FileProvider { - return S3FileProvider.instance - } - - override fun getRootFileObject(requester: PathHandlerRequest): PathHandler { + override fun createPathHandler(requester: PathHandlerRequest): PathHandler { val host = requester.host val builder = MinioClient.builder() .endpoint(host.host) @@ -39,21 +32,12 @@ class S3ProtocolProvider private constructor() : TransferProtocolProvider { if (StringUtils.isNotBlank(region)) { builder.region(region) } - val delimiter = host.options.extras["s3.delimiter"] ?: "/" - val options = FileSystemOptions() +// val delimiter = host.options.extras["s3.delimiter"] ?: "/" val defaultPath = host.options.sftpDefaultDirectory - - S3FileSystemConfigBuilder.instance.setRegion(options, StringUtils.defaultString(region)) - S3FileSystemConfigBuilder.instance.setEndpoint(options, host.host) - S3FileSystemConfigBuilder.instance.setAccessKey(options, host.username) - S3FileSystemConfigBuilder.instance.setSecretKey(options, host.authentication.password) - S3FileSystemConfigBuilder.instance.setDelimiter(options, delimiter) - - val file = VFS.getManager().resolveFile( - "s3://${StringUtils.defaultIfBlank(defaultPath, "/")}", - options - ) - return PathHandler(file) + val minioClient = builder.build() + val fs = S3FileSystem(minioClient) + return PathHandler(fs, fs.getPath(defaultPath)) } + } \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProviderExtension.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProviderExtension.kt index f0068a6..9ae980e 100644 --- a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProviderExtension.kt +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ProtocolProviderExtension.kt @@ -9,6 +9,6 @@ class S3ProtocolProviderExtension private constructor() : ProtocolProviderExtens } override fun getProtocolProvider(): ProtocolProvider { - return S3ProtocolProvider.Companion.instance + return S3ProtocolProvider.instance } } \ No newline at end of file diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ReadSeekableByteChannel.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ReadSeekableByteChannel.kt new file mode 100644 index 0000000..fab6e34 --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3ReadSeekableByteChannel.kt @@ -0,0 +1,51 @@ +package app.termora.plugins.s3 + +import io.minio.StatObjectResponse +import org.apache.commons.io.IOUtils +import java.nio.ByteBuffer +import java.nio.channels.ReadableByteChannel +import java.nio.channels.SeekableByteChannel + +class S3ReadSeekableByteChannel( + private val channel: ReadableByteChannel, + private val stat: StatObjectResponse +) : SeekableByteChannel { + + private var position: Long = 0 + + override fun read(dst: ByteBuffer): Int { + val bytesRead = channel.read(dst) + if (bytesRead > 0) { + position += bytesRead + } + return bytesRead + } + + override fun write(src: ByteBuffer): Int { + throw UnsupportedOperationException("Read-only channel") + } + + override fun position(): Long { + return position + } + + override fun position(newPosition: Long): SeekableByteChannel { + throw UnsupportedOperationException("Seek not supported in streaming read") + } + + override fun size(): Long { + return stat.size() + } + + override fun truncate(size: Long): SeekableByteChannel { + throw UnsupportedOperationException("Read-only channel") + } + + override fun isOpen(): Boolean { + return channel.isOpen + } + + override fun close() { + IOUtils.closeQuietly(channel) + } +} diff --git a/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3WriteSeekableByteChannel.kt b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3WriteSeekableByteChannel.kt new file mode 100644 index 0000000..ce1f9c6 --- /dev/null +++ b/plugins/s3/src/main/kotlin/app/termora/plugins/s3/S3WriteSeekableByteChannel.kt @@ -0,0 +1,44 @@ +package app.termora.plugins.s3 + +import org.apache.commons.io.IOUtils +import java.nio.ByteBuffer +import java.nio.channels.SeekableByteChannel +import java.nio.channels.WritableByteChannel + +class S3WriteSeekableByteChannel( + private val channel: WritableByteChannel, +) : SeekableByteChannel { + + + override fun read(dst: ByteBuffer): Int { + throw UnsupportedOperationException("read not supported") + } + + override fun write(src: ByteBuffer): Int { + return channel.write(src) + } + + override fun position(): Long { + throw UnsupportedOperationException("position not supported") + } + + override fun position(newPosition: Long): SeekableByteChannel { + throw UnsupportedOperationException("position not supported") + } + + override fun size(): Long { + throw UnsupportedOperationException("size not supported") + } + + override fun truncate(size: Long): SeekableByteChannel { + throw UnsupportedOperationException("truncate not supported") + } + + override fun isOpen(): Boolean { + return channel.isOpen + } + + override fun close() { + IOUtils.closeQuietly(channel) + } +} diff --git a/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt b/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt deleted file mode 100644 index c885abf..0000000 --- a/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileProviderTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -package app.termora.plugins.s3 - -import app.termora.Authentication -import app.termora.AuthenticationType -import app.termora.Host -import app.termora.protocol.PathHandlerRequest -import app.termora.vfs2.VFSWalker -import io.minio.MakeBucketArgs -import io.minio.MinioClient -import io.minio.PutObjectArgs -import org.apache.commons.vfs2.FileObject -import org.apache.commons.vfs2.VFS -import org.apache.commons.vfs2.cache.WeakRefFilesCache -import org.apache.commons.vfs2.impl.DefaultFileSystemManager -import org.testcontainers.containers.GenericContainer -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers -import java.io.ByteArrayInputStream -import java.io.IOException -import java.nio.file.FileVisitResult -import java.nio.file.FileVisitor -import java.nio.file.attribute.BasicFileAttributes -import java.util.* -import kotlin.test.Test - -@Testcontainers -class S3FileProviderTest { - - private val ak = UUID.randomUUID().toString() - private val sk = UUID.randomUUID().toString() - - @Container - private val monio: GenericContainer<*> = GenericContainer("minio/minio") - .withEnv("MINIO_ACCESS_KEY", ak) - .withEnv("MINIO_SECRET_KEY", sk) - .withExposedPorts(9000, 9090) - .withCommand("server", "/data", "--console-address", ":9090", "-address", ":9000") - - companion object { - - } - - @Test - fun test() { - val endpoint = "http://127.0.0.1:${monio.getMappedPort(9000)}" - val minioClient = MinioClient.builder() - .endpoint(endpoint) - .credentials(ak, sk) - .build() - - val fileSystemManager = DefaultFileSystemManager() - fileSystemManager.addProvider("s3", S3ProtocolProvider.instance.getFileProvider()) - fileSystemManager.filesCache = WeakRefFilesCache() - fileSystemManager.init() - VFS.setManager(fileSystemManager) - - for (i in 0 until 5) { - val bucket = "bucket-$i" - minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()) - - minioClient.putObject( - PutObjectArgs.builder().bucket(bucket) - .`object`("test-1/test-2/test-3/file-$i") - .stream(ByteArrayInputStream("hello".toByteArray()), -1, 5 * 1024 * 1024) - .build() - ) - } - - val requester = PathHandlerRequest( - host = Host( - name = "test", - protocol = S3ProtocolProvider.PROTOCOL, - host = endpoint, - username = ak, - authentication = Authentication.No.copy(type = AuthenticationType.Password, password = sk), - ), - ) - val file = S3ProtocolProvider.instance.getRootFileObject(requester).file - VFSWalker.walk(file, object : FileVisitor { - override fun preVisitDirectory( - dir: FileObject, - attrs: BasicFileAttributes - ): FileVisitResult { - println("preVisitDirectory: ${dir.name}") - return FileVisitResult.CONTINUE - } - - override fun visitFile( - file: FileObject, - attrs: BasicFileAttributes - ): FileVisitResult { - println("visitFile: ${file.name}") - return FileVisitResult.CONTINUE - } - - override fun visitFileFailed( - file: FileObject, - exc: IOException - ): FileVisitResult { - return FileVisitResult.TERMINATE - } - - override fun postVisitDirectory( - dir: FileObject, - exc: IOException? - ): FileVisitResult { - return FileVisitResult.CONTINUE - } - - }) - } -} \ No newline at end of file diff --git a/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileSystemTest.kt b/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileSystemTest.kt new file mode 100644 index 0000000..23f1328 --- /dev/null +++ b/plugins/s3/src/test/kotlin/app/termora/plugins/s3/S3FileSystemTest.kt @@ -0,0 +1,103 @@ +package app.termora.plugins.s3 + +import app.termora.randomUUID +import app.termora.transfer.PathWalker +import io.minio.MakeBucketArgs +import io.minio.MinioClient +import io.minio.PutObjectArgs +import org.apache.commons.io.file.PathVisitor +import org.testcontainers.containers.GenericContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes +import kotlin.io.path.absolutePathString +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.Test + +@Testcontainers +class S3FileSystemTest { + + private val ak = randomUUID() + private val sk = randomUUID() + + @Container + private val monio: GenericContainer<*> = GenericContainer("minio/minio") + .withEnv("MINIO_ACCESS_KEY", ak) + .withEnv("MINIO_SECRET_KEY", sk) + .withExposedPorts(9000, 9090) + .withCommand("server", "/data", "--console-address", ":9090", "-address", ":9000") + + companion object { + + } + + @Test + fun test() { + val endpoint = "http://127.0.0.1:${monio.getMappedPort(9000)}" + val minioClient = MinioClient.builder() + .endpoint(endpoint) + .credentials(ak, sk) + .build() + + for (i in 0 until 1) { + val bucket = "bucket${i}" + minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()) + + for (n in 0 until 1) { + minioClient.putObject( + PutObjectArgs.builder().bucket(bucket) + .`object`("test1/test2/test3/file${n}") + .stream(ByteArrayInputStream("Hello 中国".toByteArray()), -1, 5 * 1024 * 1024) + .build() + ) + } + } + + val fileSystem = S3FileSystem(minioClient) + val path = fileSystem.getPath("/") + PathWalker.walkFileTree(path, object : PathVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + println("preVisitDirectory: ${dir.absolutePathString()}") + return FileVisitResult.CONTINUE + } + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + println(file.readText()) + file.writeText("test") + println(file.readText()) + println("visitFile: ${file.absolutePathString()}") + return FileVisitResult.CONTINUE + + } + + override fun visitFileFailed( + file: Path?, + exc: IOException + ): FileVisitResult { + return FileVisitResult.TERMINATE + + } + + override fun postVisitDirectory( + dir: Path, + exc: IOException? + ): FileVisitResult { + println("postVisitDirectory: ${dir.absolutePathString()}") + return FileVisitResult.CONTINUE + } + + }) + + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 02f4be3..65ea550 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,7 +3,7 @@ plugins { } rootProject.name = "termora" -//include("plugins:s3") +include("plugins:s3") //include("plugins:oss") //include("plugins:cos") //include("plugins:obs") diff --git a/src/main/kotlin/app/termora/NewHostDialogV2.kt b/src/main/kotlin/app/termora/NewHostDialogV2.kt index 8179527..e30a543 100644 --- a/src/main/kotlin/app/termora/NewHostDialogV2.kt +++ b/src/main/kotlin/app/termora/NewHostDialogV2.kt @@ -3,7 +3,7 @@ package app.termora import app.termora.actions.AnAction import app.termora.actions.AnActionEvent import app.termora.protocol.* -import com.formdev.flatlaf.extras.FlatSVGIcon +import app.termora.transfer.ScaleIcon import com.formdev.flatlaf.extras.components.FlatToolBar import com.formdev.flatlaf.ui.FlatButtonBorder import kotlinx.coroutines.Dispatchers @@ -20,10 +20,16 @@ import javax.swing.* class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : DialogWrapper(owner) { + private object Current { + var card: ProtocolHostPanel? = null + var extension: ProtocolHostPanelExtension? = null + } + private val cardLayout = CardLayout() private val cardPanel = JPanel(cardLayout) + private val testConnectionAction = createTestConnectionAction() + private val testConnectionBtn = JButton(testConnectionAction) private val buttonGroup = mutableListOf() - private var currentCard: ProtocolHostPanel? = null var host: Host? = null private set @@ -62,10 +68,7 @@ class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : Dialo .filter { it.canCreateProtocolHostPanel() } for ((index, extension) in extensions.withIndex()) { val protocol = extension.getProtocolProvider().getProtocol() - val icon = FlatSVGIcon( - extension.getProtocolProvider().getIcon().name, - 22, 22, extension.javaClass.classLoader - ) + val icon = ScaleIcon(extension.getProtocolProvider().getIcon(), 22) val hostPanel = extension.createProtocolHostPanel() val button = JToggleButton(protocol, icon).apply { buttonGroup.add(this) } button.setVerticalTextPosition(SwingConstants.BOTTOM) @@ -74,7 +77,7 @@ class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : Dialo FlatButtonBorder(), BorderFactory.createEmptyBorder(0, 4, 0, 4) ) - button.addActionListener { show(protocol, hostPanel, button) } + button.addActionListener { show(protocol, hostPanel, extension, button) } Disposer.register(disposable, hostPanel) @@ -88,22 +91,22 @@ class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : Dialo if (editHost == null) { if (index == 0) { - show(protocol, hostPanel, button) + show(protocol, hostPanel, extension, button) } } else { if (StringUtils.equalsIgnoreCase(editHost.protocol, protocol)) { - show(protocol, hostPanel, button) - currentCard?.setHost(editHost) + show(protocol, hostPanel, extension, button) + Current.card?.setHost(editHost) } } } - if (editHost != null && currentCard == null) { + if (editHost != null && Current.card == null) { SwingUtilities.invokeLater { OptionPane.showMessageDialog( this, - "Protocol ${editHost.protocol} not supported", + I18n.getString("termora.protocol.not-supported", editHost.protocol), messageType = JOptionPane.ERROR_MESSAGE ) doCancelAction() @@ -115,28 +118,45 @@ class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : Dialo return panel } - private fun show(name: String, card: ProtocolHostPanel, button: JToggleButton) { - currentCard?.onBeforeHidden() + private fun show( + name: String, + card: ProtocolHostPanel, + extension: ProtocolHostPanelExtension, + button: JToggleButton + ) { + Current.card?.onBeforeHidden() card.onBeforeShown() cardLayout.show(cardPanel, name) - currentCard?.onHidden() + Current.card?.onHidden() card.onShown() - currentCard = card + Current.card = card + Current.extension = extension buttonGroup.forEach { it.isSelected = false } button.isSelected = true + + val provider = extension.getProtocolProvider() + testConnectionBtn.isVisible = provider is ProtocolTester + } override fun createActions(): List { - return listOf(createOkAction(), createTestConnectionAction(), CancelAction()) + return listOf(createOkAction(), testConnectionAction, CancelAction()) + } + + override fun createJButtonForAction(action: Action): JButton { + if (testConnectionAction == action) { + return testConnectionBtn + } + return super.createJButtonForAction(action) } private fun createTestConnectionAction(): AbstractAction { return object : AnAction(I18n.getString("termora.new-host.test-connection")) { override fun actionPerformed(evt: AnActionEvent) { - val panel = currentCard ?: return + val panel = Current.card ?: return if (panel.validateFields().not()) return val host = panel.getHost() val provider = ProtocolProvider.valueOf(host.protocol) ?: return @@ -181,7 +201,7 @@ class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : Dialo } override fun doOKAction() { - val panel = currentCard ?: return + val panel = Current.card ?: return if (panel.validateFields().not()) return var host = panel.getHost() diff --git a/src/main/kotlin/app/termora/actions/OpenHostAction.kt b/src/main/kotlin/app/termora/actions/OpenHostAction.kt index 7b2803e..6149b98 100644 --- a/src/main/kotlin/app/termora/actions/OpenHostAction.kt +++ b/src/main/kotlin/app/termora/actions/OpenHostAction.kt @@ -28,7 +28,7 @@ class OpenHostAction : AnAction() { if (providers.none { StringUtils.equalsIgnoreCase(it.getProtocol(), host.protocol) }) { OptionPane.showMessageDialog( windowScope.window, - "Protocol ${host.protocol} not supported", + I18n.getString("termora.protocol.not-supported", host.protocol), messageType = JOptionPane.ERROR_MESSAGE, ) return diff --git a/src/main/kotlin/app/termora/transfer/ScaleIcon.kt b/src/main/kotlin/app/termora/transfer/ScaleIcon.kt index d37860f..b8bf15d 100644 --- a/src/main/kotlin/app/termora/transfer/ScaleIcon.kt +++ b/src/main/kotlin/app/termora/transfer/ScaleIcon.kt @@ -13,8 +13,9 @@ class ScaleIcon(private val icon: Icon, private val size: Int) : Icon { g.save() val iconWidth = icon.iconWidth.toDouble() val iconHeight = icon.iconHeight.toDouble() + g.translate(x, y) g.scale(getIconWidth() / iconWidth, getIconHeight() / iconHeight) - icon.paintIcon(c, g, x, y) + icon.paintIcon(c, g, 0, 0) g.restore() } } diff --git a/src/main/kotlin/app/termora/transfer/TransportPanel.kt b/src/main/kotlin/app/termora/transfer/TransportPanel.kt index 0e48cd1..2f1f325 100644 --- a/src/main/kotlin/app/termora/transfer/TransportPanel.kt +++ b/src/main/kotlin/app/termora/transfer/TransportPanel.kt @@ -889,8 +889,8 @@ class TransportPanel( ) { // 只处理最终状态 if (state != TransferTreeTableNode.State.Done && state != TransferTreeTableNode.State.Failed) return - // 删除失败就是不存在 - if (transferIds.remove(transfer.id()).not()) return + // 不存在的任务则不需要监听 + if (transferIds.contains(transfer.id()).not()) return if (state == TransferTreeTableNode.State.Done) { listenFileChanged(transfer.target(), transfer.source()) } diff --git a/src/main/kotlin/app/termora/transfer/TransportSelectionPanel.kt b/src/main/kotlin/app/termora/transfer/TransportSelectionPanel.kt index ed3e3df..639e9a7 100644 --- a/src/main/kotlin/app/termora/transfer/TransportSelectionPanel.kt +++ b/src/main/kotlin/app/termora/transfer/TransportSelectionPanel.kt @@ -101,7 +101,7 @@ class TransportSelectionPanel( val provider = TransferProtocolProvider.valueOf(host.protocol) if (provider == null) { - throw IllegalStateException("Protocol ${host.protocol} not supported") + throw IllegalStateException(I18n.getString("termora.protocol.not-supported", host.protocol)) } val handler = provider.createPathHandler(PathHandlerRequest(host, owner)) diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 23266d3..d2ce43b 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -407,6 +407,9 @@ termora.terminal.copied=Copied termora.terminal.channel-disconnected=Channel has been disconnected.\u0020 termora.terminal.channel-reconnect=Type {0} to reconnect. +# protocol +termora.protocol.not-supported={0} protocol is not supported, you may need to install the plugin + # Visual Window termora.visual-window.system-information=System information termora.visual-window.system-information.mem=Mem diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index dc42d1b..3bc06c8 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -380,6 +380,10 @@ termora.terminal.channel-disconnected=终端断开连接, termora.terminal.channel-reconnect=按 {0} 进行重连。 +# protocol +termora.protocol.not-supported=不支持 {0} 协议,你可能需要安装插件 + + # Actions termora.actions.copy-from-terminal=从终端复制 termora.actions.paste-to-terminal=粘贴到终端 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index f4f9512..dd5d705 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -372,6 +372,9 @@ termora.terminal.copied=已複製 termora.terminal.channel-disconnected=終端機連線中斷, termora.terminal.channel-reconnect=按 {0} 進行重新連線。 +# protocol +termora.protocol.not-supported=不支援 {0} 協議,你可能需要安裝插件 + # Actions termora.actions.copy-from-terminal=從終端複製 termora.actions.paste-to-terminal=貼上到終端