Compare commits

...

46 Commits
1.0.0 ... 1.0.2

Author SHA1 Message Date
hstyi
a71493e52c release: 1.0.2 2025-01-15 13:59:35 +08:00
hstyi
cb327f218c fix: 修复 macOS 没有对二进制依赖进行签名的问题 2025-01-15 13:57:47 +08:00
hstyi
6881b6376f feat: 支持 macOS 签名以及 Windows MSI 安装包 (#71) 2025-01-14 19:03:41 +08:00
hstyi
5027fd9dfb fix: 修复 SFTP 拖拽单个文件上传失败的问题 2025-01-10 18:42:58 +08:00
hstyi
49cef39b8b fix: 修复在极端情况下可能导致部分样式错乱问题 2025-01-10 18:28:38 +08:00
hstyi
5c4acf85e8 chore: unix shell login 2025-01-10 16:36:26 +08:00
hstyi
07bee64b7f chore: 修改 SFTP 图标 2025-01-10 15:08:52 +08:00
hstyi
923afb7e99 feat: 弹窗位置以父窗口为中心 (#55) 2025-01-10 15:08:01 +08:00
hstyi
68df52bfc0 fix: 修复 Windows 切换标签页导致误输入的问题 (#54) 2025-01-10 14:40:26 +08:00
hstyi
c2ee6fc8ac feat: analytics 2025-01-10 13:28:37 +08:00
hstyi
9d4562e7e3 fix: 修复 SFTP 格式化进度可能报错的问题 2025-01-10 12:32:36 +08:00
hstyi
5733b5f485 fix: 修复在同步配置时 “宏” 没有禁用的问题 (#49) 2025-01-10 11:24:32 +08:00
hstyi
9dbdb5fd7a fix: 修复 ESC[?xm 私有模式导致不正常渲染的问题 2025-01-10 10:57:24 +08:00
hstyi
a1d1821553 feat: vim 支持鼠标滚动 2025-01-10 10:57:05 +08:00
hstyi
4a8faea8c5 chore: 在新窗口中打开时标题跟随之前的 2025-01-09 20:53:22 +08:00
hstyi
cfb841db00 feat: support Dracula 2025-01-09 20:53:07 +08:00
hstyi
a87d4ddf82 fix: 修复在 Linux 环境下无法移动窗口的问题 2025-01-09 16:07:39 +08:00
hstyi
6071b251a4 fix: 修复终端日志重复记录的问题 2025-01-09 14:58:04 +08:00
hstyi
950ff517bb fix: 修复自定义工具栏排序无效的问题 2025-01-09 14:45:40 +08:00
hstyi
70008978d8 feat: 在工具栏添加 SFTP 快速打开 2025-01-09 14:15:49 +08:00
hstyi
7c445bdadb feat: 优化自定义工具栏的存储结构 2025-01-09 14:05:53 +08:00
hstyi
f24151f6d8 feat: 支持自定义工具栏 2025-01-09 13:11:46 +08:00
hstyi
7d65a88d63 fix: 修复在开发环境 “设置 - 关于” 页面地址 404 问题 2025-01-08 17:45:46 +08:00
hstyi
ed57c3e5b4 chore: 改进 SFTP 传输进度 2025-01-08 17:43:57 +08:00
hstyi
00f11c9ed5 feat: 添加非 macOS 系统下的 复制/粘贴 快捷键 (#31) 2025-01-08 15:10:02 +08:00
hstyi
5ebea06a95 feat: 当停止记录终端日志的时候立即关闭文件流 2025-01-08 11:46:40 +08:00
hstyi
3e5df2161b feat: support keyboard-interactive 2025-01-07 23:12:39 +08:00
hstyi
ffcb4d028e feat: 支持 OSC 52 指令 2025-01-07 23:12:07 +08:00
hstyi
022ae402cc feat: 支持终端日志记录 (#7) 2025-01-07 17:43:59 +08:00
hstyi
75f8d1de99 release: 1.0.1 2025-01-07 12:27:42 +08:00
hstyi
b9ed8258d1 fix: 修复删除本地文件失败的问题 2025-01-07 12:27:42 +08:00
hstyi
6b6ceb1409 chore: 改进 rm -rf 的文案 2025-01-07 12:27:42 +08:00
hstyi
550ad85415 fix: 修复在 Shift 键不是连续的情况下也会打开 Find Everywhere 的问题 (#18) 2025-01-07 12:27:42 +08:00
hstyi
9d6fd7871b feat: 支持在 Find Everywhere 中快速打开本地终端 2025-01-07 12:27:42 +08:00
Kairlec
7f40a67c28 fix: 修复ssh_config中Host存在其他字符时第一次连接失败 2025-01-07 12:27:42 +08:00
hstyi
89fa153c1e feat: support SFTP
Refs #10
Refs #9
Refs #6
2025-01-07 12:27:42 +08:00
hstyi
46af9a44b2 feat: 支持新增/修改主机时测试连接 (#10) 2025-01-07 12:27:42 +08:00
hstyi
babc440841 chore: 默认情况下忽略大小写 2025-01-07 12:27:42 +08:00
hstyi
72057418aa chore: 环境变量改成下划线 2025-01-07 12:27:42 +08:00
hstyi
568962dc41 feat: 支持 F1-F12 功能键 (#8) 2025-01-07 12:27:42 +08:00
hstyi
d578d1529c fix: 修复使用 HTTP 代理无效的问题 2025-01-07 12:27:42 +08:00
hstyi
fe1106658a feat: 支持导入 ed25519 (#5) 2025-01-07 12:27:42 +08:00
hstyi
401712c5b5 fix: 修复发送鼠标滚动/点击时可能使用错模式的问题 2025-01-07 12:27:42 +08:00
hstyi
3ff6d93279 fix: xterm-256 foreground color 2025-01-07 12:27:42 +08:00
hstyi
38496b9f1b feat: 支持自定义心跳间隔 2025-01-07 12:27:42 +08:00
hstyi
db3e15508c chore: 关于页面跳转到具体的分支/版本 2025-01-07 12:27:42 +08:00
117 changed files with 5765 additions and 254 deletions

View File

@@ -11,6 +11,7 @@
## 功能特性
- 支持 SSH 和本地终端
- 支持 [SFTP](./docs/sftp-zh_CN.png) 文件传输
- 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议
- 支持 SSH 端口转发
@@ -33,7 +34,7 @@
## 开发
建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) 的 JDK 版本,通过 `./gradlew :run`即可运行程序。
建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) 的 JDK 版本,通过 `./gradlew :run` 即可运行程序。
通过 `./gradlew dist` 可以自动构建适用于本机的版本。在 macOS 上是:`dmg`,在 Windows 上是:`zip`,在 Linux 上是:`tar.gz`

View File

@@ -46,6 +46,10 @@ flatlaf 3.5.4
Apache License 2.0
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
flatlaf 3.5.4-no-natives
Apache License 2.0
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
flatlaf-extras 3.5.4
Apache License 2.0
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
@@ -228,4 +232,12 @@ https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
jediterm
Apache License 2.0
https://github.com/JetBrains/jediterm/blob/master/LICENSE-APACHE-2.0.txt
https://github.com/JetBrains/jediterm/blob/master/LICENSE-APACHE-2.0.txt
mixpanel-java 1.5.3
Apache License 2.0
https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
json-20231013
Public Domain.
https://github.com/stleary/JSON-java/blob/master/LICENSE

View File

@@ -1,9 +1,9 @@
import org.gradle.internal.jvm.Jvm
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import org.gradle.nativeplatform.platform.internal.DefaultOperatingSystem
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils
import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
import java.nio.file.Files
plugins {
java
@@ -14,11 +14,20 @@ plugins {
group = "app.termora"
version = "1.0.0"
version = "1.0.2"
val os: DefaultOperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
var arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: Architecture = DefaultNativePlatform.getCurrentArchitecture()
// macOS 签名信息
val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY
val macOSSign = os.isMacOsX && macOSSignUsername.isNotBlank()
&& System.getenv("TERMORA_MAC_SIGN").toBoolean()
// macOS 公证信息
val macOSNotaryKeychainProfile = System.getenv("TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE") ?: StringUtils.EMPTY
val macOSNotary = macOSSign && macOSNotaryKeychainProfile.isNotBlank()
&& System.getenv("TERMORA_MAC_NOTARY").toBoolean()
repositories {
mavenCentral()
@@ -27,6 +36,9 @@ repositories {
}
dependencies {
// 由于签名和公证macOS 不携带 natives
val useNoNativesFlatLaf = os.isMacOsX && macOSNotary && System.getenv("ENABLE_BUILD").toBoolean()
testImplementation(kotlin("test"))
testImplementation(libs.hutool)
testImplementation(libs.sshj)
@@ -35,6 +47,8 @@ dependencies {
testImplementation(libs.jsch)
testImplementation(libs.rhino)
testImplementation(libs.delight.rhino.sandbox)
testImplementation(platform(libs.testcontainers.bom))
testImplementation(libs.testcontainers)
implementation(libs.slf4j.api)
implementation(libs.pty4j)
@@ -48,9 +62,25 @@ dependencies {
implementation(libs.commons.compress)
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.flatlaf)
implementation(libs.flatlaf.extras)
implementation(libs.flatlaf.swingx)
implementation(libs.flatlaf) {
artifact {
if (useNoNativesFlatLaf) {
classifier = "no-natives"
}
}
}
implementation(libs.flatlaf.extras) {
if (useNoNativesFlatLaf) {
exclude(group = "com.formdev", module = "flatlaf")
}
}
implementation(libs.flatlaf.swingx) {
if (useNoNativesFlatLaf) {
exclude(group = "com.formdev", module = "flatlaf")
}
}
implementation(libs.kotlinx.serialization.json)
implementation(libs.swingx)
implementation(libs.jgoodies.forms)
@@ -73,6 +103,7 @@ dependencies {
implementation(libs.xodus.environment)
implementation(libs.bip39)
implementation(libs.colorpicker)
implementation(libs.mixpanel)
}
application {
@@ -101,8 +132,53 @@ tasks.test {
}
tasks.register<Copy>("copy-dependencies") {
from(configurations.runtimeClasspath)
.into("${layout.buildDirectory.get()}/libs")
val dir = layout.buildDirectory.dir("libs")
from(configurations.runtimeClasspath).into(dir)
// 对 JNA 和 PTY4J 的本地库提取
// 提取出来是为了单独签名,不然无法通过公证
if (os.isMacOsX && macOSSign) {
doLast {
val jna = libs.jna.asProvider().get()
val dylib = dir.get().dir("dylib").asFile
val pty4j = libs.pty4j.get()
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
val targetDir = File(dylib, jna.name)
FileUtils.forceMkdir(targetDir)
// @formatter:off
exec { commandLine("unzip","-j","-o", file.absolutePath, "com/sun/jna/darwin-${arch.name}/*", "-d", targetDir.absolutePath) }
// @formatter:on
// 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/darwin-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/sunos-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/openbsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/linux-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/freebsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/dragonflybsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/aix-*") }
} else if ("${pty4j.name}-${pty4j.version}" == file.nameWithoutExtension) {
val targetDir = FileUtils.getFile(dylib, pty4j.name, "darwin")
FileUtils.forceMkdir(targetDir)
// @formatter:off
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "resources/com/pty4j/native/darwin*", "-d", targetDir.absolutePath) }
// @formatter:on
// 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "resources/*") }
}
}
// 对二进制签名
Files.walk(dylib.toPath()).use { paths ->
for (path in paths) {
if (Files.isRegularFile(path)) {
signMacOSLocalFile(path.toFile())
}
}
}
}
}
}
tasks.register<Exec>("jlink") {
@@ -134,6 +210,7 @@ tasks.register<Exec>("jlink") {
}
tasks.register<Exec>("jpackage") {
val buildDir = layout.buildDirectory.get()
val options = mutableListOf(
"--add-exports java.base/sun.nio.ch=ALL-UNNAMED",
@@ -162,6 +239,9 @@ tasks.register<Exec>("jpackage") {
arguments.addAll(listOf("--temp", "$buildDir/jpackage"))
arguments.addAll(listOf("--dest", "$buildDir/distributions"))
arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE)))
arguments.addAll(listOf("--vendor", "TermoraDev"))
arguments.addAll(listOf("--copyright", "TermoraDev"))
arguments.addAll(listOf("--description", "A terminal emulator and SSH client."))
if (os.isMacOsX) {
@@ -191,6 +271,12 @@ tasks.register<Exec>("jpackage") {
throw UnsupportedOperationException()
}
if (os.isMacOsX && macOSSign) {
arguments.add("--mac-sign")
arguments.add("--mac-signing-key-user-name")
arguments.add(macOSSignUsername)
}
commandLine(arguments)
}
@@ -205,12 +291,18 @@ tasks.register("dist") {
val distributionDir = layout.buildDirectory.dir("distributions").get()
val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath
val osName = if (os.isMacOsX) "osx" else if (os.isWindows) "windows" else "linux"
val finalFilenameWithoutExtension = "${project.name}-${project.version}-${osName}-${arch.name}"
val macOSFinalFilePath = distributionDir.file("${finalFilenameWithoutExtension}.dmg").asFile.absolutePath
// 清空目录
exec { commandLine(gradlew, "clean") }
// 打包并复制依赖
exec { commandLine(gradlew, "jar", "copy-dependencies") }
exec {
commandLine(gradlew, "jar", "copy-dependencies")
environment("ENABLE_BUILD" to true)
}
// 检查依赖的开源协议
exec { commandLine(gradlew, "check-license") }
@@ -222,29 +314,64 @@ tasks.register("dist") {
exec { commandLine(gradlew, "jpackage") }
// pack
exec {
if (os.isWindows) { // zip
if (os.isWindows) { // zip and msi
// zip
exec {
commandLine(
"tar", "-vacf",
distributionDir.file("${project.name}-${project.version}-windows-${arch.name}.zip").asFile.absolutePath,
distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile.absolutePath,
project.name.uppercaseFirstChar()
)
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
} else if (os.isLinux) { // tar.gz
}
// msi
exec {
commandLine(
"cmd", "/c", "move",
"${project.name.uppercaseFirstChar()}-${project.version}.msi",
"${finalFilenameWithoutExtension}.msi"
)
workingDir = distributionDir.asFile
}
} else if (os.isLinux) { // tar.gz
exec {
commandLine(
"tar", "-czvf",
distributionDir.file("${project.name}-${project.version}-linux-${arch.name}.tar.gz").asFile.absolutePath,
distributionDir.file("${finalFilenameWithoutExtension}.tar.gz").asFile.absolutePath,
project.name.uppercaseFirstChar()
)
workingDir = distributionDir.asFile
} else if (os.isMacOsX) { // rename
}
} else if (os.isMacOsX) { // rename
exec {
commandLine(
"mv",
distributionDir.file("${project.name.uppercaseFirstChar()}-${project.version}.dmg").asFile.absolutePath,
distributionDir.file("${project.name}-${project.version}-osx-${arch.name}.dmg").asFile.absolutePath,
macOSFinalFilePath,
)
} else {
throw GradleException("${os.name} is not supported")
}
} else {
throw GradleException("${os.name} is not supported")
}
// sign dmg
if (os.isMacOsX && macOSSign) {
// sign
signMacOSLocalFile(File(macOSFinalFilePath))
// notary
if (macOSNotary) {
exec {
commandLine(
"/usr/bin/xcrun", "notarytool",
"submit", macOSFinalFilePath,
"--keychain-profile", macOSNotaryKeychainProfile,
"--wait",
)
}
}
}
}
@@ -269,21 +396,29 @@ tasks.register("check-license") {
thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license
thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first())
}
}
}
for (file in configurations.runtimeClasspath.get()) {
val name = file.nameWithoutExtension
if (!thirdParty.containsKey(name)) {
if (logger.isWarnEnabled) {
logger.warn("$name does not exist in third-party")
}
if (!thirdPartyNames.contains(name)) {
throw GradleException("$name No license found")
}
/**
* macOS 对本地文件进行签名
*/
fun signMacOSLocalFile(file: File) {
if (os.isMacOsX && macOSSign) {
if (file.exists() && file.isFile) {
exec {
commandLine(
"/usr/bin/codesign",
"-s", macOSSignUsername,
"--timestamp", "--force",
"-vvvv", "--options", "runtime",
file.absolutePath,
)
}
}
}
}
kotlin {
jvmToolchain {
languageVersion = JavaLanguageVersion.of(21)

BIN
docs/sftp-zh_CN.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
docs/sftp-zh_TW.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
docs/sftp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -39,6 +39,8 @@ bip39 = "1.0.8"
colorpicker = "2.0.1"
rhino = "1.7.15"
delight-rhino-sandbox = "0.0.17"
testcontainers = "1.20.4"
mixpanel = "1.5.3"
[libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
@@ -57,6 +59,8 @@ flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" }
trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" }
testcontainers = { module = "org.testcontainers:testcontainers" }
koin-core = { module = "io.insert-koin:koin-core" }
swingx = { module = "org.swinglabs.swingx:swingx-all", version.ref = "swingx" }
jgoodies-forms = { module = "com.jgoodies:jgoodies-forms", version.ref = "jgoodies-forms" }
@@ -92,6 +96,7 @@ bip39 = { module = "cash.z.ecc.android:kotlin-bip39-jvm", version.ref = "bip39"
rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View File

@@ -20,7 +20,7 @@ object Actions {
/**
* 关键词高亮
*/
const val KEYWORD_HIGHLIGHT_EVERYWHERE = "KeywordHighlightAction"
const val KEYWORD_HIGHLIGHT = "KeywordHighlightAction"
/**
* Key manager
@@ -47,4 +47,14 @@ object Actions {
* 打开一个主机
*/
const val OPEN_HOST = "OpenHostAction"
/**
* 终端日志记录
*/
const val TERMINAL_LOGGER = "TerminalLogAction"
/**
* 打开 SFTP Tab Action
*/
const val SFTP = "SFTPAction"
}

View File

@@ -17,8 +17,11 @@ import java.io.File
import java.net.URI
import java.time.Duration
import java.util.*
import kotlin.math.ln
import kotlin.math.pow
import kotlin.reflect.KClass
object Application {
private val services = Collections.synchronizedMap(mutableMapOf<KClass<*>, Any>())
private lateinit var baseDataDir: File
@@ -69,7 +72,7 @@ object Application {
var baseDataDir = System.getProperty("${getName()}.base-data-dir".lowercase())
// 取不到从环境取
if (StringUtils.isBlank(baseDataDir)) {
baseDataDir = System.getenv("${getName()}-BASE-DATA-DIR".uppercase())
baseDataDir = System.getenv("${getName()}_BASE_DATA_DIR".uppercase())
}
var dir = File(SystemUtils.getUserHome(), ".${getName()}".lowercase())
@@ -99,6 +102,10 @@ object Application {
return version
}
fun isUnknownVersion(): Boolean {
return getVersion().contains("unknown")
}
fun getAppPath(): String {
return StringUtils.defaultString(System.getProperty("jpackage.app-path"))
}
@@ -144,3 +151,28 @@ object Application {
}
}
}
fun formatBytes(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt()
val value = bytes / 1024.0.pow(exp.toDouble())
return String.format("%.2f %s", value, units[exp])
}
fun formatSeconds(seconds: Long): String {
val days = seconds / 86400
val hours = (seconds % 86400) / 3600
val minutes = (seconds % 3600) / 60
val remainingSeconds = seconds % 60
return when {
days > 0 -> "${days}${hours}小时${minutes}${remainingSeconds}"
hours > 0 -> "${hours}小时${minutes}${remainingSeconds}"
minutes > 0 -> "${minutes}${remainingSeconds}"
else -> "${remainingSeconds}"
}
}

View File

@@ -6,20 +6,26 @@ import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatInspector
import com.formdev.flatlaf.util.SystemInfo
import com.jthemedetecor.OsThemeDetector
import com.mixpanel.mixpanelapi.ClientDelivery
import com.mixpanel.mixpanelapi.MessageBuilder
import com.mixpanel.mixpanelapi.MixpanelAPI
import com.sun.jna.platform.WindowUtils
import com.sun.jna.platform.win32.User32
import com.sun.jna.ptr.IntByReference
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.LocaleUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.math.NumberUtils
import org.json.JSONObject
import org.slf4j.LoggerFactory
import org.tinylog.configuration.Configuration
import java.io.File
import java.io.RandomAccessFile
import java.nio.channels.FileChannel
import java.nio.channels.FileLock
import java.nio.file.StandardOpenOption
import java.util.*
import javax.swing.*
import javax.swing.WindowConstants.DISPOSE_ON_CLOSE
@@ -51,6 +57,9 @@ class ApplicationRunner {
// 加载设置
loadSettings()
// 统计
enableAnalytics()
// 设置 LAF
setupLaf()
@@ -251,4 +260,48 @@ class ApplicationRunner {
}
}
/**
* 统计 https://mixpanel.com
*/
@OptIn(DelicateCoroutinesApi::class)
private fun enableAnalytics() {
if (Application.isUnknownVersion()) {
return
}
GlobalScope.launch(Dispatchers.IO) {
try {
val properties = JSONObject()
properties.put("os", SystemUtils.OS_NAME)
if (SystemInfo.isLinux) {
properties.put("platform", "Linux")
} else if (SystemInfo.isWindows) {
properties.put("platform", "Windows")
} else if (SystemInfo.isMacOS) {
properties.put("platform", "macOS")
}
properties.put("version", Application.getVersion())
properties.put("language", Locale.getDefault().toString())
val message = MessageBuilder("0871335f59ee6d0eb246b008a20f9d1c")
.event(getAnalyticsUserID(), "launch", properties)
val delivery = ClientDelivery()
delivery.addMessage(message)
MixpanelAPI().deliver(delivery, true)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
private fun getAnalyticsUserID(): String {
var id = Database.instance.properties.getString("AnalyticsUserID")
if (id.isNullOrBlank()) {
id = UUID.randomUUID().toSimpleString()
Database.instance.properties.putString("AnalyticsUserID", id)
}
return id
}
}

View File

@@ -0,0 +1,373 @@
package app.termora
import app.termora.Application.ohMyJson
import app.termora.db.Database
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.serialization.encodeToString
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager
import java.awt.Component
import java.awt.Dimension
import java.awt.Window
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.*
import javax.swing.event.ListDataEvent
import javax.swing.event.ListDataListener
import kotlin.math.max
import kotlin.math.min
class CustomizeToolBarDialog(
owner: Window,
private val toolbar: TermoraToolBar
) : DialogWrapper(owner) {
private val moveTopBtn = JButton(Icons.moveUp)
private val moveBottomBtn = JButton(Icons.moveDown)
private val upBtn = JButton(Icons.up)
private val downBtn = JButton(Icons.down)
private val leftBtn = JButton(Icons.left)
private val rightBtn = JButton(Icons.right)
private val resetBtn = JButton(Icons.refresh)
private val allToLeftBtn = JButton(Icons.applyNotConflictsRight)
private val allToRightBtn = JButton(Icons.applyNotConflictsLeft)
private val leftList = ToolBarActionList()
private val rightList = ToolBarActionList()
private val actionManager get() = ActionManager.getInstance()
private var isOk = false
init {
size = Dimension(UIManager.getInt("Dialog.width") - 150, UIManager.getInt("Dialog.height") - 100)
isModal = true
controlsVisible = false
isResizable = false
title = I18n.getString("termora.toolbar.customize-toolbar")
setLocationRelativeTo(null)
moveTopBtn.isEnabled = false
moveBottomBtn.isEnabled = false
downBtn.isEnabled = false
upBtn.isEnabled = false
leftBtn.isEnabled = false
rightBtn.isEnabled = false
initEvents()
init()
}
override fun createCenterPanel(): JComponent {
allToLeftBtn.isEnabled = !rightList.model.isEmpty
allToRightBtn.isEnabled = !leftList.model.isEmpty
val box = JToolBar(JToolBar.VERTICAL)
box.add(Box.createVerticalStrut(leftList.fixedCellHeight))
box.add(rightBtn)
box.add(leftBtn)
box.add(Box.createVerticalGlue())
box.add(resetBtn)
box.add(Box.createVerticalGlue())
box.add(allToRightBtn)
box.add(allToLeftBtn)
box.add(Box.createVerticalStrut(leftList.fixedCellHeight))
val box2 = JToolBar(JToolBar.VERTICAL)
box2.add(Box.createVerticalStrut(leftList.fixedCellHeight))
box2.add(moveTopBtn)
box2.add(upBtn)
box2.add(Box.createVerticalGlue())
box2.add(downBtn)
box2.add(moveBottomBtn)
box2.add(Box.createVerticalStrut(leftList.fixedCellHeight))
return FormBuilder.create().debug(false)
.border(BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor))
.layout(FormLayout("default:grow, pref, default:grow, pref", "fill:p:grow"))
.add(JScrollPane(leftList).apply {
border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
}).xy(1, 1)
.add(box).xy(2, 1)
.add(JScrollPane(rightList).apply {
border = BorderFactory.createMatteBorder(0, 1, 0, 1, DynamicColor.BorderColor)
}).xy(3, 1)
.add(box2).xy(4, 1)
.build()
}
private fun initEvents() {
rightList.addListSelectionListener { resetMoveButtons() }
leftList.addListSelectionListener {
val indices = leftList.selectedIndices
rightBtn.isEnabled = indices.isNotEmpty()
}
leftList.model.addListDataListener(object : ListDataListener {
override fun intervalAdded(e: ListDataEvent) {
contentsChanged(e)
}
override fun intervalRemoved(e: ListDataEvent) {
contentsChanged(e)
}
override fun contentsChanged(e: ListDataEvent) {
allToLeftBtn.isEnabled = !rightList.model.isEmpty
allToRightBtn.isEnabled = !leftList.model.isEmpty
resetMoveButtons()
}
})
rightList.model.addListDataListener(object : ListDataListener {
override fun intervalAdded(e: ListDataEvent) {
contentsChanged(e)
}
override fun intervalRemoved(e: ListDataEvent) {
contentsChanged(e)
}
override fun contentsChanged(e: ListDataEvent) {
allToLeftBtn.isEnabled = !rightList.model.isEmpty
allToRightBtn.isEnabled = !leftList.model.isEmpty
resetMoveButtons()
}
})
resetBtn.addActionListener {
leftList.model.removeAllElements()
rightList.model.removeAllElements()
for (action in toolbar.getAllActions()) {
actionManager.getAction(action.id)?.let {
rightList.model.addElement(ActionHolder(action.id, it))
}
}
}
// move first
moveTopBtn.addActionListener {
val indices = rightList.selectedIndices.sortedDescending()
rightList.clearSelection()
for (index in indices.indices) {
val ele = rightList.model.getElementAt(indices[index])
rightList.model.removeElementAt(indices[index])
rightList.model.add(index, ele)
rightList.selectionModel.addSelectionInterval(index, max(index - 1, 0))
}
}
// move up
upBtn.addActionListener {
val indices = rightList.selectedIndices.sortedDescending()
rightList.clearSelection()
for (index in indices) {
val ele = rightList.model.getElementAt(index)
rightList.model.removeElementAt(index)
rightList.model.add(index - 1, ele)
rightList.selectionModel.addSelectionInterval(max(index - 1, 0), max(index - 1, 0))
}
}
// move down
downBtn.addActionListener {
val indices = rightList.selectedIndices.sortedDescending()
rightList.clearSelection()
for (index in indices) {
val ele = rightList.model.getElementAt(index)
rightList.model.removeElementAt(index)
rightList.model.add(index + 1, ele)
rightList.selectionModel.addSelectionInterval(index + 1, index + 1)
}
}
// move last
moveBottomBtn.addActionListener {
val indices = rightList.selectedIndices.sortedDescending()
val size = rightList.model.size
rightList.clearSelection()
for (index in indices.indices) {
val ele = rightList.model.getElementAt(indices[index])
rightList.model.removeElementAt(indices[index])
rightList.model.add(size - index - 1, ele)
rightList.selectionModel.addSelectionInterval(size - index - 1, size - index - 1)
}
}
allToLeftBtn.addActionListener {
while (!rightList.model.isEmpty) {
val ele = rightList.model.getElementAt(0)
rightList.model.removeElementAt(0)
leftList.model.addElement(ele)
}
}
allToRightBtn.addActionListener {
while (!leftList.model.isEmpty) {
val ele = leftList.model.getElementAt(0)
leftList.model.removeElementAt(0)
rightList.model.addElement(ele)
}
}
leftBtn.addActionListener {
val indices = rightList.selectedIndices.sortedDescending()
for (index in indices) {
val ele = rightList.model.getElementAt(index)
rightList.model.removeElementAt(index)
leftList.model.addElement(ele)
}
rightList.clearSelection()
val index = min(indices.max(), rightList.model.size - 1)
if (!rightList.model.isEmpty) {
rightList.addSelectionInterval(index, index)
}
}
rightBtn.addActionListener {
val indices = leftList.selectedIndices.sortedDescending()
val rightSelectedIndex = if (rightList.selectedIndices.isEmpty()) rightList.model.size else
rightList.selectionModel.maxSelectionIndex + 1
if (indices.isNotEmpty()) {
for (index in indices.indices) {
val ele = leftList.model.getElementAt(indices[index])
leftList.model.removeElementAt(indices[index])
rightList.model.add(rightSelectedIndex + index, ele)
}
leftList.clearSelection()
val index = min(indices.max(), leftList.model.size - 1)
if (!leftList.model.isEmpty) {
leftList.addSelectionInterval(index, index)
}
rightList.clearSelection()
rightList.addSelectionInterval(rightSelectedIndex, rightSelectedIndex)
}
}
addWindowListener(object : WindowAdapter() {
override fun windowOpened(e: WindowEvent) {
removeWindowListener(this)
for (action in toolbar.getActions()) {
if (action.visible) {
actionManager.getAction(action.id)
?.let { rightList.model.addElement(ActionHolder(action.id, it)) }
} else {
actionManager.getAction(action.id)
?.let { leftList.model.addElement(ActionHolder(action.id, it)) }
}
}
}
})
}
private fun resetMoveButtons() {
val indices = rightList.selectedIndices
if (indices.isEmpty()) {
moveTopBtn.isEnabled = false
moveBottomBtn.isEnabled = false
downBtn.isEnabled = false
upBtn.isEnabled = false
} else {
moveTopBtn.isEnabled = !indices.contains(0)
upBtn.isEnabled = moveTopBtn.isEnabled
moveBottomBtn.isEnabled = !indices.contains(rightList.model.size - 1)
downBtn.isEnabled = moveBottomBtn.isEnabled
}
leftBtn.isEnabled = indices.isNotEmpty()
}
private class ToolBarActionList : JList<ActionHolder>() {
private val model = DefaultListModel<ActionHolder>()
init {
initView()
initEvents()
setModel(model)
}
private fun initView() {
border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
background = UIManager.getColor("window")
fixedCellHeight = UIManager.getInt("Tree.rowHeight")
cellRenderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value?.toString() ?: StringUtils.EMPTY
if (value is ActionHolder) {
val action = value.action
text = action.getValue(Action.NAME)?.toString() ?: text
}
val c = super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
if (value is ActionHolder) {
val action = value.action
val icon = action.getValue(Action.SMALL_ICON) as Icon?
if (icon != null) {
this.icon = icon
if (icon is DynamicIcon) {
if (isSelected && cellHasFocus) {
this.icon = icon.dark
}
}
}
}
return c
}
}
}
private fun initEvents() {
}
override fun getModel(): DefaultListModel<ActionHolder> {
return model
}
}
override fun doOKAction() {
isOk = true
val actions = mutableListOf<ToolBarAction>()
for (i in 0 until rightList.model.size()) {
actions.add(ToolBarAction(rightList.model.getElementAt(i).id, true))
}
for (i in 0 until leftList.model.size()) {
actions.add(ToolBarAction(leftList.model.getElementAt(i).id, false))
}
Database.instance.properties.putString("Termora.ToolBar.Actions", ohMyJson.encodeToString(actions))
super.doOKAction()
}
fun open(): Boolean {
isModal = true
isVisible = true
return isOk
}
private class ActionHolder(val id: String, val action: Action)
}

View File

@@ -71,17 +71,23 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(8, 12, 8, 12)
)
val okButton = createJButtonForAction(createOkAction())
box.add(Box.createHorizontalGlue())
box.add(createJButtonForAction(CancelAction()))
box.add(Box.createHorizontalStrut(8))
box.add(okButton)
val actions = createActions()
for (i in actions.size - 1 downTo 0) {
box.add(createJButtonForAction(actions[i]))
if (i != 0) {
box.add(Box.createHorizontalStrut(8))
}
}
return box
}
protected open fun createActions(): List<AbstractAction> {
return listOf(createOkAction(), CancelAction())
}
protected open fun createOkAction(): AbstractAction {
return OkAction()
}

View File

@@ -31,6 +31,7 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
terminalOption.charsetComboBox.selectedItem = host.options.encoding
terminalOption.environmentTextArea.text = host.options.env
terminalOption.startupCommandTextField.text = host.options.startupCommand
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
tunnelingOption.tunnelings.addAll(host.tunnelings)
}

View File

@@ -58,6 +58,10 @@ data class Options(
* 连接成功后立即发送命令
*/
val startupCommand: String = StringUtils.EMPTY,
/**
* SSH 心跳间隔
*/
val heartbeatInterval: Int = 30
) {
companion object {
val Default = Options()

View File

@@ -1,12 +1,13 @@
package app.termora
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.UIManager
import java.awt.event.ActionEvent
import javax.swing.*
class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
private val pane = if (host != null) EditHostOptionsPane(host) else HostOptionsPane()
@@ -33,6 +34,51 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
return panel
}
override fun createActions(): List<AbstractAction> {
return listOf(createOkAction(), createTestConnectionAction(), CancelAction())
}
private fun createTestConnectionAction(): AbstractAction {
return object : AnAction(I18n.getString("termora.new-host.test-connection")) {
override fun actionPerformed(e: ActionEvent) {
if (!pane.validateFields()) {
return
}
putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...")
SwingUtilities.invokeLater {
testConnection(pane.getHost())
putValue(NAME, I18n.getString("termora.new-host.test-connection"))
}
}
}
}
private fun testConnection(host: Host) {
if (host.protocol != Protocol.SSH) {
OptionPane.showMessageDialog(this, I18n.getString("termora.new-host.test-connection-successful"))
return
}
var client: SshClient? = null
var session: ClientSession? = null
try {
client = SshClients.openClient(host)
session = SshClients.openSession(host, client)
OptionPane.showMessageDialog(this, I18n.getString("termora.new-host.test-connection-successful"))
} catch (e: Exception) {
OptionPane.showMessageDialog(
this, ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
} finally {
session?.close()
client?.close()
}
}
override fun doOKAction() {
if (!pane.validateFields()) {

View File

@@ -67,7 +67,8 @@ open class HostOptionsPane : OptionsPane() {
val options = Options.Default.copy(
encoding = terminalOption.charsetComboBox.selectedItem as String,
env = terminalOption.environmentTextArea.text,
startupCommand = terminalOption.startupCommandTextField.text
startupCommand = terminalOption.startupCommandTextField.text,
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
)
return Host(
@@ -508,6 +509,7 @@ open class HostOptionsPane : OptionsPane() {
protected inner class TerminalOption : JPanel(BorderLayout()), Option {
val charsetComboBox = JComboBox<String>()
val startupCommandTextField = OutlineTextField()
val heartbeatIntervalTextField = IntSpinner(30, minimum = 3, maximum = Int.MAX_VALUE)
val environmentTextArea = FixedLengthTextArea(2048)
@@ -563,7 +565,7 @@ open class HostOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin"
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
var rows = 1
@@ -571,6 +573,8 @@ open class HostOptionsPane : OptionsPane() {
val panel = FormBuilder.create().layout(layout)
.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)
.add(heartbeatIntervalTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.startup-commands")}:").xy(1, rows)
.add(startupCommandTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.env")}:").xy(1, rows)

View File

@@ -8,9 +8,15 @@ import kotlinx.coroutines.swing.Swing
import java.beans.PropertyChangeEvent
import javax.swing.Icon
abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
abstract class HostTerminalTab(
val host: Host,
protected val terminal: Terminal = TerminalFactory.instance.createTerminal()
) : PropertyTerminalTab() {
companion object {
val Host = DataKey(app.termora.Host::class)
}
protected val coroutineScope by lazy { CoroutineScope(Dispatchers.Swing) }
protected val terminal = TerminalFactory.instance.createTerminal()
protected val terminalModel get() = terminal.getTerminalModel()
protected var unread = false
set(value) {
@@ -25,6 +31,7 @@ abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
}
init {
terminal.getTerminalModel().setData(Host, host)
terminal.getTerminalModel().addDataListener(object : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
if (key == VisualTerminal.Written) {
@@ -51,6 +58,7 @@ abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
}
override fun dispose() {
terminal.close()
coroutineScope.cancel()
}

View File

@@ -27,6 +27,13 @@ class HostTree : JTree(), Disposable {
private val hostManager get() = HostManager.instance
private val editor = OutlineTextField(64)
var contextmenu = true
/**
* 双击是否打开连接
*/
var doubleClickConnection = true
val model = HostTreeModel()
val searchableModel = SearchableHostTreeModel(model)
@@ -122,7 +129,7 @@ class HostTree : JTree(), Disposable {
}
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val host = lastSelectedPathComponent
if (host is Host && host.protocol != Protocol.Folder) {
ActionManager.getInstance().getAction(Actions.OPEN_HOST)
@@ -296,6 +303,8 @@ class HostTree : JTree(), Disposable {
}
private fun showContextMenu(event: MouseEvent) {
if (!contextmenu) return
val lastHost = lastSelectedPathComponent
if (lastHost !is Host) {
return
@@ -356,7 +365,7 @@ class HostTree : JTree(), Disposable {
remove.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
"删除后无法恢复,你确定要删除吗?",
I18n.getString("termora.keymgr.delete-warning"),
I18n.getString("termora.remove"),
JOptionPane.YES_NO_OPTION,
JOptionPane.QUESTION_MESSAGE
@@ -512,7 +521,7 @@ class HostTree : JTree(), Disposable {
collapsePath(TreePath(model.getPathToRoot(node)))
}
private fun getSelectionNodes(): List<Host> {
fun getSelectionNodes(): List<Host> {
val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent }
.filterIsInstance<Host>()

View File

@@ -0,0 +1,119 @@
package app.termora
import app.termora.db.Database
import java.awt.Dimension
import java.awt.Window
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.*
import javax.swing.tree.TreeSelectionModel
class HostTreeDialog(owner: Window) : DialogWrapper(owner) {
private val tree = HostTree()
val hosts = mutableListOf<Host>()
var allowMulti = true
set(value) {
field = value
if (value) {
tree.selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
} else {
tree.selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION
}
}
init {
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
isModal = true
isResizable = false
controlsVisible = false
title = I18n.getString("termora.transport.sftp.select-host")
tree.setModel(SearchableHostTreeModel(tree.model) { host ->
host.protocol == Protocol.Folder || host.protocol == Protocol.SSH
})
tree.contextmenu = true
tree.doubleClickConnection = false
tree.dragEnabled = false
initEvents()
init()
setLocationRelativeTo(null)
}
private fun initEvents() {
addWindowListener(object : WindowAdapter() {
override fun windowActivated(e: WindowEvent) {
removeWindowListener(this)
val state = Database.instance.properties.getString("HostTreeDialog.HostTreeExpansionState")
if (state != null) {
TreeUtils.loadExpansionState(tree, state)
}
}
})
tree.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val node = tree.lastSelectedPathComponent ?: return
if (node is Host && node.protocol != Protocol.Folder) {
doOKAction()
}
}
}
})
addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
Database.instance.properties.putString(
"HostTreeDialog.HostTreeExpansionState",
TreeUtils.saveExpansionState(tree)
)
}
})
}
override fun createCenterPanel(): JComponent {
val scrollPane = JScrollPane(tree)
scrollPane.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(4, 6, 4, 6)
)
return scrollPane
}
override fun doOKAction() {
if (allowMulti) {
val nodes = tree.getSelectionNodes().filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) {
return
}
hosts.clear()
hosts.addAll(nodes)
} else {
val node = tree.lastSelectedPathComponent ?: return
if (node !is Host || node.protocol != Protocol.SSH) {
return
}
hosts.clear()
hosts.add(node)
}
super.doOKAction()
}
override fun doCancelAction() {
hosts.clear()
super.doCancelAction()
}
}

View File

@@ -1,8 +1,11 @@
package app.termora
object Icons {
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") }
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") }
@@ -13,7 +16,9 @@ object Icons {
val settings by lazy { DynamicIcon("icons/settings.svg", "icons/settings_dark.svg") }
val pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") }
val empty by lazy { DynamicIcon("icons/empty.svg") }
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") }
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") }
@@ -26,6 +31,8 @@ object Icons {
val rec by lazy { DynamicIcon("icons/rec.svg", "icons/rec_dark.svg") }
val stop by lazy { DynamicIcon("icons/stop.svg", "icons/stop_dark.svg") }
val find by lazy { DynamicIcon("icons/find.svg", "icons/find_dark.svg") }
val bookmarks by lazy { DynamicIcon("icons/bookmarks.svg", "icons/bookmarks_dark.svg") }
val bookmarksOff by lazy { DynamicIcon("icons/bookmarksOff.svg", "icons/bookmarksOff_dark.svg") }
val keyboard by lazy { DynamicIcon("icons/keyboard.svg", "icons/keyboard_dark.svg") }
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") }
val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
@@ -43,11 +50,11 @@ object Icons {
val google by lazy { DynamicIcon("icons/google-small.svg") }
val aliyun by lazy { DynamicIcon("icons/aliyun.svg") }
val yandexCloud by lazy { DynamicIcon("icons/yandexCloud.svg") }
val aws by lazy { DynamicIcon("icons/aws.svg","icons/aws_dark.svg") }
val aws by lazy { DynamicIcon("icons/aws.svg", "icons/aws_dark.svg") }
val huawei by lazy { DynamicIcon("icons/huawei.svg") }
val baidu by lazy { DynamicIcon("icons/baiduyun.svg") }
val tianyi by lazy { DynamicIcon("icons/tianyiyun.svg") }
val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg","icons/digitalocean_dark.svg") }
val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg", "icons/digitalocean_dark.svg") }
val terminalUnread by lazy { DynamicIcon("icons/terminalUnread.svg", "icons/terminalUnread_dark.svg") }
val dbPrimitive by lazy { DynamicIcon("icons/dbPrimitive.svg", "icons/dbPrimitive_dark.svg") }
val linux by lazy { DynamicIcon("icons/linux.svg", "icons/linux_dark.svg") }
@@ -64,10 +71,28 @@ object Icons {
val split by lazy { DynamicIcon("icons/split.svg", "icons/split_dark.svg") }
val setKey by lazy { DynamicIcon("icons/setKey.svg", "icons/setKey_dark.svg") }
val greyKey by lazy { DynamicIcon("icons/greyKey.svg", "icons/greyKey_dark.svg") }
val refresh by lazy { DynamicIcon("icons/refresh.svg", "icons/refresh_dark.svg") }
val sortedSet by lazy { DynamicIcon("icons/sortedSet.svg", "icons/sortedSet_dark.svg") }
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") }
val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
val left by lazy { DynamicIcon("icons/left.svg", "icons/left_dark.svg") }
val right by lazy { DynamicIcon("icons/right.svg", "icons/right_dark.svg") }
val dotListFiles by lazy { DynamicIcon("icons/dotListFiles.svg", "icons/dotListFiles_dark.svg") }
val fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") }
val applyNotConflictsLeft by lazy {
DynamicIcon(
"icons/applyNotConflictsLeft.svg",
"icons/applyNotConflictsLeft_dark.svg"
)
}
val applyNotConflictsRight by lazy {
DynamicIcon(
"icons/applyNotConflictsRight.svg",
"icons/applyNotConflictsRight_dark.svg"
)
}
val expand by lazy { DynamicIcon("icons/expand.svg", "icons/expand_dark.svg") }
val collapse by lazy { DynamicIcon("icons/collapse.svg", "icons/collapse_dark.svg") }
val expandAll by lazy { DynamicIcon("icons/expandAll.svg", "icons/expandAll_dark.svg") }

View File

@@ -8,6 +8,51 @@ import com.formdev.flatlaf.FlatPropertiesLaf
import com.formdev.flatlaf.util.SystemInfo
import java.util.*
class DraculaLaf : FlatPropertiesLaf("Dracula", Properties().apply {
putAll(
mapOf(
"@baseTheme" to "dark",
"@background" to "#282935",
"@windowText" to "#eaeaea",
)
)
}), ColorTheme {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Basic.BACKGROUND -> 0x282935
TerminalColor.Basic.FOREGROUND -> 0xeaeaea
TerminalColor.Basic.SELECTION_BACKGROUND -> 0x56596b
TerminalColor.Basic.SELECTION_FOREGROUND -> 0xfeffff
TerminalColor.Basic.HYPERLINK -> 0x255ab4
TerminalColor.Cursor.BACKGROUND -> 0xc7c7c7
TerminalColor.Find.BACKGROUND -> 0xffff00
TerminalColor.Find.FOREGROUND -> 0x282935
TerminalColor.Normal.BLACK -> 0
TerminalColor.Normal.RED -> 0xef766d
TerminalColor.Normal.GREEN -> 0x88f397
TerminalColor.Normal.YELLOW -> 0xf4f8a7
TerminalColor.Normal.BLUE -> 0xc4a9f4
TerminalColor.Normal.MAGENTA -> 0xf297cd
TerminalColor.Normal.CYAN -> 0xaceafb
TerminalColor.Normal.WHITE -> 0xc7c7c7
TerminalColor.Bright.BLACK -> 0x676767
TerminalColor.Bright.RED -> 0xef766d
TerminalColor.Bright.GREEN -> 0x88f397
TerminalColor.Bright.YELLOW -> 0xf4f8a7
TerminalColor.Bright.BLUE -> 0xc4a9f4
TerminalColor.Bright.MAGENTA -> 0xf297cd
TerminalColor.Bright.CYAN -> 0xaceafb
TerminalColor.Bright.WHITE -> 0xfeffff
else -> Int.MAX_VALUE
}
}
}
class LightLaf : FlatLightLaf(), ColorTheme {
override fun getColor(color: TerminalColor): Int {
@@ -163,7 +208,7 @@ class TermiusDarkLaf : FlatPropertiesLaf("Termius Dark", Properties().apply {
TerminalColor.Basic.SELECTION_BACKGROUND,
TerminalColor.Cursor.BACKGROUND -> 0x21b568
TerminalColor.Basic.SELECTION_FOREGROUND ->0
TerminalColor.Basic.SELECTION_FOREGROUND -> 0
TerminalColor.Basic.FOREGROUND -> 0x21b568

View File

@@ -1,6 +1,44 @@
package app.termora
import com.pty4j.util.PtyUtil
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import java.io.File
fun main() {
// 由于 macOS 签名和公证问题,依赖二进制依赖会单独在一个文件夹
if (SystemUtils.IS_OS_MAC_OSX) {
setupNativeLibraries()
}
ApplicationRunner().run()
}
private fun setupNativeLibraries() {
if (!SystemUtils.IS_OS_MAC_OSX) {
return
}
val appPath = Application.getAppPath()
if (StringUtils.isBlank(appPath)) {
return
}
val contents = File(appPath).parentFile?.parentFile ?: return
val dylib = FileUtils.getFile(contents, "app", "dylib")
if (!dylib.exists()) {
return
}
val jna = FileUtils.getFile(dylib, "jna")
if (jna.exists()) {
System.setProperty("jna.boot.library.path", jna.absolutePath)
}
val pty4j = FileUtils.getFile(dylib, "pty4j")
if (pty4j.exists()) {
System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath)
}
}

View File

@@ -6,6 +6,7 @@ import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXLabel
import java.awt.BorderLayout
import java.awt.Component
@@ -122,7 +123,7 @@ object OptionPane {
if (Desktop.isDesktopSupported() && Desktop.getDesktop()
.isSupported(Desktop.Action.BROWSE_FILE_DIR)
) {
if (JOptionPane.YES_OPTION == showConfirmDialog(
if (yMessage.isEmpty() || JOptionPane.YES_OPTION == showConfirmDialog(
parentComponent,
yMessage,
optionType = JOptionPane.YES_NO_OPTION

View File

@@ -30,7 +30,12 @@ class PtyConnectorFactory {
envs.putAll(env)
val command = database.terminal.localShell
val ptyProcess = PtyProcessBuilder(arrayOf(command))
val commands = mutableListOf(command)
if (SystemUtils.IS_OS_UNIX) {
commands.add("-l")
}
val ptyProcess = PtyProcessBuilder(commands.toTypedArray())
.setEnvironment(envs)
.setInitialRows(rows)
.setInitialColumns(cols)

View File

@@ -1,9 +1,6 @@
package app.termora
import app.termora.terminal.ControlCharacters
import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate
import app.termora.terminal.TerminalKeyEvent
import app.termora.terminal.*
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.exception.ExceptionUtils
@@ -12,7 +9,11 @@ import java.awt.event.KeyEvent
import javax.swing.JComponent
import kotlin.time.Duration.Companion.milliseconds
abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
abstract class PtyHostTerminalTab(
host: Host,
terminal: Terminal = TerminalFactory.instance.createTerminal()
) : HostTerminalTab(host, terminal) {
companion object {
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
}
@@ -60,6 +61,10 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
// 失败关闭
stop()
withContext(Dispatchers.Swing) {
terminal.write("\r\n${ControlCharacters.ESC}[31m")
terminal.write(ExceptionUtils.getRootCauseMessage(e))

View File

@@ -0,0 +1,56 @@
package app.termora
import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
class SFTPTerminalTab : Disposable, TerminalTab {
private val transportPanel by lazy {
TransportPanel().apply {
Disposer.register(this@SFTPTerminalTab, this)
}
}
override fun getTitle(): String {
return "SFTP"
}
override fun getIcon(): Icon {
return Icons.folder
}
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
}
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
}
override fun getJComponent(): JComponent {
return transportPanel
}
override fun canClone(): Boolean {
return false
}
override fun canClose(): Boolean {
assertEventDispatchThread()
if (transportPanel.transportManager.getTransports().isEmpty()) {
return true
}
return OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(getJComponent()),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}
}

View File

@@ -1,6 +1,7 @@
package app.termora
import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector
@@ -24,6 +25,7 @@ import org.apache.sshd.common.util.net.SshdSocketAddress
import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets
import javax.swing.JComponent
import javax.swing.SwingUtilities
class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
@@ -76,6 +78,9 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
}
val client = SshClients.openClient(host).also { sshClient = it }
// keyboard interactive
client.userInteraction = TerminalUserInteraction(SwingUtilities.getWindowAncestor(terminalPanel))
val sessionListener = MySessionListener()
val channelListener = MyChannelListener()

View File

@@ -5,7 +5,10 @@ import javax.swing.event.TreeModelListener
import javax.swing.tree.TreeModel
import javax.swing.tree.TreePath
class SearchableHostTreeModel(private val model: HostTreeModel) : TreeModel {
class SearchableHostTreeModel(
private val model: HostTreeModel,
private val filter: (host: Host) -> Boolean = { true }
) : TreeModel {
private var text = String()
override fun getRoot(): Any {
@@ -45,7 +48,8 @@ class SearchableHostTreeModel(private val model: HostTreeModel) : TreeModel {
val children = model.getChildren(parent)
if (children.isEmpty()) return emptyList()
return children.filter { e ->
e.name.contains(text, true) || TreeUtils.children(model, e, true).filterIsInstance<Host>().any {
filter.invoke(e) && e.name.contains(text, true) || TreeUtils.children(model, e, true)
.filterIsInstance<Host>().any {
it.name.contains(text, true)
}
}

View File

@@ -654,6 +654,7 @@ class SettingsOptionsPane : OptionsPane() {
gistTextField.isEnabled = false
tokenTextField.isEnabled = false
keysCheckBox.isEnabled = false
macrosCheckBox.isEnabled = false
keywordHighlightsCheckBox.isEnabled = false
hostsCheckBox.isEnabled = false
domainTextField.isEnabled = false
@@ -685,6 +686,7 @@ class SettingsOptionsPane : OptionsPane() {
keysCheckBox.isEnabled = true
hostsCheckBox.isEnabled = true
typeComboBox.isEnabled = true
macrosCheckBox.isEnabled = true
gistTextField.isEnabled = true
tokenTextField.isEnabled = true
domainTextField.isEnabled = true
@@ -872,6 +874,8 @@ class SettingsOptionsPane : OptionsPane() {
var rows = 1
val step = 2
val branch = if (Application.isUnknownVersion()) "main" else Application.getVersion()
return FormBuilder.create().padding("$formMargin, $formMargin, $formMargin, $formMargin")
.layout(layout).debug(true)
.add(I18n.getString("termora.settings.about.termora", Application.getVersion()))
@@ -879,13 +883,18 @@ class SettingsOptionsPane : OptionsPane() {
.add("${I18n.getString("termora.settings.about.author")}:").xy(1, rows)
.add(createHyperlink("https://github.com/hstyi")).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.about.source")}:").xy(1, rows)
.add(createHyperlink("https://github.com/TermoraDev/termora")).xy(3, rows).apply { rows += step }
.add(
createHyperlink(
"https://github.com/TermoraDev/termora/tree/${branch}",
"https://github.com/TermoraDev/termora",
)
).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.about.issue")}:").xy(1, rows)
.add(createHyperlink("https://github.com/TermoraDev/termora/issues")).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.about.third-party")}:").xy(1, rows)
.add(
createHyperlink(
"https://github.com/TermoraDev/termora/blob/master/THIRDPARTY",
"https://github.com/TermoraDev/termora/blob/${branch}/THIRDPARTY",
"Open-source software"
)
).xy(3, rows).apply { rows += step }

View File

@@ -19,6 +19,8 @@ import org.eclipse.jgit.transport.sshd.ProxyData
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
import kotlin.math.max
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
object SshClients {
private val timeout = Duration.ofSeconds(30)
@@ -62,9 +64,12 @@ object SshClients {
} else if (host.authentication.type == AuthenticationType.PublicKey) {
session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password)
}
if (!session.auth().verify(timeout).await(timeout)) {
val verifyTimeout = Duration.ofSeconds(timeout.seconds * 5)
if (!session.auth().verify(verifyTimeout).await(verifyTimeout)) {
throw SshException("Authentication failed")
}
return session
}
@@ -82,22 +87,25 @@ object SshClients {
builder.forwardingFilter(AcceptAllForwardingFilter.INSTANCE)
}
builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY)
val sshClient = builder.build() as JGitSshClient
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, timeout)
val heartbeatInterval = max(host.options.heartbeatInterval, 3)
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong()))
sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) }
if (host.proxy.type != ProxyType.No) {
sshClient.setProxyDatabase {
if (host.proxy.authenticationType == AuthenticationType.No) ProxyData(
Proxy(
Proxy.Type.SOCKS,
if (host.proxy.type == ProxyType.SOCKS5) Proxy.Type.SOCKS else Proxy.Type.HTTP,
InetSocketAddress(host.proxy.host, host.proxy.port)
)
)
else
ProxyData(
Proxy(
Proxy.Type.SOCKS,
if (host.proxy.type == ProxyType.SOCKS5) Proxy.Type.SOCKS else Proxy.Type.HTTP,
InetSocketAddress(host.proxy.host, host.proxy.port)
),
host.proxy.username,

View File

@@ -3,6 +3,7 @@ package app.termora
import app.termora.db.Database
import app.termora.terminal.*
import app.termora.terminal.panel.TerminalPanel
import app.termora.tlog.TerminalLoggerDataListener
import java.awt.Color
import javax.swing.UIManager
@@ -15,6 +16,10 @@ class TerminalFactory {
fun createTerminal(): Terminal {
val terminal = MyVisualTerminal()
// terminal logger listener
terminal.getTerminalModel().addDataListener(TerminalLoggerDataListener(terminal))
terminals.add(terminal)
return terminal
}
@@ -23,7 +28,7 @@ class TerminalFactory {
return terminals
}
private inner class MyVisualTerminal : VisualTerminal() {
open class MyVisualTerminal : VisualTerminal() {
private val terminalModel by lazy { MyTerminalModel(this) }
override fun getTerminalModel(): TerminalModel {
@@ -31,13 +36,13 @@ class TerminalFactory {
}
}
private inner class MyTerminalModel(terminal: Terminal) : TerminalModelImpl(terminal) {
open class MyTerminalModel(terminal: Terminal) : TerminalModelImpl(terminal) {
private val colorPalette by lazy { MyColorPalette(terminal) }
private val config get() = Database.instance.terminal
init {
setData(DataKey.CursorStyle, config.cursor)
setData(TerminalPanel.Debug, config.debug)
this.setData(DataKey.CursorStyle, config.cursor)
this.setData(TerminalPanel.Debug, config.debug)
}
override fun getColorPalette(): ColorPalette {
@@ -97,7 +102,7 @@ class TerminalFactory {
}
private inner class MyColorPalette(terminal: Terminal) : ColorPaletteImpl(terminal) {
class MyColorPalette(terminal: Terminal) : ColorPaletteImpl(terminal) {
private val colorTheme by lazy { FlatLafColorTheme() }
override fun getTheme(): ColorTheme {
return colorTheme

View File

@@ -37,5 +37,15 @@ interface TerminalTab : Disposable {
fun onLostFocus() {}
fun onGrabFocus() {}
/**
* @return 返回 false 则不可关闭
*/
fun canClose(): Boolean = true
/**
* 是否可以克隆
*/
fun canClone(): Boolean = true
}

View File

@@ -3,9 +3,9 @@ package app.termora
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.JPanel
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.*
class TerminalTabDialog(
owner: Window,
@@ -19,10 +19,20 @@ class TerminalTabDialog(
isAlwaysOnTop = false
iconImages = owner.iconImages
escapeDispose = false
super.setSize(size)
init()
defaultCloseOperation = WindowConstants.DO_NOTHING_ON_CLOSE
addWindowListener(object : WindowAdapter() {
override fun windowClosing(e: WindowEvent) {
if (terminalTab.canClose()) {
SwingUtilities.invokeLater { doCancelAction() }
}
}
})
setLocationRelativeTo(null)
}

View File

@@ -4,30 +4,25 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhere
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.transport.TransportPanel
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionContainerFactory
import org.jdesktop.swingx.action.ActionManager
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.event.ActionEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.beans.PropertyChangeEvent
import java.awt.*
import java.awt.event.*
import java.beans.PropertyChangeListener
import javax.swing.*
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
import kotlin.math.min
class TerminalTabbed(
private val toolbar: JToolBar,
private val termoraToolBar: TermoraToolBar,
private val tabbedPane: FlatTabbedPane,
) : JPanel(BorderLayout()), Disposable, TerminalTabbedManager {
private val tabs = mutableListOf<TerminalTab>()
private val customizeToolBarAWTEventListener = CustomizeToolBarAWTEventListener()
private val toolbar = termoraToolBar.getJToolBar()
private val iconListener = PropertyChangeListener { e ->
val source = e.source
@@ -53,33 +48,6 @@ class TerminalTabbed(
tabbedPane.styleMap = mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground")
)
val actionManager = ActionManager.getInstance()
val actionContainerFactory = ActionContainerFactory(actionManager)
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))
updateBtn.isVisible = updateBtn.isEnabled
updateBtn.addChangeListener { updateBtn.isVisible = updateBtn.isEnabled }
toolbar.add(actionContainerFactory.createButton(object : AnAction(StringUtils.EMPTY, Icons.add) {
override fun actionPerformed(e: ActionEvent?) {
actionManager.getAction(Actions.FIND_EVERYWHERE)?.actionPerformed(e)
}
override fun isEnabled(): Boolean {
return actionManager.getAction(Actions.FIND_EVERYWHERE)?.isEnabled ?: false
}
}))
toolbar.add(Box.createHorizontalStrut(UIManager.getInt("TabbedPane.tabHeight")))
toolbar.add(Box.createHorizontalGlue())
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.MACRO)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEYWORD_HIGHLIGHT_EVERYWHERE)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEY_MANAGER)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.MULTIPLE)))
toolbar.add(updateBtn)
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.FIND_EVERYWHERE)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.SETTING)))
tabbedPane.trailingComponent = toolbar
add(tabbedPane, BorderLayout.CENTER)
@@ -92,18 +60,16 @@ class TerminalTabbed(
tabbedPane.setTabCloseCallback { _, i -> removeTabAt(i, true) }
// 选中变动
tabbedPane.addPropertyChangeListener("selectedIndex", object : PropertyChangeListener {
override fun propertyChange(evt: PropertyChangeEvent) {
val oldIndex = evt.oldValue as Int
val newIndex = evt.newValue as Int
if (oldIndex >= 0 && tabs.size > newIndex) {
tabs[oldIndex].onLostFocus()
}
if (newIndex >= 0 && tabs.size > newIndex) {
tabs[newIndex].onGrabFocus()
}
tabbedPane.addPropertyChangeListener("selectedIndex") { evt ->
val oldIndex = evt.oldValue as Int
val newIndex = evt.newValue as Int
if (oldIndex >= 0 && tabs.size > newIndex) {
tabs[oldIndex].onLostFocus()
}
})
if (newIndex >= 0 && tabs.size > newIndex) {
tabs[newIndex].onGrabFocus()
}
}
// 选择变动
tabbedPane.addChangeListener {
@@ -162,7 +128,7 @@ class TerminalTabbed(
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e)) {
val index = tabbedPane.indexAtLocation(e.x, e.y)
if (index >= 0) {
if (index > 0) {
tabbedPane.getComponentAt(index).requestFocusInWindow()
}
}
@@ -174,7 +140,8 @@ class TerminalTabbed(
override fun find(pattern: String): List<FindEverywhereResult> {
val results = mutableListOf<FindEverywhereResult>()
for (i in 0 until tabbedPane.tabCount) {
if (tabbedPane.getComponentAt(i) is WelcomePanel) {
val c = tabbedPane.getComponentAt(i)
if (c is WelcomePanel || c is TransportPanel) {
continue
}
results.add(
@@ -208,11 +175,21 @@ class TerminalTabbed(
}
})
// 监听全局事件
toolkit.addAWTEventListener(customizeToolBarAWTEventListener, AWTEvent.MOUSE_EVENT_MASK)
}
private fun removeTabAt(index: Int, disposable: Boolean = true) {
if (tabbedPane.isTabClosable(index)) {
val tab = tabs[index]
if (disposable) {
if (!tab.canClose()) {
return
}
}
tab.onLostFocus()
tab.removePropertyChangeListener(iconListener)
@@ -241,9 +218,11 @@ class TerminalTabbed(
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
val c = tabbedPane.getComponentAt(tabIndex) as JComponent
val tab = tabs[tabIndex]
val popupMenu = FlatPopupMenu()
// 修改名称
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
rename.addActionListener {
val index = tabbedPane.selectedIndex
@@ -261,31 +240,29 @@ class TerminalTabbed(
}
// 克隆
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
clone.addActionListener {
val index = tabbedPane.selectedIndex
if (index > 0) {
val tab = tabs[index]
if (tab is HostTerminalTab) {
ActionManager.getInstance()
.getAction(Actions.OPEN_HOST)
.actionPerformed(OpenHostActionEvent(this, tab.host))
}
if (tab is HostTerminalTab) {
ActionManager.getInstance()
.getAction(Actions.OPEN_HOST)
.actionPerformed(OpenHostActionEvent(this, tab.host))
}
}
// 在新窗口中打开
val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window"))
openInNewWindow.addActionListener {
val index = tabbedPane.selectedIndex
if (index > 0) {
val tab = tabs[index]
val title = tabbedPane.getTitleAt(index)
removeTabAt(index, false)
val dialog = TerminalTabDialog(
owner = SwingUtilities.getWindowAncestor(this),
terminalTab = tab,
size = Dimension(min(size.width, 1280), min(size.height, 800))
)
dialog.title = title
Disposer.register(dialog, tab)
Disposer.register(this, dialog)
dialog.isVisible = true
@@ -294,11 +271,13 @@ class TerminalTabbed(
popupMenu.addSeparator()
// 关闭
val close = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close"))
close.addActionListener {
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabIndex)
}
// 关闭其他标签页
popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close-other-tabs")).addActionListener {
for (i in tabbedPane.tabCount - 1 downTo tabIndex + 1) {
tabbedPane.tabCloseCallback?.accept(tabbedPane, i)
@@ -308,6 +287,7 @@ class TerminalTabbed(
}
}
// 关闭所有标签页
popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close-all-tabs")).addActionListener {
for (i in 0 until tabbedPane.tabCount) {
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabbedPane.tabCount - 1)
@@ -320,6 +300,10 @@ class TerminalTabbed(
clone.isEnabled = close.isEnabled
openInNewWindow.isEnabled = close.isEnabled
// 如果不允许克隆
if (clone.isEnabled && !tab.canClone()) {
clone.isEnabled = false
}
if (close.isEnabled) {
popupMenu.addSeparator()
@@ -353,6 +337,64 @@ class TerminalTabbed(
Disposer.register(this, tab)
}
/**
* 对着 ToolBar 右键
*/
private inner class CustomizeToolBarAWTEventListener : AWTEventListener, Disposable {
init {
Disposer.register(this@TerminalTabbed, this)
}
override fun eventDispatched(event: AWTEvent) {
if (event !is MouseEvent || event.id != MouseEvent.MOUSE_CLICKED || !SwingUtilities.isRightMouseButton(event)) return
// 如果 ToolBar 没有显示
if (!toolbar.isShowing) return
// 如果不是作用于在 ToolBar 上面
if (!Rectangle(toolbar.locationOnScreen, toolbar.size).contains(event.locationOnScreen)) return
// 显示右键菜单
showContextMenu(event)
}
private fun showContextMenu(event: MouseEvent) {
val popupMenu = FlatPopupMenu()
popupMenu.add(I18n.getString("termora.toolbar.customize-toolbar")).addActionListener {
val dialog = CustomizeToolBarDialog(
SwingUtilities.getWindowAncestor(this@TerminalTabbed),
termoraToolBar
)
if (dialog.open()) {
termoraToolBar.rebuild()
}
}
popupMenu.show(event.component, event.x, event.y)
}
override fun dispose() {
toolkit.removeAWTEventListener(this)
}
}
/*private inner class CustomizeToolBarDialog(owner: Window) : DialogWrapper(owner) {
init {
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
isModal = true
title = I18n.getString("termora.setting")
setLocationRelativeTo(null)
init()
}
override fun createCenterPanel(): JComponent {
val model = DefaultListModel<String>()
val checkBoxList = CheckBoxList(model)
checkBoxList.fixedCellHeight = UIManager.getInt("Tree.rowHeight")
model.addElement("Test")
return checkBoxList
}
}*/
private inner class SwitchFindEverywhereResult(
private val title: String,
private val icon: Icon?,
@@ -400,5 +442,14 @@ class TerminalTabbed(
return tabs
}
override fun setSelectedTerminalTab(tab: TerminalTab) {
for (index in tabs.indices) {
if (tabs[index] == tab) {
tabbedPane.selectedIndex = index
break
}
}
}
}

View File

@@ -4,4 +4,5 @@ interface TerminalTabbedManager {
fun addTerminalTab(tab: TerminalTab)
fun getSelectedTerminalTab(): TerminalTab?
fun getTerminalTabs(): List<TerminalTab>
fun setSelectedTerminalTab(tab: TerminalTab)
}

View File

@@ -4,6 +4,8 @@ import app.termora.findeverywhere.FindEverywhere
import app.termora.highlight.KeywordHighlightDialog
import app.termora.keymgr.KeyManagerDialog
import app.termora.macro.MacroAction
import app.termora.tlog.TerminalLoggerAction
import app.termora.transport.SFTPAction
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatDesktop
@@ -43,12 +45,12 @@ class TermoraFrame : JFrame() {
private val log = LoggerFactory.getLogger(TermoraFrame::class.java)
}
private val toolbar = JToolBar()
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
private val tabbedPane = MyTabbedPane()
private val toolbar = TermoraToolBar(titleBar, tabbedPane)
private lateinit var terminalTabbed: TerminalTabbed
private val disposable = Disposer.newDisposable()
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
private val updaterManager get() = UpdaterManager.instance
private val preferencesHandler = object : Runnable {
@@ -66,6 +68,7 @@ class TermoraFrame : JFrame() {
FlatDesktop.setPreferencesHandler(that)
}
})
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
}
}
@@ -80,38 +83,6 @@ class TermoraFrame : JFrame() {
private fun initEvents() {
// 监听窗口大小变动,然后修改边距避开控制按钮
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
if (SystemInfo.isMacOS) {
val left = titleBar.leftInset.toInt()
if (tabbedPane.tabAreaInsets.left != left) {
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0)
}
} else if (SystemInfo.isWindows || SystemInfo.isLinux) {
val right = titleBar.rightInset.toInt()
for (i in 0 until toolbar.componentCount) {
val c = toolbar.getComponent(i)
if (c.name == "spacing") {
if (c.width == right) {
return
}
toolbar.remove(i)
break
}
}
if (right > 0) {
val spacing = Box.createHorizontalStrut(right)
spacing.name = "spacing"
toolbar.add(spacing)
}
}
}
})
forceHitTest()
// macos 需要判断是否全部删除
@@ -143,6 +114,8 @@ class TermoraFrame : JFrame() {
.actionPerformed(ActionEvent(rootPane, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))
}
lastTime = now
} else if (e.keyCode != KeyEvent.VK_SHIFT) { // 如果不是 Shift 键,那么就阻断了连续性,重置时间
lastTime = -1
}
return false
}
@@ -207,7 +180,7 @@ class TermoraFrame : JFrame() {
// Keyword Highlight
ActionManager.getInstance().addAction(Actions.KEYWORD_HIGHLIGHT_EVERYWHERE, object : AnAction(
ActionManager.getInstance().addAction(Actions.KEYWORD_HIGHLIGHT, object : AnAction(
I18n.getString("termora.highlight"),
Icons.edit
) {
@@ -231,6 +204,12 @@ class TermoraFrame : JFrame() {
}
})
// 终端日志记录
ActionManager.getInstance().addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
// SFTP
ActionManager.getInstance().addAction(Actions.SFTP, SFTPAction())
// macro
ActionManager.getInstance().addAction(Actions.MACRO, MacroAction())
@@ -244,7 +223,9 @@ class TermoraFrame : JFrame() {
val focusWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
val frame = this@TermoraFrame
if (focusWindow == frame) {
FindEverywhere(frame).isVisible = true
val dialog = FindEverywhere(frame)
dialog.setLocationRelativeTo(frame)
dialog.isVisible = true
}
}
}
@@ -392,9 +373,6 @@ class TermoraFrame : JFrame() {
if (e.source == tabbedPane) {
val index = tabbedPane.indexAtLocation(e.x, e.y)
if (index >= 0) {
if (e.id == MouseEvent.MOUSE_CLICKED) {
tabbedPane.getComponentAt(index)?.requestFocusInWindow()
}
return
}
}
@@ -406,7 +384,7 @@ class TermoraFrame : JFrame() {
}
override fun mousePressed(e: MouseEvent) {
if (e.source == toolbar) {
if (e.source == toolbar.getJToolBar()) {
if (!isWindowDecorationsSupported && SwingUtilities.isLeftMouseButton(e)) {
if (JBR.isWindowMoveSupported()) {
JBR.getWindowMove().startMovingTogetherWithMouse(this@TermoraFrame, e.button)
@@ -441,8 +419,8 @@ class TermoraFrame : JFrame() {
tabbedPane.addMouseListener(mouseAdapter)
tabbedPane.addMouseMotionListener(mouseAdapter)
toolbar.addMouseListener(mouseAdapter)
toolbar.addMouseMotionListener(mouseAdapter)
toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
}
private fun initDesktopHandler() {

View File

@@ -0,0 +1,175 @@
package app.termora
import app.termora.Application.ohMyJson
import app.termora.db.Database
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.WindowDecorations
import kotlinx.serialization.Serializable
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionContainerFactory
import org.jdesktop.swingx.action.ActionManager
import java.awt.Insets
import java.awt.event.ActionEvent
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import javax.swing.Box
import javax.swing.JToolBar
@Serializable
data class ToolBarAction(
val id: String,
val visible: Boolean,
)
class TermoraToolBar(
private val titleBar: WindowDecorations.CustomTitleBar,
private val tabbedPane: FlatTabbedPane
) {
private val properties by lazy { Database.instance.properties }
private val toolbar by lazy { MyToolBar().apply { rebuild(this) } }
fun getJToolBar(): JToolBar {
return toolbar
}
/**
* 获取到所有的 Action
*/
fun getAllActions(): List<ToolBarAction> {
return listOf(
ToolBarAction(Actions.SFTP, true),
ToolBarAction(Actions.TERMINAL_LOGGER, true),
ToolBarAction(Actions.MACRO, true),
ToolBarAction(Actions.KEYWORD_HIGHLIGHT, true),
ToolBarAction(Actions.KEY_MANAGER, true),
ToolBarAction(Actions.MULTIPLE, true),
ToolBarAction(Actions.FIND_EVERYWHERE, true),
ToolBarAction(Actions.SETTING, true),
)
}
/**
* 获取到所有 Action会根据用户个性化排序/显示
*/
fun getActions(): List<ToolBarAction> {
val text = properties.getString(
"Termora.ToolBar.Actions",
StringUtils.EMPTY
)
val actions = getAllActions()
if (text.isBlank()) {
return actions
}
// 存储的 action
val storageActions = (ohMyJson.runCatching {
ohMyJson.decodeFromString<List<ToolBarAction>>(text)
}.getOrNull() ?: return actions).toMutableList()
for (action in actions) {
// 如果存储的 action 不包含这个,那么这个可能是新增的,新增的默认显示出来
if (storageActions.none { it.id == action.id }) {
storageActions.addFirst(ToolBarAction(action.id, true))
}
}
// 如果存储的 Action 在所有 Action 里没有,那么移除
storageActions.removeIf { e -> actions.none { e.id == it.id } }
return storageActions
}
fun rebuild() {
rebuild(this.toolbar)
}
private fun rebuild(toolbar: JToolBar) {
val actionManager = ActionManager.getInstance()
val actionContainerFactory = ActionContainerFactory(actionManager)
toolbar.removeAll()
toolbar.add(actionContainerFactory.createButton(object : AnAction(StringUtils.EMPTY, Icons.add) {
override fun actionPerformed(e: ActionEvent?) {
actionManager.getAction(Actions.FIND_EVERYWHERE)?.actionPerformed(e)
}
override fun isEnabled(): Boolean {
return actionManager.getAction(Actions.FIND_EVERYWHERE)?.isEnabled ?: false
}
}))
toolbar.add(Box.createHorizontalGlue())
// update btn
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))
updateBtn.isVisible = updateBtn.isEnabled
updateBtn.addChangeListener { updateBtn.isVisible = updateBtn.isEnabled }
toolbar.add(updateBtn)
// 获取显示的Action如果不是 false 那么就是显示出来
for (action in getActions()) {
if (action.visible) {
actionManager.getAction(action.id)?.let {
toolbar.add(actionContainerFactory.createButton(it))
}
}
}
if (toolbar is MyToolBar) {
toolbar.adjust()
}
toolbar.revalidate()
toolbar.repaint()
}
private inner class MyToolBar : JToolBar() {
init {
// 监听窗口大小变动,然后修改边距避开控制按钮
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
adjust()
}
})
}
fun adjust() {
if (SystemInfo.isMacOS) {
val left = titleBar.leftInset.toInt()
if (tabbedPane.tabAreaInsets.left != left) {
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0)
}
} else if (SystemInfo.isWindows || SystemInfo.isLinux) {
val right = titleBar.rightInset.toInt()
val toolbar = this@MyToolBar
for (i in 0 until toolbar.componentCount) {
val c = toolbar.getComponent(i)
if (c.name == "spacing") {
if (c.width == right) {
return
}
toolbar.remove(i)
break
}
}
if (right > 0) {
val spacing = Box.createHorizontalStrut(right)
spacing.name = "spacing"
toolbar.add(spacing)
}
}
}
}
}

View File

@@ -30,6 +30,7 @@ class ThemeManager private constructor() {
val themes = mapOf(
"Light" to LightLaf::class.java.name,
"Dark" to DarkLaf::class.java.name,
"Dracula" to DraculaLaf::class.java.name,
"iTerm2 Dark" to iTerm2DarkLaf::class.java.name,
"Termius Dark" to TermiusDarkLaf::class.java.name,
"Termius Light" to TermiusLightLaf::class.java.name,

View File

@@ -5,7 +5,12 @@ import app.termora.I18n
import org.jdesktop.swingx.action.ActionManager
class QuickActionsFindEverywhereProvider : FindEverywhereProvider {
private val actions = listOf(Actions.KEY_MANAGER, Actions.KEYWORD_HIGHLIGHT_EVERYWHERE, Actions.MULTIPLE)
private val actions = listOf(
Actions.KEY_MANAGER,
Actions.KEYWORD_HIGHLIGHT,
Actions.MULTIPLE,
)
override fun find(pattern: String): List<FindEverywhereResult> {
val actionManager = ActionManager.getInstance()
return actions

View File

@@ -1,20 +1,42 @@
package app.termora.findeverywhere
import app.termora.Actions
import app.termora.I18n
import app.termora.Icons
import app.termora.*
import com.formdev.flatlaf.FlatLaf
import org.jdesktop.swingx.action.ActionManager
import java.awt.event.ActionEvent
import javax.swing.Icon
class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
private val actionManager get() = ActionManager.getInstance()
override fun find(pattern: String): List<FindEverywhereResult> {
val list = mutableListOf<FindEverywhereResult>()
ActionManager.getInstance().getAction(Actions.ADD_HOST)?.let {
actionManager?.let {
list.add(CreateHostFindEverywhereResult())
}
// Local terminal
list.add(ActionFindEverywhereResult(object : AnAction(
I18n.getString("termora.find-everywhere.quick-command.local-terminal"),
Icons.terminal
) {
override fun actionPerformed(evt: ActionEvent) {
actionManager.getAction(Actions.OPEN_HOST)?.actionPerformed(
OpenHostActionEvent(
this, Host(
name = name,
protocol = Protocol.Local
)
)
)
}
}))
// SFTP
actionManager.getAction(Actions.SFTP)?.let {
list.add(ActionFindEverywhereResult(it))
}
return list
}

View File

@@ -20,7 +20,7 @@ data class KeywordHighlight(
val description: String = StringUtils.EMPTY,
/**
* [keyword] 是否忽略大小写
* [keyword] 是否大小写匹配,如果为 true 表示不忽略大小写,也就是:'A != a';如果为 false 那么 'A == a'
*/
val matchCase: Boolean = false,
@@ -57,7 +57,7 @@ data class KeywordHighlight(
/**
* 是否启用
*/
val enabled:Boolean = true,
val enabled: Boolean = true,
/**
* 排序

View File

@@ -42,7 +42,7 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
return document.getLine(index++)
}
}, CharArraySubstr(highlight.keyword.toCharArray())).find(highlight.matchCase)
}, CharArraySubstr(highlight.keyword.toCharArray())).find(!highlight.matchCase)
for (kind in kinds) {
terminal.getMarkupModel().addHighlighter(

View File

@@ -0,0 +1,72 @@
package app.termora.keyboardinteractive
import app.termora.DialogWrapper
import app.termora.I18n
import app.termora.OutlinePasswordField
import app.termora.OutlineTextField
import com.formdev.flatlaf.FlatClientProperties
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.lang3.StringUtils
import java.awt.Dimension
import java.awt.Window
import javax.swing.JComponent
import javax.swing.text.JTextComponent
class KeyboardInteractiveDialog(
owner: Window,
private val prompt: String,
echo: Boolean
) : DialogWrapper(owner) {
private val textField = (if (echo) OutlineTextField() else OutlinePasswordField()) as JTextComponent
init {
isModal = true
isResizable = true
controlsVisible = false
title = I18n.getString("termora.new-host.title")
init()
pack()
size = Dimension(300, size.height)
setLocationRelativeTo(null)
}
override fun createCenterPanel(): JComponent {
val formMargin = "4dlu"
val layout = FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin"
)
var rows = 1
val step = 2
return FormBuilder.create().layout(layout).padding("$formMargin, $formMargin, 0, $formMargin")
.add(prompt).xy(1, rows)
.add(textField).xy(3, rows).apply { rows += step }
.build()
}
override fun doCancelAction() {
textField.text = StringUtils.EMPTY
super.doCancelAction()
}
override fun doOKAction() {
if (textField.text.isBlank()) {
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
textField.requestFocusInWindow()
return
}
super.doOKAction()
}
fun getText(): String {
isModal = true
isVisible = true
return textField.text
}
}

View File

@@ -0,0 +1,53 @@
package app.termora.keyboardinteractive
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.auth.keyboard.UserInteraction
import org.apache.sshd.client.session.ClientSession
import java.awt.Window
import javax.swing.SwingUtilities
class TerminalUserInteraction(
private val owner: Window
) : UserInteraction {
override fun interactive(
session: ClientSession?,
name: String?,
instruction: String?,
lang: String?,
prompt: Array<out String>,
echo: BooleanArray
): Array<String> {
val passwords = Array(prompt.size) { StringUtils.EMPTY }
SwingUtilities.invokeAndWait {
for (i in prompt.indices) {
val dialog = KeyboardInteractiveDialog(
owner,
prompt[i],
true
)
dialog.title = instruction ?: name ?: StringUtils.EMPTY
passwords[i] = dialog.getText()
if (passwords[i].isBlank()) {
break
}
}
}
if (passwords.last().isBlank()) {
throw IllegalStateException("User interaction was cancelled.")
}
if (passwords.all { it.isEmpty() }) {
return emptyArray()
}
return passwords
}
override fun getUpdatedPassword(session: ClientSession?, prompt: String?, lang: String?): String {
throw UnsupportedOperationException()
}
}

View File

@@ -0,0 +1,11 @@
package app.termora.keymgr
import org.apache.sshd.common.util.io.resource.AbstractIoResource
import java.io.ByteArrayInputStream
import java.io.InputStream
class ByteArrayIoResource(bytes: ByteArray) : AbstractIoResource<ByteArray>(ByteArray::class.java, bytes) {
override fun openInputStream(): InputStream {
return ByteArrayInputStream(resourceValue)
}
}

View File

@@ -11,6 +11,7 @@ import com.formdev.flatlaf.ui.FlatTextBorder
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import net.i2p.crypto.eddsa.EdDSAPublicKey
import org.apache.commons.codec.binary.Base64
import org.apache.commons.io.IOUtils
import org.apache.commons.io.file.PathUtils
@@ -30,6 +31,7 @@ import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.security.KeyPair
import java.security.spec.X509EncodedKeySpec
import java.util.*
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@@ -187,8 +189,9 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
for (keyPair in keyPairs) {
val pubNameCount = names.getOrPut(keyPair.name + ".pub") { 0 }
val priNameCount = names.getOrPut(keyPair.name) { 0 }
val publicKey = RSA.generatePublic(Base64.decodeBase64(keyPair.publicKey))
val privateKey = RSA.generatePrivate(Base64.decodeBase64(keyPair.privateKey))
val kp = OhKeyPairKeyPairProvider.generateKeyPair(keyPair)
val publicKey = kp.public
val privateKey = kp.private
zos.putNextEntry(ZipEntry("${keyPair.name}${if (pubNameCount > 0) ".${pubNameCount}" else String()}.pub"))
OpenSSHKeyPairResourceWriter.INSTANCE.writePublicKey(publicKey, null, zos)
@@ -236,6 +239,9 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
title = I18n.getString("termora.keymgr.title")
typeComboBox.addItem("RSA")
typeComboBox.addItem("ED25519")
// 默认 RSA
lengthComboBox.addItem(1024)
lengthComboBox.addItem(1024 * 2)
lengthComboBox.addItem(1024 * 3)
@@ -254,16 +260,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
savePublicKeyBtn.isEnabled = false
savePublicKeyBtn.addActionListener {
val fileChooser = FileChooser()
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
fileChooser.win32Filters.add(Pair("All Files", listOf("*")))
fileChooser.showSaveDialog(this, nameTextField.text).thenAccept { file ->
file?.outputStream()?.use {
IOUtils.write(publicKeyTextArea.text, it, StandardCharsets.UTF_8)
}
}
}
initEvents()
if (editable) {
typeComboBox.isEnabled = false
@@ -273,8 +270,15 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
nameTextField.text = ohKeyPair.name
remarkTextField.text = ohKeyPair.remark
val baos = ByteArrayOutputStream()
OpenSSHKeyPairResourceWriter.INSTANCE
.writePublicKey(RSA.generatePublic(ohKeyPair.publicKey.decodeBase64()), null, baos)
if (ohKeyPair.type == "RSA") {
OpenSSHKeyPairResourceWriter.INSTANCE
.writePublicKey(RSA.generatePublic(ohKeyPair.publicKey.decodeBase64()), null, baos)
} else if (ohKeyPair.type == "ED25519") {
OpenSSHKeyPairResourceWriter.INSTANCE.writePublicKey(
EdDSAPublicKey(X509EncodedKeySpec(ohKeyPair.publicKey.decodeBase64())),
null, baos
)
}
publicKeyTextArea.text = baos.toString()
savePublicKeyBtn.isEnabled = true
} else {
@@ -327,6 +331,35 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
.build()
}
private fun initEvents() {
savePublicKeyBtn.addActionListener {
val fileChooser = FileChooser()
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
fileChooser.win32Filters.add(Pair("All Files", listOf("*")))
fileChooser.showSaveDialog(this, "${nameTextField.text}.pub").thenAccept { file ->
file?.outputStream()?.use {
IOUtils.write(publicKeyTextArea.text, it, StandardCharsets.UTF_8)
}
}
}
typeComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
lengthComboBox.removeAllItems()
if (typeComboBox.selectedItem == "ED25519") {
lengthComboBox.addItem(256)
} else if (typeComboBox.selectedItem == "RSA") {
lengthComboBox.addItem(1024)
lengthComboBox.addItem(1024 * 2)
lengthComboBox.addItem(1024 * 3)
lengthComboBox.addItem(1024 * 4)
lengthComboBox.addItem(1024 * 8)
lengthComboBox.selectedItem = 1024 * 2
}
}
}
}
override fun createOkAction(): AbstractAction {
if (!editable) {
@@ -349,7 +382,9 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
return
}
val keyPair = RSA.generateKeyPair(lengthComboBox.selectedItem as Int)
val keyType = if (typeComboBox.selectedItem == "RSA")
KeyPairProvider.SSH_RSA else KeyPairProvider.SSH_ED25519
val keyPair = KeyUtils.generateKeyPair(keyType, lengthComboBox.selectedItem as Int)
ohKeyPair = OhKeyPair(
id = UUID.randomUUID().toSimpleString(),
name = nameTextField.text,
@@ -516,16 +551,20 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
val dialog = InputDialog(owner = this@ImportKeyDialog, title = "Password")
dialog.getText() ?: String()
}
val keyPair =
provider.loadKeys(null).firstOrNull() ?: throw IllegalStateException("Failed to load the key file")
val keyPair = provider.loadKeys(null).firstOrNull()
?: throw IllegalStateException("Failed to load the key file")
val keyType = KeyUtils.getKeyType(keyPair)
if (keyType != KeyPairProvider.SSH_RSA) {
throw UnsupportedOperationException("Key type:${keyType}. Only RSA keys are supported.")
if (keyType != KeyPairProvider.SSH_RSA && keyType != KeyPairProvider.SSH_ED25519) {
throw UnsupportedOperationException("Key type:${keyType}. Only RSA/ED25519 keys are supported.")
}
nameTextField.text = StringUtils.defaultIfBlank(nameTextField.text, file.name)
fileTextField.text = file.absolutePath
typeComboBox.addItem("RSA")
if (keyType == KeyPairProvider.SSH_RSA) {
typeComboBox.addItem("RSA")
} else {
typeComboBox.addItem("ED25519")
}
lengthComboBox.addItem(KeyUtils.getKeySize(keyPair.private))
ohKeyPair = OhKeyPair(
@@ -573,6 +612,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
if (ohKeyPair.remark.isEmpty()) {
ohKeyPair = ohKeyPair.copy(
name = nameTextField.text,
remark = "Import on " + DateFormatUtils.format(Date(), I18n.getString("termora.date-format"))
)
}

View File

@@ -9,7 +9,7 @@ data class OhKeyPair(
val publicKey: String,
// base64
val privateKey: String,
// RSA
// RSA、ED25519
val type: String,
val name: String,
val remark: String,

View File

@@ -2,6 +2,8 @@ package app.termora.keymgr
import app.termora.AES.decodeBase64
import app.termora.RSA
import net.i2p.crypto.eddsa.EdDSAPrivateKey
import net.i2p.crypto.eddsa.EdDSAPublicKey
import org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider
import org.apache.sshd.common.session.SessionContext
import org.slf4j.LoggerFactory
@@ -9,6 +11,8 @@ import java.security.Key
import java.security.KeyPair
import java.security.PrivateKey
import java.security.PublicKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.*
import java.util.concurrent.ConcurrentHashMap
@@ -16,6 +20,27 @@ class OhKeyPairKeyPairProvider(private val id: String) : AbstractResourceKeyPair
companion object {
private val log = LoggerFactory.getLogger(OhKeyPairKeyPairProvider::class.java)
private val cache = ConcurrentHashMap<String, Key>()
fun generateKeyPair(ohKeyPair: OhKeyPair): KeyPair {
val publicKey = cache.getOrPut(ohKeyPair.publicKey) {
when (ohKeyPair.type) {
"RSA" -> RSA.generatePublic(ohKeyPair.publicKey.decodeBase64())
"ED25519" -> EdDSAPublicKey(X509EncodedKeySpec(ohKeyPair.publicKey.decodeBase64()))
else -> throw UnsupportedOperationException("${ohKeyPair.type} is not supported")
}
} as PublicKey
val privateKey = cache.getOrPut(ohKeyPair.privateKey) {
when (ohKeyPair.type) {
"RSA" -> RSA.generatePrivate(ohKeyPair.privateKey.decodeBase64())
"ED25519" -> EdDSAPrivateKey(PKCS8EncodedKeySpec(ohKeyPair.privateKey.decodeBase64()))
else -> throw UnsupportedOperationException("${ohKeyPair.type} is not supported")
}
} as PrivateKey
return KeyPair(publicKey, privateKey)
}
}
@@ -31,13 +56,7 @@ class OhKeyPairKeyPairProvider(private val id: String) : AbstractResourceKeyPair
return object : Iterable<KeyPair> {
override fun iterator(): Iterator<KeyPair> {
val result = kotlin.runCatching {
val publicKey = cache.getOrPut(ohKeyPair.publicKey)
{ RSA.generatePublic(ohKeyPair.publicKey.decodeBase64()) } as PublicKey
val privateKey = cache.getOrPut(ohKeyPair.privateKey)
{ RSA.generatePrivate(ohKeyPair.privateKey.decodeBase64()) } as PrivateKey
return@runCatching KeyPair(publicKey, privateKey)
}
val result = kotlin.runCatching { generateKeyPair(ohKeyPair) }
if (result.isSuccess) {
return listOf(result.getOrThrow()).iterator()
} else if (log.isErrorEnabled) {

View File

@@ -3,8 +3,8 @@ package app.termora.native
import app.termora.native.osx.DispatchNative
import com.formdev.flatlaf.util.SystemInfo
import de.jangassen.jfa.foundation.Foundation
import de.jangassen.jfa.foundation.Foundation.NSArray
import jnafilechooser.api.JnaFileChooser
import org.apache.commons.lang3.StringUtils
import java.awt.Window
import java.io.File
import java.util.concurrent.CompletableFuture
@@ -17,6 +17,12 @@ class FileChooser {
var allowsOtherFileTypes = true
var canCreateDirectories = true
var win32Filters = mutableListOf<Pair<String, List<String>>>()
var osxAllowedFileTypes = emptyList<String>()
/**
* 默认的打开目录
*/
var defaultDirectory = StringUtils.EMPTY
fun showOpenDialog(owner: Window? = null): CompletableFuture<List<File>> {
val future = CompletableFuture<List<File>>()
@@ -26,6 +32,17 @@ class FileChooser {
val fileChooser = JnaFileChooser()
fileChooser.isMultiSelectionEnabled = allowsMultiSelection
fileChooser.setTitle(title)
if (defaultDirectory.isNotBlank()) {
fileChooser.setCurrentDirectory(defaultDirectory)
}
if (win32Filters.isNotEmpty()) {
for ((name, filters) in win32Filters) {
fileChooser.addFilter(name, *filters.toTypedArray())
}
}
if (fileChooser.showOpenDialog(owner)) {
future.complete(fileChooser.selectedFiles.toList())
} else {
@@ -91,6 +108,27 @@ class FileChooser {
// 是否允许多选
Foundation.invoke(openPanelInstance, "setAllowsMultipleSelection:", allowsMultiSelection)
// 限制文件类型
if (osxAllowedFileTypes.isNotEmpty()) {
Foundation.invoke(
openPanelInstance,
"setAllowedFileTypes:",
Foundation.fillArray(osxAllowedFileTypes.toTypedArray())
)
}
if (defaultDirectory.isNotBlank()) {
Foundation.invoke(
openPanelInstance,
"setDirectoryURL:",
Foundation.invoke(
"NSURL",
"fileURLWithPath:",
Foundation.nsString(defaultDirectory)
)
)
}
// 标题
if (title.isNotBlank()) {
Foundation.invoke(openPanelInstance, "setTitle:", Foundation.nsString(title))
@@ -103,7 +141,7 @@ class FileChooser {
}
val files = mutableListOf<File>()
val urls = NSArray(Foundation.invoke(openPanelInstance, "URLs"))
val urls = Foundation.NSArray(Foundation.invoke(openPanelInstance, "URLs"))
for (i in 0 until urls.count()) {
val url = Foundation.invoke(urls.at(i), "path")
if (url != null) {

View File

@@ -769,6 +769,11 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
args.append("0")
} else if (args.startsWithMoreMark()) {
return
} else if (args.startsWithQuestionMark()) {
if (log.isWarnEnabled) {
log.warn("ignore SGR: {}", args)
}
return
}
val iterator = args.controlSequences().iterator()
@@ -831,9 +836,7 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
37 -> foreground = mode - 30 + 1
// xterm-256 foreground color
38
-> {
38 -> {
if (iterator.hasNext()) {
when (val code = iterator.next()) {
// rgb
@@ -841,7 +844,7 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
val r = iterator.next()
val g = iterator.next()
val b = iterator.next()
background = 65536 * r + 256 * g + b
foreground = 65536 * r + 256 * g + b
}
// index color

View File

@@ -1,5 +1,7 @@
package app.termora.terminal
import java.awt.event.KeyEvent
@Suppress("MemberVisibilityCanBePrivate")
open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataListener {
@@ -44,6 +46,20 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
// Delete
putCode(TerminalKeyEvent(keyCode = 0x7F), encode = "${ControlCharacters.ESC}[3~")
// Function Keys
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F1), encode = "${ControlCharacters.ESC}OP")
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F2), encode = "${ControlCharacters.ESC}OQ")
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F3), encode = "${ControlCharacters.ESC}OR")
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F4), encode = "${ControlCharacters.ESC}OS")
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F5), encode = "${ControlCharacters.ESC}[15~");
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F6), encode = "${ControlCharacters.ESC}[17~");
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F7), encode = "${ControlCharacters.ESC}[18~");
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F8), encode = "${ControlCharacters.ESC}[19~");
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F9), encode = "${ControlCharacters.ESC}[20~");
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F10), encode = "${ControlCharacters.ESC}[21~");
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F11), encode = "${ControlCharacters.ESC}[23~");
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F12), encode = "${ControlCharacters.ESC}[24~");
terminal.getTerminalModel().addDataListener(object : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
this@KeyEncoderImpl.onChanged(key, data)

View File

@@ -1,6 +1,9 @@
package app.termora.terminal
import org.apache.commons.codec.binary.Base64
import org.slf4j.LoggerFactory
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader) :
AbstractProcessor(terminal, reader) {
@@ -95,6 +98,25 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
replyColor(mode, terminalColor)
}
// Ps = 5 2 ⇒ Manipulate Selection Data. These controls may be disabled using the allowWindowOps resource. The parameter Pt is parsed as
52 -> {
val pair = suffix.split(";", limit = 2).let {
Pair(it.first(), it.last())
}
// base64
if (pair.first == "c") {
val text = String(Base64.decodeBase64(pair.second))
Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(text), null)
if (log.isDebugEnabled) {
log.debug("Copy {} to clipboard", text)
}
} else if (log.isWarnEnabled) {
log.warn("Manipulate Selection Data. Unknown: {}", pair)
}
}
else -> {
if (log.isWarnEnabled) {
log.warn("Unknown OSC: $prefix")

View File

@@ -86,7 +86,11 @@ class TerminalLine {
if (chars.size() > i) {
if (chars.get(i).isNull) {
chars.set(i, Char.Space)
styles.set(i, TextStyle.Default)
// 如果等于默认,那么替换成当前的样式
// 如果不是默认,那么不需要替换样式
if (styles.getTextStyle(i) == TextStyle.Default) {
styles.set(i, buffer.style)
}
}
} else {
break

View File

@@ -7,12 +7,11 @@ import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
class TerminalCopyAction(private val terminalPanel: TerminalPanel) : TerminalAction(
KeyStroke.getKeyStroke(KeyEvent.VK_C, terminalPanel.toolkit.menuShortcutKeyMaskEx)
) {
class TerminalCopyAction(private val terminalPanel: TerminalPanel) : TerminalPredicateAction {
companion object {
private val log = LoggerFactory.getLogger(TerminalCopyAction::class.java)
}
@@ -36,10 +35,16 @@ class TerminalCopyAction(private val terminalPanel: TerminalPanel) : TerminalAct
}
override fun test(keyStroke: KeyStroke, e: KeyEvent): Boolean {
if (!SystemInfo.isMacOS) {
return false
if (SystemInfo.isMacOS) {
return KeyStroke.getKeyStroke(KeyEvent.VK_C, terminalPanel.toolkit.menuShortcutKeyMaskEx) == keyStroke
}
return super.test(keyStroke, e)
// Ctrl + Insert
val keyStroke1 = KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, InputEvent.CTRL_DOWN_MASK)
// Ctrl + Shift + C
val keyStroke2 = KeyStroke.getKeyStroke(KeyEvent.VK_C, InputEvent.CTRL_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK)
return keyStroke == keyStroke1 || keyStroke == keyStroke2
}
private class EmptyTransferable : Transferable {

View File

@@ -298,7 +298,7 @@ class TerminalDisplay(
g.drawLine(xOffset, ly, xOffset + charWidth, ly)
}
// 删除线
// 双下划线
if (textStyle.doublyUnderline) {
if (textStyle.underline) {
g.drawLine(xOffset, i * lineHeight - 3, xOffset + charWidth, i * lineHeight - 3)

View File

@@ -2,6 +2,8 @@ package app.termora.terminal.panel
import app.termora.terminal.PtyConnector
import app.termora.terminal.Terminal
import com.formdev.flatlaf.util.SystemInfo
import java.awt.event.InputEvent
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
@@ -45,6 +47,11 @@ class TerminalPanelKeyAdapter(
ptyConnector.write(encode)
}
// https://github.com/TermoraDev/termora/issues/52
if (SystemInfo.isWindows && e.keyCode == KeyEvent.VK_TAB && isCtrlPressedOnly(e)) {
return
}
if (Character.isISOControl(e.keyChar)) {
terminal.getSelectionModel().clearSelection()
// 如果不为空表示已经发送过了,所以这里为空的时候再发送
@@ -55,4 +62,12 @@ class TerminalPanelKeyAdapter(
}
}
private fun isCtrlPressedOnly(e: KeyEvent): Boolean {
val modifiersEx = e.modifiersEx
return (modifiersEx and InputEvent.ALT_DOWN_MASK) == 0
&& (modifiersEx and InputEvent.ALT_GRAPH_DOWN_MASK) == 0
&& (modifiersEx and InputEvent.CTRL_DOWN_MASK) != 0
&& (modifiersEx and InputEvent.SHIFT_DOWN_MASK) == 0
}
}

View File

@@ -2,6 +2,7 @@ package app.termora.terminal.panel
import app.termora.terminal.*
import org.slf4j.LoggerFactory
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseWheelEvent
@@ -64,14 +65,14 @@ class TerminalPanelMouseTrackingAdapter(
}
override fun mouseWheelMoved(e: MouseWheelEvent) {
if (shouldSendMouseData) {
if (this.shouldSendMouseData || terminalModel.isAlternateScreenBuffer()) {
val unitsToScroll = e.unitsToScroll
val encode = terminal.getKeyEncoder()
.encode(TerminalKeyEvent(if (e.wheelRotation < 0) KeyEvent.VK_UP else KeyEvent.VK_DOWN))
if (encode.isBlank()) return
for (i in 0 until abs(unitsToScroll)) {
val sb = StringBuilder()
sb.append(ControlCharacters.ESC)
sb.append('O')
sb.append(if (e.wheelRotation < 0) 'A' else 'B')
ptyConnector.write(sb.toString())
ptyConnector.write(encode)
}
}
}
@@ -119,9 +120,6 @@ class TerminalPanelMouseTrackingAdapter(
sb.append(ControlCharacters.ESC).append("[M")
.append((32 + cb).toChar()).append((32 + x).toChar())
.append((32 + y).toChar())
} else if (isUrxvtMouseMode) {
sb.append(ControlCharacters.ESC).append("[")
.append(32 + cb).append(x).append(y).append('M')
} else if (isSGRMouseMode) {
// for SGR 1006 style, internal use only
// 128 - mouse button is released
@@ -136,6 +134,9 @@ class TerminalPanelMouseTrackingAdapter(
.append(cb).append(';').append(x).append(';')
.append(y).append('M')
}
} else if (isUrxvtMouseMode) {
sb.append(ControlCharacters.ESC).append("[")
.append(32 + cb).append(x).append(y).append('M')
} else {
charset = Charsets.ISO_8859_1
sb.append(ControlCharacters.ESC).append("[M")

View File

@@ -3,12 +3,11 @@ package app.termora.terminal.panel
import com.formdev.flatlaf.util.SystemInfo
import org.slf4j.LoggerFactory
import java.awt.datatransfer.DataFlavor
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
class TerminalPasteAction(private val terminalPanel: TerminalPanel) : TerminalAction(
KeyStroke.getKeyStroke(KeyEvent.VK_V, terminalPanel.toolkit.menuShortcutKeyMaskEx)
) {
class TerminalPasteAction(private val terminalPanel: TerminalPanel) : TerminalPredicateAction {
companion object {
private val log = LoggerFactory.getLogger(TerminalPasteAction::class.java)
}
@@ -28,10 +27,16 @@ class TerminalPasteAction(private val terminalPanel: TerminalPanel) : TerminalAc
}
override fun test(keyStroke: KeyStroke, e: KeyEvent): Boolean {
if (!SystemInfo.isMacOS) {
return false
if (SystemInfo.isMacOS) {
return KeyStroke.getKeyStroke(KeyEvent.VK_V, terminalPanel.toolkit.menuShortcutKeyMaskEx) == keyStroke
}
return super.test(keyStroke, e)
// Shift + Insert
val keyStroke1 = KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, InputEvent.SHIFT_DOWN_MASK)
// Ctrl + Shift + V
val keyStroke2 = KeyStroke.getKeyStroke(KeyEvent.VK_V, InputEvent.CTRL_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK)
return keyStroke == keyStroke1 || keyStroke == keyStroke2
}
}

View File

@@ -0,0 +1,57 @@
package app.termora.tlog
import app.termora.TerminalFactory
import app.termora.terminal.*
import org.slf4j.LoggerFactory
class LogViewerTerminal : TerminalFactory.MyVisualTerminal() {
companion object {
private val log = LoggerFactory.getLogger(LogViewerTerminal::class.java)
}
private val document by lazy { MyDocument(this) }
private val terminalModel by lazy { LogViewerTerminalModel(this) }
override fun getDocument(): Document {
return document
}
override fun getTerminalModel(): TerminalModel {
return terminalModel
}
private class MyDocument(terminal: Terminal) : DocumentImpl(terminal) {
override fun eraseInDisplay(n: Int) {
// 预览日志的时候,不处理清屏操作,不然会导致日志看不到。
// 例如,用户输入了 cat xxx.txt ,然后执行了 clear 那么就看不到了
if (n == 3) {
if (log.isDebugEnabled) {
log.debug("ignore $n eraseInDisplay")
}
return
}
super.eraseInDisplay(n)
}
}
@Suppress("UNCHECKED_CAST")
private class LogViewerTerminalModel(terminal: Terminal) : TerminalFactory.MyTerminalModel(terminal) {
override fun getMaxRows(): Int {
return Int.MAX_VALUE
}
override fun <T : Any> getData(key: DataKey<T>): T {
if (key == DataKey.ShowCursor) {
return false as T
}
return super.getData(key)
}
override fun <T : Any> getData(key: DataKey<T>, defaultValue: T): T {
if (key == DataKey.ShowCursor) {
return false as T
}
return super.getData(key, defaultValue)
}
}
}

View File

@@ -0,0 +1,73 @@
package app.termora.tlog
import app.termora.Host
import app.termora.Icons
import app.termora.Protocol
import app.termora.PtyHostTerminalTab
import app.termora.terminal.PtyConnector
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileNotFoundException
import java.nio.file.Files
import javax.swing.Icon
class LogViewerTerminalTab(private val file: File) : PtyHostTerminalTab(
Host(
name = file.name,
protocol = Protocol.Local
),
LogViewerTerminal()
) {
init {
// 不记录日志
terminal.getTerminalModel().setData(TerminalLoggerDataListener.IgnoreTerminalLogger, true)
}
override suspend fun openPtyConnector(): PtyConnector {
if (!file.exists()) {
throw FileNotFoundException(file.absolutePath)
}
val input = withContext(Dispatchers.IO) {
Files.newBufferedReader(file.toPath())
}
return object : PtyConnector {
override fun read(buffer: CharArray): Int {
return input.read(buffer)
}
override fun write(buffer: ByteArray, offset: Int, len: Int) {
}
override fun resize(rows: Int, cols: Int) {
}
override fun waitFor(): Int {
return -1
}
override fun close() {
input.close()
}
}
}
override fun getIcon(): Icon {
return Icons.listFiles
}
override fun canReconnect(): Boolean {
return false
}
override fun canClone(): Boolean {
return false
}
}

View File

@@ -0,0 +1,110 @@
package app.termora.tlog
import app.termora.*
import app.termora.db.Database
import app.termora.native.FileChooser
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.FileUtils
import java.awt.Window
import java.awt.event.ActionEvent
import java.io.File
import java.time.LocalDate
import javax.swing.JComponent
import javax.swing.JFileChooser
import javax.swing.SwingUtilities
class TerminalLoggerAction : AnAction(I18n.getString("termora.terminal-logger"), Icons.listFiles) {
private val properties by lazy { Database.instance.properties }
/**
* 是否开启了记录
*/
var isRecording = properties.getString("terminal.logger.isRecording")?.toBoolean() ?: false
private set(value) {
field = value
// firePropertyChange
putValue("Recording", value)
properties.putString("terminal.logger.isRecording", value.toString())
}
init {
smallIcon = if (isRecording) Icons.dotListFiles else Icons.listFiles
}
override fun actionPerformed(evt: ActionEvent) {
val source = evt.source
if (source !is JComponent) return
val popupMenu = FlatPopupMenu()
if (isRecording) {
// stop
popupMenu.add(I18n.getString("termora.terminal-logger.stop-recording")).addActionListener {
isRecording = false
smallIcon = Icons.listFiles
}
} else {
// start
popupMenu.add(I18n.getString("termora.terminal-logger.start-recording")).addActionListener {
isRecording = true
smallIcon = Icons.dotListFiles
}
}
popupMenu.addSeparator()
// 打开日志浏览
popupMenu.add(I18n.getString("termora.terminal-logger.open-log-viewer")).addActionListener {
openLogViewer(SwingUtilities.getWindowAncestor(source))
}
// 打开日志文件夹
popupMenu.add(
I18n.getString(
"termora.terminal-logger.open-in-folder",
if (SystemInfo.isMacOS) I18n.getString("termora.finder")
else if (SystemInfo.isWindows) I18n.getString("termora.explorer")
else I18n.getString("termora.folder")
)
).addActionListener {
val dir = getLogDir()
Application.browse(dir.toURI())
}
val width = popupMenu.preferredSize.width
popupMenu.show(source, -(width / 2) + source.width / 2, source.height)
}
private fun openLogViewer(owner: Window) {
val fc = FileChooser()
fc.allowsMultiSelection = true
fc.title = I18n.getString("termora.terminal-logger.open-log-viewer")
fc.fileSelectionMode = JFileChooser.FILES_ONLY
if (SystemInfo.isMacOS) {
fc.osxAllowedFileTypes = listOf("log")
} else if (SystemInfo.isWindows) {
fc.win32Filters.add(Pair("Log files", listOf("log")))
}
fc.defaultDirectory = getLogDir().absolutePath
fc.showOpenDialog(owner).thenAccept { files ->
if (files.isNotEmpty()) {
SwingUtilities.invokeLater {
val manager = Application.getService(TerminalTabbedManager::class)
for (file in files) {
val tab = LogViewerTerminalTab(file)
tab.start()
manager.addTerminalTab(tab)
}
}
}
}
}
fun getLogDir(): File {
val dir = FileUtils.getFile(Application.getBaseDataDir(), "terminal", "logs", LocalDate.now().toString())
FileUtils.forceMkdir(dir)
return dir
}
}

View File

@@ -0,0 +1,194 @@
package app.termora.tlog
import app.termora.*
import app.termora.terminal.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.onSuccess
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.beans.PropertyChangeListener
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
class TerminalLoggerDataListener(private val terminal: Terminal) : DataListener {
companion object {
/**
* 忽略日志的标记
*/
val IgnoreTerminalLogger = DataKey(Boolean::class)
private val log = LoggerFactory.getLogger(TerminalLoggerDataListener::class.java)
}
private var coroutineScope: CoroutineScope? = null
private var channel: Channel<String>? = null
private var file: File? = null
private var writer: BufferedWriter? = null
private val isRecording = AtomicBoolean(false)
private val isClosed = AtomicBoolean(false)
// 监听 Recording 变化,如果已经停止录制,那么立即关闭文件
private val terminalLoggerActionPropertyChangeListener = PropertyChangeListener { evt ->
if (evt.propertyName == "Recording") {
if (evt.newValue == false) {
close()
}
}
}
private val host: Host?
get() {
if (terminal.getTerminalModel().hasData(HostTerminalTab.Host)) {
return terminal.getTerminalModel().getData(HostTerminalTab.Host)
}
return null
}
init {
terminal.addTerminalListener(object : TerminalListener {
override fun onClose(terminal: Terminal) {
if (isClosed.compareAndSet(false, true)) {
// 设置为已经关闭
isClosed.set(true)
// 移除变动监听
terminal.getTerminalModel().removeDataListener(this@TerminalLoggerDataListener)
// 关闭流
close()
}
}
})
}
override fun onChanged(key: DataKey<*>, data: Any) {
if (key != VisualTerminal.Written || isClosed.get()) {
return
}
// 如果忽略了,那么跳过
if (terminal.getTerminalModel().getData(IgnoreTerminalLogger, false)) {
return
}
val host = this.host ?: return
val action = ActionManager.getInstance().getAction(Actions.TERMINAL_LOGGER)
if (action !is TerminalLoggerAction || !action.isRecording) {
return
}
try {// 尝试记录
tryRecord(data as String, host, action)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
private fun tryRecord(text: String, host: Host, action: TerminalLoggerAction) {
if (isRecording.compareAndSet(false, true)) {
val file = createFile(host, action.getLogDir()).apply { file = this }
val writer = BufferedWriter(FileWriter(file, false)).apply { writer = this }
if (log.isInfoEnabled) {
log.info("Terminal logger file: ${file.absolutePath}")
}
action.removePropertyChangeListener(terminalLoggerActionPropertyChangeListener)
action.addPropertyChangeListener(terminalLoggerActionPropertyChangeListener)
val coroutineScope = this.coroutineScope ?: CoroutineScope(Dispatchers.IO).apply { coroutineScope = this }
val channel = this.channel ?: Channel<String>(Channel.UNLIMITED).apply { channel = this }
coroutineScope.launch {
while (coroutineScope.isActive) {
channel.receiveCatching().onSuccess {
writer.write(it)
}.onFailure { e ->
if (log.isErrorEnabled && e is Throwable) {
log.error(e.message, e)
}
}
}
}
val date = DateFormatUtils.format(Date(), I18n.getString("termora.date-format"))
channel.trySend("[BEGIN] ---- $date ----").isSuccess
channel.trySend("${ControlCharacters.LF}${ControlCharacters.CR}").isSuccess
}
if (isRecording.get()) {
channel?.trySend(text)?.isSuccess
}
}
private fun createFile(host: Host, dir: File): File {
val now = DateFormatUtils.format(Date(), "HH_mm_ss_SSS")
val filename = "${dir.absolutePath}${File.separator}${host.name}.${now}.log"
return try {
// 如果名称中包含 :\\n 等符号会获取失败,那么采用 ID 代替
Paths.get(filename).toFile()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
try {
Paths.get(dir.absolutePath, "${host.id}.${now}.log").toFile()
} catch (e: Exception) {
Paths.get(dir.absolutePath, "${UUID.randomUUID().toSimpleString()}.${now}.log").toFile()
}
}
}
private fun close() {
if (!isRecording.compareAndSet(true, false)) {
return
}
// 移除监听
ActionManager.getInstance().getAction(Actions.TERMINAL_LOGGER)
?.removePropertyChangeListener(terminalLoggerActionPropertyChangeListener)
this.channel?.close()
this.coroutineScope?.cancel()
this.channel = null
this.coroutineScope = null
// write end
runCatching {
val date = DateFormatUtils.format(Date(), I18n.getString("termora.date-format"))
this.writer?.write("${ControlCharacters.LF}${ControlCharacters.CR}")
this.writer?.write("[END] ---- $date ----")
}.onFailure {
if (log.isErrorEnabled) {
log.error(it.message, it)
}
}
IOUtils.closeQuietly(this.writer)
val file = this.file
if (log.isInfoEnabled && file != null) {
log.info("Terminal logger file: {} saved", file.absolutePath)
}
this.writer = null
this.file = null
}
}

View File

@@ -0,0 +1,164 @@
package app.termora.transport
import app.termora.Application.ohMyJson
import app.termora.DynamicColor
import app.termora.I18n
import app.termora.Icons
import app.termora.assertEventDispatchThread
import app.termora.db.Database
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.ui.FlatUIUtils
import kotlinx.serialization.encodeToString
import org.apache.commons.lang3.StringUtils
import java.awt.*
import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.SwingConstants
import javax.swing.SwingUtilities
class BookmarkButton : JButton(Icons.bookmarks) {
private val properties by lazy { Database.instance.properties }
private val arrowWidth = 16
private val arrowSize = 6
/**
* 为 true 表示在书签内
*/
var isBookmark = false
set(value) {
field = value
icon = if (value) {
Icons.bookmarksOff
} else {
Icons.bookmarks
}
}
init {
val oldWidth = preferredSize.width
preferredSize = Dimension(oldWidth + arrowWidth, preferredSize.height)
horizontalAlignment = SwingConstants.LEFT
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e)) {
if (e.x < oldWidth) {
super@BookmarkButton.fireActionPerformed(
ActionEvent(
this@BookmarkButton,
ActionEvent.ACTION_PERFORMED,
StringUtils.EMPTY
)
)
} else {
showBookmarks(e)
}
}
}
})
isBookmark = false
}
private fun showBookmarks(e: MouseEvent) {
if (StringUtils.isBlank(name)) return
val popupMenu = FlatPopupMenu()
val bookmarks = getBookmarks()
popupMenu.add(I18n.getString("termora.transport.bookmarks")).addActionListener {
val list = BookmarksDialog(SwingUtilities.getWindowAncestor(this), bookmarks).open()
properties.putString(name, ohMyJson.encodeToString(list))
}
if (bookmarks.isNotEmpty()) {
popupMenu.addSeparator()
for (bookmark in bookmarks) {
popupMenu.add(bookmark).addActionListener {
super@BookmarkButton.fireActionPerformed(
ActionEvent(
this@BookmarkButton,
ActionEvent.ACTION_PERFORMED,
bookmark
)
)
}
}
}
popupMenu.show(e.component, -(popupMenu.preferredSize.width / 2 - width / 2), height + 2)
}
fun addBookmark(text: String) {
assertEventDispatchThread()
if (StringUtils.isBlank(name)) return
val bookmarks = getBookmarks().toMutableList()
bookmarks.add(text)
properties.putString(name, ohMyJson.encodeToString(bookmarks))
}
fun deleteBookmark(text: String) {
assertEventDispatchThread()
if (StringUtils.isBlank(name)) return
val bookmarks = getBookmarks().toMutableList()
bookmarks.removeIf { text == it }
properties.putString(name, ohMyJson.encodeToString(bookmarks))
}
fun getBookmarks(): List<String> {
if (StringUtils.isBlank(name)) {
return emptyList()
}
val text = properties.getString(name, "[]")
if (StringUtils.isNotBlank(text)) {
runCatching { ohMyJson.decodeFromString<List<String>>(text) }.onSuccess {
return it
}
}
return emptyList()
}
override fun paintComponent(g: Graphics) {
val g2d = g as Graphics2D
super.paintComponent(g2d)
val x = preferredSize.width - arrowWidth
g.color = DynamicColor.BorderColor
g.drawLine(x + 1, 4, x + 1, preferredSize.height - 2)
g.color = if (FlatLaf.isLafDark()) Color(206, 208, 214) else Color(108, 112, 126)
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
FlatUIUtils.paintArrow(
g2d, x, preferredSize.height / 2 - arrowSize, arrowWidth, arrowWidth, SwingConstants.SOUTH,
false, arrowSize, 0f, 0f, 0f
)
}
override fun isSelected(): Boolean {
return false
}
/**
* 忽略默认的触发事件
*/
override fun fireActionPerformed(event: ActionEvent) {
}
}

View File

@@ -0,0 +1,157 @@
package app.termora.transport
import app.termora.DialogWrapper
import app.termora.DynamicColor
import app.termora.I18n
import app.termora.OptionPane
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import javax.swing.*
import javax.swing.border.EmptyBorder
class BookmarksDialog(
owner: Window,
bookmarks: List<String>
) : DialogWrapper(owner) {
private val model = DefaultListModel<String>()
private val list = JList(model)
private val upBtn = JButton(I18n.getString("termora.transport.bookmarks.up"))
private val downBtn = JButton(I18n.getString("termora.transport.bookmarks.down"))
private val deleteBtn = JButton(I18n.getString("termora.remove"))
init {
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
isModal = true
title = I18n.getString("termora.transport.bookmarks")
initView()
initEvents()
model.addAll(bookmarks)
init()
setLocationRelativeTo(null)
}
private fun initView() {
upBtn.isEnabled = false
downBtn.isEnabled = false
deleteBtn.isEnabled = false
upBtn.isFocusable = false
downBtn.isFocusable = false
deleteBtn.isFocusable = false
list.fixedCellHeight = UIManager.getInt("Tree.rowHeight")
list.selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION
}
private fun initEvents() {
upBtn.addActionListener {
val rows = list.selectedIndices.sorted()
list.clearSelection()
for (row in rows) {
val a = model.getElementAt(row - 1)
val b = model.getElementAt(row)
model.setElementAt(b, row - 1)
model.setElementAt(a, row)
list.selectionModel.addSelectionInterval(row - 1, row - 1)
}
}
downBtn.addActionListener {
val rows = list.selectedIndices.sortedDescending()
list.clearSelection()
for (row in rows) {
val a = model.getElementAt(row + 1)
val b = model.getElementAt(row)
model.setElementAt(b, row + 1)
model.setElementAt(a, row)
list.selectionModel.addSelectionInterval(row + 1, row + 1)
}
}
deleteBtn.addActionListener {
if (list.selectionModel.selectedItemsCount > 0) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
for (e in list.selectionModel.selectedIndices.sortedDescending()) {
model.removeElementAt(e)
}
if (model.size > 0) {
list.selectedIndex = 0
}
}
}
}
list.selectionModel.addListSelectionListener {
upBtn.isEnabled = list.selectionModel.selectedItemsCount > 0
downBtn.isEnabled = upBtn.isEnabled
deleteBtn.isEnabled = upBtn.isEnabled
upBtn.isEnabled = list.minSelectionIndex != 0
downBtn.isEnabled = list.maxSelectionIndex != model.size - 1
}
}
override fun createCenterPanel(): JComponent {
val panel = JPanel(BorderLayout())
panel.add(JScrollPane(list).apply {
border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
}, BorderLayout.CENTER)
var rows = 1
val step = 2
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow",
"pref, $formMargin, pref, $formMargin, pref"
)
panel.add(
FormBuilder.create().layout(layout).padding(EmptyBorder(0, 12, 0, 0))
.add(upBtn).xy(1, rows).apply { rows += step }
.add(downBtn).xy(1, rows).apply { rows += step }
.add(deleteBtn).xy(1, rows).apply { rows += step }
.build(),
BorderLayout.EAST)
panel.border = BorderFactory.createEmptyBorder(
if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0,
12, 12, 12
)
return panel
}
override fun createSouthPanel(): JComponent? {
return null
}
fun open(): List<String> {
isModal = true
isVisible = true
return model.elements().toList()
}
}

View File

@@ -0,0 +1,787 @@
package app.termora.transport
import app.termora.*
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.icons.FlatFileViewDirectoryIcon
import com.formdev.flatlaf.icons.FlatFileViewFileIcon
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.sftp.client.SftpClient
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath
import org.jdesktop.swingx.JXBusyLabel
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Desktop
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.dnd.DnDConstants
import java.awt.dnd.DropTarget
import java.awt.dnd.DropTargetDropEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.io.File
import java.nio.file.*
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
/**
* 文件系统面板
*/
class FileSystemPanel(
private val fileSystem: FileSystem,
private val transportManager: TransportManager,
private val host: Host
) : JPanel(BorderLayout()), Disposable,
FileSystemTransportListener.Provider {
companion object {
private val log = LoggerFactory.getLogger(FileSystemPanel::class.java)
}
private val tableModel = FileSystemTableModel(fileSystem)
private val table = JTable(tableModel)
private val parentBtn = JButton(Icons.up)
private val workdirTextField = OutlineTextField()
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val layeredPane = FileSystemLayeredPane()
private val loadingPanel = LoadingPanel()
private val bookmarkBtn = BookmarkButton()
private val homeBtn = JButton(Icons.homeFolder)
val workdir get() = tableModel.workdir
init {
initView()
initEvents()
}
private fun initView() {
// 设置书签名称
bookmarkBtn.name = "Host.${host.id}.Bookmarks"
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString())
table.autoResizeMode = JTable.AUTO_RESIZE_OFF
table.fillsViewportHeight = true
table.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
)
)
table.setDefaultRenderer(
Any::class.java,
DefaultTableCellRenderer().apply {
horizontalAlignment = SwingConstants.CENTER
}
)
val modifyDateColumn = table.columnModel.getColumn(FileSystemTableModel.COLUMN_LAST_MODIFIED_TIME)
modifyDateColumn.preferredWidth = 130
val nameColumn = table.columnModel.getColumn(FileSystemTableModel.COLUMN_NAME)
nameColumn.preferredWidth = 250
nameColumn.setCellRenderer(object : DefaultTableCellRenderer() {
private val b = BorderFactory.createEmptyBorder(0, 4, 0, 0)
private val d = FlatFileViewDirectoryIcon()
private val f = FlatFileViewFileIcon()
override fun getTableCellRendererComponent(
table: JTable?,
value: Any,
isSelected: Boolean,
hasFocus: Boolean,
row: Int,
column: Int
): Component {
var text = value.toString()
// name
if (value is FileSystemTableModel.CacheablePath) {
text = value.fileName
icon = if (value.isDirectory) d else f
iconTextGap = 4
}
val c = super.getTableCellRendererComponent(table, text, isSelected, hasFocus, row, column)
border = b
return c
}
})
parentBtn.toolTipText = I18n.getString("termora.transport.parent-folder")
val toolbar = FlatToolBar()
toolbar.add(homeBtn)
toolbar.add(Box.createHorizontalStrut(2))
toolbar.add(workdirTextField)
toolbar.add(bookmarkBtn)
toolbar.add(parentBtn)
toolbar.add(JButton(Icons.refresh).apply {
addActionListener { reload() }
toolTipText = I18n.getString("termora.transport.table.contextmenu.refresh")
})
toolbar.border = BorderFactory.createEmptyBorder(4, 2, 4, 2)
val scrollPane = JScrollPane(table)
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
layeredPane.add(scrollPane, JLayeredPane.DEFAULT_LAYER as Any)
layeredPane.add(loadingPanel, JLayeredPane.MODAL_LAYER as Any)
add(toolbar, BorderLayout.NORTH)
add(layeredPane, BorderLayout.CENTER)
}
private fun initEvents() {
homeBtn.addActionListener {
if (tableModel.isLocalFileSystem) {
tableModel.workdir(SystemUtils.USER_HOME)
} else if (fileSystem is SftpFileSystem) {
tableModel.workdir(fileSystem.defaultDir)
}
reload()
}
bookmarkBtn.addActionListener { e ->
if (e.actionCommand.isNullOrBlank()) {
if (bookmarkBtn.isBookmark) {
bookmarkBtn.deleteBookmark(workdir.toString())
} else {
bookmarkBtn.addBookmark(workdir.toString())
}
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
} else if (!loadingPanel.isLoading) {
tableModel.workdir(e.actionCommand)
reload()
}
}
// contextmenu
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isRightMouseButton(e)) {
val r = table.rowAtPoint(e.point)
if (r >= 0 && r < table.rowCount) {
if (!table.isRowSelected(r)) {
table.setRowSelectionInterval(r, r)
}
} else {
table.clearSelection()
}
val rows = table.selectedRows
if (!table.hasFocus()) {
table.requestFocusInWindow()
}
showContextMenu(rows.filter { it != 0 }.toIntArray(), e)
}
}
})
// double click
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val row = table.selectedRow
if (row < 0) return
val path = tableModel.getCacheablePath(row)
if (path.isDirectory) {
openFolder()
} else {
transport(listOf(path))
}
}
}
})
// 本地文件系统不支持本地拖拽进去
if (!tableModel.isLocalFileSystem) {
table.dropTarget = object : DropTarget() {
override fun drop(dtde: DropTargetDropEvent) {
val transportPanel = getTransportPanel() ?: return
val localFileSystemPanel = transportPanel.leftFileSystemTabbed.getFileSystemPanel(0) ?: return
dtde.acceptDrop(DnDConstants.ACTION_COPY)
val files = dtde.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return
val paths = files.filterIsInstance<File>().map { FileSystemTableModel.CacheablePath(it.toPath()) }
for (path in paths) {
if (path.isDirectory) {
Files.walk(path.path).use {
for (e in it) {
transportPanel.transport(
sourceWorkdir = path.path.parent,
targetWorkdir = workdir,
isSourceDirectory = e.isDirectory(),
sourcePath = e,
sourceHolder = localFileSystemPanel,
targetHolder = this@FileSystemPanel
)
}
}
} else {
transportPanel.transport(
sourceWorkdir = path.path.parent,
targetWorkdir = workdir,
isSourceDirectory = false,
sourcePath = path.path,
sourceHolder = localFileSystemPanel,
targetHolder = this@FileSystemPanel
)
}
}
}
}.apply {
this.defaultActions = DnDConstants.ACTION_COPY
}
}
// 工作目录变动
tableModel.addPropertyChangeListener {
if (it.propertyName == "workdir") {
workdirTextField.text = tableModel.workdir.toAbsolutePath().toString()
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdirTextField.text)
}
}
// 修改工作目录
workdirTextField.addActionListener {
val text = workdirTextField.text
if (text.isBlank()) {
workdirTextField.text = tableModel.workdir.toAbsolutePath().toString()
reload()
} else {
val path = fileSystem.getPath(workdirTextField.text)
if (Files.exists(path)) {
tableModel.workdir(path)
reload()
} else {
workdirTextField.outline = "error"
}
}
}
// 返回上一级目录
parentBtn.addActionListener {
if (tableModel.rowCount > 0) {
val path = tableModel.getCacheablePath(0)
if (path.isDirectory && path.fileName == "..") {
tableModel.workdir(path.path)
reload()
}
}
}
}
@OptIn(DelicateCoroutinesApi::class)
fun reload() {
if (loadingPanel.isLoading) {
return
}
GlobalScope.launch(Dispatchers.IO) {
runCatching { suspendReload() }
}
}
private suspend fun suspendReload() {
if (loadingPanel.isLoading) {
return
}
withContext(Dispatchers.Swing) {
// reload
loadingPanel.start()
workdirTextField.text = workdir.toString()
}
try {
tableModel.reload()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
return
} finally {
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
}
withContext(Dispatchers.Swing) {
table.scrollRectToVisible(table.getCellRect(0, 0, true))
}
}
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listenerList.add(FileSystemTransportListener::class.java, listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listenerList.remove(FileSystemTransportListener::class.java, listener)
}
private fun openFolder() {
val row = table.selectedRow
if (row < 0) return
val path = tableModel.getCacheablePath(row)
if (path.isDirectory) {
tableModel.workdir(path.path)
reload()
}
}
private fun canTransfer(): Boolean {
return getTransportPanel()?.getTargetFileSystemPanel(this) != null
}
private fun getTransportPanel(): TransportPanel? {
var p = this as Component?
while (p != null) {
if (p is TransportPanel) {
return p
}
p = p.parent
}
return null
}
private fun showContextMenu(rows: IntArray, event: MouseEvent) {
val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
// 创建文件夹
newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder")).addActionListener {
newFolderOrFile(file = false)
}
// 创建文件
newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file")).addActionListener {
newFolderOrFile(file = true)
}
// 传输
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
transfer.addActionListener {
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
if (paths.isNotEmpty()) {
transport(paths)
}
}
popupMenu.addSeparator()
// 复制路径
val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path"))
copyPath.addActionListener {
val row = table.selectedRow
if (row > 0) {
toolkit.systemClipboard.setContents(
StringSelection(
tableModel.getPath(row).toAbsolutePath().toString()
), null
)
}
}
// 如果是本地,那么支持打开本地路径
if (tableModel.isLocalFileSystem) {
if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
popupMenu.add(
I18n.getString(
"termora.transport.table.contextmenu.open-in-folder",
if (SystemInfo.isMacOS) I18n.getString("termora.finder")
else if (SystemInfo.isWindows) I18n.getString("termora.explorer")
else I18n.getString("termora.folder")
)
).addActionListener {
val row = table.selectedRow
if (row > 0) {
Desktop.getDesktop().browseFileDirectory(tableModel.getPath(row).toFile())
}
}
}
}
popupMenu.addSeparator()
// 重命名
val rename = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.rename"))
rename.addActionListener { renamePath(tableModel.getPath(rows.last())) }
// 删除
val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete")).apply {
addActionListener { deletePaths(rows) }
}
// rm -rf
val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.errorIntroduction)).apply {
addActionListener {
deletePaths(rows, true)
}
}
// 只有 SFTP 可以
if (fileSystem !is SftpFileSystem) {
rmrf.isVisible = false
}
// 修改权限
val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
permission.isEnabled = false
// 如果是本地系统文件,那么不允许修改权限,用户应该自己修改
if (!tableModel.isLocalFileSystem && rows.isNotEmpty()) {
permission.isEnabled = true
permission.addActionListener { changePermissions(tableModel.getCacheablePath(rows.last())) }
}
popupMenu.addSeparator()
// 刷新
popupMenu.add(I18n.getString("termora.transport.table.contextmenu.refresh"))
.apply { addActionListener { reload() } }
popupMenu.addSeparator()
// 新建
popupMenu.add(newMenu)
if (rows.isEmpty()) {
transfer.isEnabled = false
rename.isEnabled = false
delete.isEnabled = false
rmrf.isEnabled = false
copyPath.isEnabled = false
permission.isEnabled = false
} else {
transfer.isEnabled = canTransfer()
}
popupMenu.show(table, event.x, event.y)
}
@OptIn(DelicateCoroutinesApi::class)
private fun renamePath(path: Path) {
val fileName = path.fileName.toString()
val text = InputDialog(
owner = owner,
title = fileName,
text = fileName,
).getText() ?: return
if (fileName == text) return
loadingPanel.stop()
GlobalScope.launch(Dispatchers.IO) {
val result = runCatching {
Files.move(path, path.parent.resolve(text), StandardCopyOption.ATOMIC_MOVE)
}.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, it.message ?: ExceptionUtils.getRootCauseMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
if (result.isSuccess) {
reload()
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun newFolderOrFile(file: Boolean = false) {
val title = I18n.getString("termora.transport.table.contextmenu.new.${if (file) "file" else "folder"}")
val text = InputDialog(
owner = owner,
title = title,
).getText() ?: return
if (text.isEmpty()) return
loadingPanel.stop()
GlobalScope.launch(Dispatchers.IO) {
val result = runCatching {
val path = workdir.resolve(text)
if (path.exists()) {
throw IllegalStateException(I18n.getString("termora.transport.file-already-exists", text))
}
if (file)
Files.createFile(path)
else
Files.createDirectories(path)
}.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, it.message ?: ExceptionUtils.getRootCauseMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
if (result.isSuccess) {
reload()
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun deletePaths(rows: IntArray, rm: Boolean = false) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"),
messageType = if (rm) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE
) != JOptionPane.YES_OPTION
) {
return
}
loadingPanel.start()
GlobalScope.launch(Dispatchers.IO) {
runCatching {
for (row in rows.sortedDescending()) {
try {
deleteRecursively(tableModel.getPath(row), rm)
withContext(Dispatchers.Swing) {
tableModel.removeRow(row)
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}.onFailure {
if (log.isErrorEnabled) {
log.error(it.message, it)
}
}
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
}
}
private fun deleteRecursively(path: Path, rm: Boolean) {
if (path.fileSystem == FileSystems.getDefault()) {
FileUtils.deleteQuietly(path.toFile())
} else if (path.fileSystem is SftpFileSystem) {
val fs = path.fileSystem as SftpFileSystem
if (rm) {
fs.session.executeRemoteCommand("rm -rf '$path'")
} else {
fs.client.use {
deleteRecursivelySFTP(path as SftpPath, it)
}
}
}
}
/**
* 优化删除效率,采用一个连接
*/
private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) {
val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory()
if (isDirectory) {
for (e in sftpClient.readDir(path.toString())) {
if (e.filename == ".." || e.filename == ".") {
continue
}
if (e.attributes.isDirectory) {
deleteRecursivelySFTP(path.resolve(e.filename), sftpClient)
} else {
sftpClient.remove(path.resolve(e.filename).toString())
}
}
sftpClient.rmdir(path.toString())
} else {
sftpClient.remove(path.toString())
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun changePermissions(cacheablePath: FileSystemTableModel.CacheablePath) {
val dialog = PosixFilePermissionDialog(
SwingUtilities.getWindowAncestor(this),
cacheablePath.posixFilePermissions
)
val permissions = dialog.open() ?: return
loadingPanel.start()
GlobalScope.launch(Dispatchers.IO) {
val result = runCatching {
Files.setPosixFilePermissions(cacheablePath.path, permissions)
}
result.onFailure {
if (log.isErrorEnabled) {
log.error(it.message, it)
}
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
SwingUtilities.getWindowAncestor(this@FileSystemPanel), ExceptionUtils.getRootCauseMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
if (result.isSuccess) {
reload()
}
}
}
private fun transport(paths: List<FileSystemTableModel.CacheablePath>) {
assertEventDispatchThread()
if (!canTransfer()) {
return
}
loadingPanel.start()
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
runCatching { doTransport(paths) }
withContext(Dispatchers.Swing) {
loadingPanel.stop()
}
}
}
private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) {
if (paths.isEmpty()) return
val listeners = listenerList.getListeners(FileSystemTransportListener::class.java)
if (listeners.isEmpty()) return
// 收集数据
for (e in paths) {
if (!e.isDirectory) {
withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, false, e.path) }
}
continue
}
withContext(Dispatchers.IO) {
Files.walk(e.path).use { walkPaths ->
for (path in walkPaths) {
if (path is SftpPath) {
val isDirectory = if (path.attributes != null)
path.attributes.isDirectory else path.isDirectory()
withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) }
}
} else {
val isDirectory = path.isDirectory()
withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) }
}
}
}
}
}
}
}
private class LoadingPanel : JPanel() {
private val busyLabel = JXBusyLabel()
val isLoading get() = busyLabel.isBusy
init {
isOpaque = false
border = BorderFactory.createEmptyBorder(50, 0, 0, 0)
add(busyLabel, BorderLayout.CENTER)
addMouseListener(object : MouseAdapter() {})
isVisible = false
}
fun start() {
busyLabel.isBusy = true
isVisible = true
}
fun stop() {
busyLabel.isBusy = false
isVisible = false
}
}
private class FileSystemLayeredPane : JLayeredPane() {
override fun doLayout() {
synchronized(treeLock) {
val w = width
val h = height
for (c in components) {
if (c is JScrollPane) {
c.setBounds(0, 0, w, h)
} else if (c is LoadingPanel) {
c.setBounds(0, 0, w, h)
}
}
}
}
}
}

View File

@@ -0,0 +1,205 @@
package app.termora.transport
import app.termora.*
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
import java.awt.Point
import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.*
import kotlin.math.max
class FileSystemTabbed(
private val transportManager: TransportManager,
private val isLeft: Boolean = false
) : FlatTabbedPane(), FileSystemTransportListener.Provider, Disposable {
private val addBtn = JButton(Icons.add)
private val listeners = mutableListOf<FileSystemTransportListener>()
init {
initView()
initEvents()
}
private fun initView() {
tabLayoutPolicy = SCROLL_TAB_LAYOUT
isTabsClosable = true
tabType = TabType.underlined
styleMap = mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
)
val toolbar = JToolBar()
toolbar.add(addBtn)
trailingComponent = toolbar
if (isLeft) {
addFileSystemTransportProvider(
I18n.getString("termora.transport.local"),
FileSystemPanel(
FileSystems.getDefault(),
transportManager,
host = Host(
id = "local",
name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local,
)
).apply { reload() }
)
setTabClosable(0, false)
} else {
addFileSystemTransportProvider(
I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel(transportManager)
)
}
}
private fun initEvents() {
addBtn.addActionListener {
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this))
dialog.location = Point(
addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2,
addBtn.locationOnScreen.y + max(tabHeight, addBtn.height)
)
dialog.isVisible = true
for (host in dialog.hosts) {
val panel = SftpFileSystemPanel(transportManager, host)
addFileSystemTransportProvider(host.name, panel)
panel.connect()
}
}
setTabCloseCallback { _, index ->
removeTabAt(index)
}
}
override fun removeTabAt(index: Int) {
val fileSystemPanel = getFileSystemPanel(index)
// 取消进行中的任务
if (fileSystemPanel != null) {
val transports = mutableListOf<Transport>()
for (transport in transportManager.getTransports()) {
if (transport.targetHolder == fileSystemPanel || transport.sourceHolder == fileSystemPanel) {
transports.add(transport)
}
}
if (transports.isNotEmpty()) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.WARNING_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) != JOptionPane.OK_OPTION
) {
return
}
transports.sortedBy { it.state == TransportState.Waiting }
.forEach { transportManager.removeTransport(it) }
}
}
val c = getComponentAt(index)
if (c is Disposable) {
Disposer.dispose(c)
}
super.removeTabAt(index)
if (tabCount == 0) {
if (!isLeft) {
addFileSystemTransportProvider(
I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel(transportManager)
)
}
}
}
fun addFileSystemTransportProvider(title: String, provider: FileSystemTransportListener.Provider) {
if (provider !is JComponent) {
throw IllegalArgumentException("Provider is not an JComponent")
}
provider.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
}
})
// 修改 Tab名称
provider.addPropertyChangeListener("TabName") { e ->
SwingUtilities.invokeLater {
val name = StringUtils.defaultIfEmpty(
e.newValue.toString(),
I18n.getString("termora.transport.sftp.select-host")
)
for (i in 0 until tabCount) {
if (getComponentAt(i) == provider) {
setTitleAt(i, name)
break
}
}
}
}
addTab(title, provider)
if (tabCount > 0)
selectedIndex = tabCount - 1
}
fun getSelectedFileSystemPanel(): FileSystemPanel? {
return getFileSystemPanel(selectedIndex)
}
fun getFileSystemPanel(index: Int): FileSystemPanel? {
if (index < 0) return null
val c = getComponentAt(index)
if (c is SftpFileSystemPanel) {
val p = c.fileSystemPanel
if (p != null) {
return p
}
}
if (c is FileSystemPanel) {
return c
}
return null
}
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.add(listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.remove(listener)
}
override fun dispose() {
while (tabCount > 0) {
val c = getComponentAt(0)
if (c is Disposable) {
Disposer.dispose(c)
}
super.removeTabAt(0)
}
}
}

View File

@@ -0,0 +1,232 @@
package app.termora.transport
import app.termora.I18n
import app.termora.formatBytes
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath
import org.slf4j.LoggerFactory
import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener
import java.nio.file.FileSystem
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.PosixFilePermissions
import java.util.*
import javax.swing.SwingUtilities
import javax.swing.table.DefaultTableModel
import kotlin.io.path.*
class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableModel() {
companion object {
const val COLUMN_NAME = 0
const val COLUMN_TYPE = 1
const val COLUMN_FILE_SIZE = 2
const val COLUMN_LAST_MODIFIED_TIME = 3
const val COLUMN_ATTRS = 4
const val COLUMN_OWNER = 5
}
private val root = fileSystem.rootDirectories.first()
var workdir: Path = if (fileSystem is SftpFileSystem) fileSystem.defaultDir
else fileSystem.getPath(SystemUtils.USER_HOME)
private set
@Volatile
private var files: MutableList<CacheablePath>? = null
private val propertyChangeListeners = mutableListOf<PropertyChangeListener>()
val isLocalFileSystem by lazy { FileSystems.getDefault() == fileSystem }
override fun getRowCount(): Int {
return files?.size ?: 0
}
override fun getValueAt(row: Int, column: Int): Any {
val path = files?.get(row) ?: return StringUtils.EMPTY
if (path.fileName == ".." && column != 0) {
return StringUtils.EMPTY
}
return try {
when (column) {
COLUMN_NAME -> path
COLUMN_FILE_SIZE -> if (path.isDirectory) StringUtils.EMPTY else formatBytes(path.fileSize)
COLUMN_TYPE -> if (path.isDirectory) I18n.getString("termora.transport.table.type.folder") else path.extension
COLUMN_LAST_MODIFIED_TIME -> DateFormatUtils.format(Date(path.lastModifiedTime), "yyyy/MM/dd HH:mm")
// 如果是本地的并且还是Windows系统
COLUMN_ATTRS -> if (isLocalFileSystem && SystemUtils.IS_OS_WINDOWS) StringUtils.EMPTY else PosixFilePermissions.toString(
path.posixFilePermissions
)
COLUMN_OWNER -> path.owner
else -> StringUtils.EMPTY
}
} catch (e: Exception) {
StringUtils.EMPTY
}
}
override fun getColumnCount(): Int {
return 6
}
override fun getColumnName(column: Int): String {
return when (column) {
COLUMN_NAME -> I18n.getString("termora.transport.table.filename")
COLUMN_FILE_SIZE -> I18n.getString("termora.transport.table.size")
COLUMN_TYPE -> I18n.getString("termora.transport.table.type")
COLUMN_LAST_MODIFIED_TIME -> I18n.getString("termora.transport.table.modified-time")
COLUMN_ATTRS -> I18n.getString("termora.transport.table.permissions")
COLUMN_OWNER -> I18n.getString("termora.transport.table.owner")
else -> StringUtils.EMPTY
}
}
fun getPath(index: Int): Path {
return getCacheablePath(index).path
}
fun getCacheablePath(index: Int): CacheablePath {
return files?.get(index) ?: throw IndexOutOfBoundsException()
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
override fun removeRow(row: Int) {
files?.removeAt(row) ?: return
fireTableRowsDeleted(row, row)
}
fun reload() {
val files = mutableListOf<CacheablePath>()
if (root != workdir) {
files.add(CacheablePath(workdir.resolve("..")))
}
Files.list(workdir).use {
for (path in it) {
if (path is SftpPath) {
files.add(SftpCacheablePath(path))
} else {
files.add(CacheablePath(path))
}
}
}
files.sortWith(compareBy({ !it.isDirectory }, { it.fileName }))
SwingUtilities.invokeLater {
this.files = files
fireTableDataChanged()
}
}
fun workdir(absolutePath: String) {
workdir(fileSystem.getPath(absolutePath))
}
fun workdir(path: Path) {
this.workdir = path.toAbsolutePath().normalize()
propertyChangeListeners.forEach {
it.propertyChange(
PropertyChangeEvent(
this,
"workdir",
this.workdir,
this.workdir
)
)
}
}
fun addPropertyChangeListener(propertyChangeListener: PropertyChangeListener) {
propertyChangeListeners.add(propertyChangeListener)
}
open class CacheablePath(val path: Path) {
val fileName by lazy { path.fileName.toString() }
val extension by lazy { path.extension }
open val isDirectory by lazy { path.isDirectory() }
open val fileSize by lazy { path.fileSize() }
open val lastModifiedTime by lazy { Files.getLastModifiedTime(path).toMillis() }
open val owner by lazy { path.getOwner().toString() }
open val posixFilePermissions by lazy {
kotlin.runCatching { path.getPosixFilePermissions() }.getOrElse { emptySet() }
}
}
class SftpCacheablePath(sftpPath: SftpPath) : CacheablePath(sftpPath) {
private val attributes = sftpPath.attributes
companion object {
private val log = LoggerFactory.getLogger(SftpCacheablePath::class.java)
private fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
val result = mutableSetOf<PosixFilePermission>()
// 将十进制权限转换为八进制字符串
val octalPermissions = sftpPermissions.toString(8)
// 仅取后三位权限部分
if (octalPermissions.length < 3) {
if (log.isErrorEnabled) {
log.error("Invalid permission value: {}", sftpPermissions)
return result
}
}
val permissionBits = octalPermissions.takeLast(3)
// 解析每一部分的权限
val owner = permissionBits[0].digitToInt()
val group = permissionBits[1].digitToInt()
val others = permissionBits[2].digitToInt()
// 处理所有者权限
if ((owner and 4) != 0) result.add(PosixFilePermission.OWNER_READ)
if ((owner and 2) != 0) result.add(PosixFilePermission.OWNER_WRITE)
if ((owner and 1) != 0) result.add(PosixFilePermission.OWNER_EXECUTE)
// 处理组权限
if ((group and 4) != 0) result.add(PosixFilePermission.GROUP_READ)
if ((group and 2) != 0) result.add(PosixFilePermission.GROUP_WRITE)
if ((group and 1) != 0) result.add(PosixFilePermission.GROUP_EXECUTE)
// 处理其他用户权限
if ((others and 4) != 0) result.add(PosixFilePermission.OTHERS_READ)
if ((others and 2) != 0) result.add(PosixFilePermission.OTHERS_WRITE)
if ((others and 1) != 0) result.add(PosixFilePermission.OTHERS_EXECUTE)
return result
}
}
override val isDirectory: Boolean
get() = attributes.isDirectory
override val fileSize: Long
get() = attributes.size
override val lastModifiedTime: Long
by lazy { attributes.modifyTime.toMillis() }
override val owner: String
get() = attributes.owner
override val posixFilePermissions: Set<PosixFilePermission>
by lazy { fromSftpPermissions(attributes.permissions) }
}
}

View File

@@ -0,0 +1,19 @@
package app.termora.transport
import java.nio.file.Path
import java.util.*
interface FileSystemTransportListener : EventListener {
/**
* @param workdir 当前工作目录
* @param isDirectory 要传输的是否是文件夹
* @param path 要传输的文件/文件夹
*/
fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path)
interface Provider {
fun addFileSystemTransportListener(listener: FileSystemTransportListener)
fun removeFileSystemTransportListener(listener: FileSystemTransportListener)
}
}

View File

@@ -0,0 +1,162 @@
package app.termora.transport
import app.termora.Disposable
import app.termora.I18n
import app.termora.OptionPane
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Graphics
import java.awt.Insets
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
class FileTransportPanel(
private val transportManager: TransportManager
) : JPanel(BorderLayout()), Disposable {
private val tableModel = FileTransportTableModel(transportManager)
private val table = JTable(tableModel)
init {
initView()
initEvents()
}
private fun initView() {
table.fillsViewportHeight = true
table.autoResizeMode = JTable.AUTO_RESIZE_OFF
table.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
"cellMargins" to Insets(2, 2, 2, 2)
)
)
table.columnModel.getColumn(FileTransportTableModel.COLUMN_NAME).preferredWidth = 200
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200
table.columnModel.getColumn(FileTransportTableModel.COLUMN_TARGET_PATH).preferredWidth = 200
table.columnModel.getColumn(FileTransportTableModel.COLUMN_STATUS).preferredWidth = 100
table.columnModel.getColumn(FileTransportTableModel.COLUMN_PROGRESS).preferredWidth = 150
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SIZE).preferredWidth = 140
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SPEED).preferredWidth = 80
val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }
table.columnModel.getColumn(FileTransportTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer
table.columnModel.getColumn(FileTransportTableModel.COLUMN_ESTIMATED_TIME).cellRenderer =
centerTableCellRenderer
table.columnModel.getColumn(FileTransportTableModel.COLUMN_PROGRESS).cellRenderer =
object : DefaultTableCellRenderer() {
init {
horizontalAlignment = SwingConstants.CENTER
}
private var lastRow = -1
override fun getTableCellRendererComponent(
table: JTable?,
value: Any?,
isSelected: Boolean,
hasFocus: Boolean,
row: Int,
column: Int
): Component {
lastRow = row
return super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
}
override fun paintComponent(g: Graphics) {
if (lastRow != -1) {
val row = tableModel.getTransport(lastRow)
if (row.state == TransportState.Transporting) {
g.color = UIManager.getColor("textHighlight")
g.fillRect(0, 0, (width * row.progress).toInt(), height)
}
}
super.paintComponent(g)
}
}
add(JScrollPane(table).apply { border = BorderFactory.createEmptyBorder() }, BorderLayout.CENTER)
}
private fun initEvents() {
// contextmenu
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isRightMouseButton(e)) {
val r = table.rowAtPoint(e.point)
if (r >= 0 && r < table.rowCount) {
if (!table.isRowSelected(r)) {
table.setRowSelectionInterval(r, r)
}
} else {
table.clearSelection()
}
val rows = table.selectedRows
if (!table.hasFocus()) {
table.requestFocusInWindow()
}
showContextMenu(kotlin.runCatching {
rows.map { tableModel.getTransport(it) }
}.getOrElse { emptyList() }, e)
}
}
})
}
private fun showContextMenu(transports: List<Transport>, event: MouseEvent) {
val popupMenu = FlatPopupMenu()
val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete")).apply {
addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
for (transport in transports) {
transportManager.removeTransport(transport)
}
}
}
}
val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all"))
deleteAll.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
transportManager.removeAllTransports()
}
}
if (transports.isEmpty()) {
delete.isEnabled = false
deleteAll.isEnabled = transportManager.getTransports().isNotEmpty()
}
popupMenu.show(table, event.x, event.y)
}
}

View File

@@ -0,0 +1,126 @@
package app.termora.transport
import app.termora.I18n
import app.termora.formatBytes
import app.termora.formatSeconds
import org.apache.commons.lang3.StringUtils
import javax.swing.SwingUtilities
import javax.swing.table.DefaultTableModel
class FileTransportTableModel(transportManager: TransportManager) : DefaultTableModel() {
private var isInitialized = false
private inline fun invokeLater(crossinline block: () -> Unit) {
if (SwingUtilities.isEventDispatchThread()) {
block.invoke()
} else {
SwingUtilities.invokeLater { block.invoke() }
}
}
init {
transportManager.addTransportListener(object : TransportListener {
override fun onTransportAdded(transport: Transport) {
invokeLater { addRow(arrayOf(transport)) }
}
override fun onTransportRemoved(transport: Transport) {
invokeLater {
val index = getDataVector().indexOfFirst { it.firstOrNull() == transport }
if (index >= 0) {
removeRow(index)
}
}
}
override fun onTransportChanged(transport: Transport) {
invokeLater {
for ((index, vector) in getDataVector().withIndex()) {
if (vector.firstOrNull() == transport) {
fireTableRowsUpdated(index, index)
}
}
}
}
})
isInitialized = true
}
companion object {
const val COLUMN_NAME = 0
const val COLUMN_STATUS = 1
const val COLUMN_PROGRESS = 2
const val COLUMN_SIZE = 3
const val COLUMN_SOURCE_PATH = 4
const val COLUMN_TARGET_PATH = 5
const val COLUMN_SPEED = 6
const val COLUMN_ESTIMATED_TIME = 7
}
override fun getColumnCount(): Int {
return 8
}
fun getTransport(row: Int): Transport {
return super.getValueAt(row, COLUMN_NAME) as Transport
}
override fun getValueAt(row: Int, column: Int): Any {
val transport = getTransport(row)
val isTransporting = transport.state == TransportState.Transporting
val speed = if (isTransporting) transport.speed else 0
val estimatedTime = if (isTransporting && speed > 0)
(transport.size - transport.transferredSize) / speed else 0
val progress = transport.progress * 100.0
return when (column) {
COLUMN_NAME -> " ${transport.name}"
COLUMN_STATUS -> formatStatus(transport.state)
// 如果进度已经完成但是状态还是传输中那么进度显示99%
COLUMN_PROGRESS -> String.format("%.0f%%", if (progress >= 100.0 && isTransporting) 99.0 else progress)
// 大小
COLUMN_SIZE -> if (transport.size < 0) "-"
else if (isTransporting) "${formatBytes(transport.transferredSize)}/${formatBytes(transport.size)}"
else formatBytes(transport.size)
COLUMN_SOURCE_PATH -> " ${transport.getSourcePath}"
COLUMN_TARGET_PATH -> " ${transport.getTargetPath}"
COLUMN_SPEED -> if (isTransporting) formatBytes(speed) else "-"
COLUMN_ESTIMATED_TIME -> if (isTransporting && speed > 0) formatSeconds(estimatedTime) else "-"
else -> StringUtils.EMPTY
}
}
private fun formatStatus(state: TransportState): String {
return when (state) {
TransportState.Transporting -> I18n.getString("termora.transport.sftp.status.transporting")
TransportState.Waiting -> I18n.getString("termora.transport.sftp.status.waiting")
TransportState.Done -> I18n.getString("termora.transport.sftp.status.done")
TransportState.Failed -> I18n.getString("termora.transport.sftp.status.failed")
TransportState.Cancelled -> I18n.getString("termora.transport.sftp.status.cancelled")
}
}
override fun getColumnName(column: Int): String {
return when (column) {
COLUMN_NAME -> I18n.getString("termora.transport.jobs.table.name")
COLUMN_STATUS -> I18n.getString("termora.transport.jobs.table.status")
COLUMN_PROGRESS -> I18n.getString("termora.transport.jobs.table.progress")
COLUMN_SIZE -> I18n.getString("termora.transport.jobs.table.size")
COLUMN_SOURCE_PATH -> I18n.getString("termora.transport.jobs.table.source-path")
COLUMN_TARGET_PATH -> I18n.getString("termora.transport.jobs.table.target-path")
COLUMN_SPEED -> I18n.getString("termora.transport.jobs.table.speed")
COLUMN_ESTIMATED_TIME -> I18n.getString("termora.transport.jobs.table.estimated-time")
else -> StringUtils.EMPTY
}
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
}

View File

@@ -0,0 +1,148 @@
package app.termora.transport
import app.termora.DialogWrapper
import app.termora.I18n
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import java.awt.Dimension
import java.awt.Window
import java.nio.file.attribute.PosixFilePermission
import javax.swing.*
import kotlin.math.max
class PosixFilePermissionDialog(
owner: Window,
private val permissions: Set<PosixFilePermission>
) : DialogWrapper(owner) {
private val ownerRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
private val ownerWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
private val ownerExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
private val groupRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
private val groupWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
private val groupExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
private val otherRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
private val otherWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
private val otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
private var isCancelled = false
init {
isModal = true
isResizable = false
controlsVisible = false
title = I18n.getString("termora.transport.permissions")
initView()
init()
pack()
size = Dimension(max(size.width, UIManager.getInt("Dialog.width") - 300), size.height)
setLocationRelativeTo(null)
}
private fun initView() {
ownerRead.isSelected = permissions.contains(PosixFilePermission.OWNER_READ)
ownerWrite.isSelected = permissions.contains(PosixFilePermission.OWNER_WRITE)
ownerExecute.isSelected = permissions.contains(PosixFilePermission.OWNER_EXECUTE)
groupRead.isSelected = permissions.contains(PosixFilePermission.GROUP_READ)
groupWrite.isSelected = permissions.contains(PosixFilePermission.GROUP_WRITE)
groupExecute.isSelected = permissions.contains(PosixFilePermission.GROUP_EXECUTE)
otherRead.isSelected = permissions.contains(PosixFilePermission.OTHERS_READ)
otherWrite.isSelected = permissions.contains(PosixFilePermission.OTHERS_WRITE)
otherExecute.isSelected = permissions.contains(PosixFilePermission.OTHERS_EXECUTE)
ownerRead.isFocusable = false
ownerWrite.isFocusable = false
ownerExecute.isFocusable = false
groupRead.isFocusable = false
groupWrite.isFocusable = false
groupExecute.isFocusable = false
otherRead.isFocusable = false
otherWrite.isFocusable = false
otherExecute.isFocusable = false
}
override fun createCenterPanel(): JComponent {
val formMargin = "7dlu"
val layout = FormLayout(
"default:grow, $formMargin, default:grow, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref"
)
val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin")
.layout(layout).debug(true)
builder.add("${I18n.getString("termora.transport.permissions.file-folder-permissions")}:").xyw(1, 1, 5)
val ownerBox = Box.createVerticalBox()
ownerBox.add(ownerRead)
ownerBox.add(ownerWrite)
ownerBox.add(ownerExecute)
ownerBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.owner"))
builder.add(ownerBox).xy(1, 3)
val groupBox = Box.createVerticalBox()
groupBox.add(groupRead)
groupBox.add(groupWrite)
groupBox.add(groupExecute)
groupBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.group"))
builder.add(groupBox).xy(3, 3)
val otherBox = Box.createVerticalBox()
otherBox.add(otherRead)
otherBox.add(otherWrite)
otherBox.add(otherExecute)
otherBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.others"))
builder.add(otherBox).xy(5, 3)
return builder.build()
}
override fun doCancelAction() {
this.isCancelled = true
super.doCancelAction()
}
/**
* @return 返回空表示取消了
*/
fun open(): Set<PosixFilePermission>? {
isModal = true
isVisible = true
if (isCancelled) {
return null
}
val permissions = mutableSetOf<PosixFilePermission>()
if (ownerRead.isSelected) {
permissions.add(PosixFilePermission.OWNER_READ)
}
if (ownerWrite.isSelected) {
permissions.add(PosixFilePermission.OWNER_WRITE)
}
if (ownerExecute.isSelected) {
permissions.add(PosixFilePermission.OWNER_EXECUTE)
}
if (groupRead.isSelected) {
permissions.add(PosixFilePermission.GROUP_READ)
}
if (groupWrite.isSelected) {
permissions.add(PosixFilePermission.GROUP_WRITE)
}
if (groupExecute.isSelected) {
permissions.add(PosixFilePermission.GROUP_EXECUTE)
}
if (otherRead.isSelected) {
permissions.add(PosixFilePermission.OTHERS_READ)
}
if (otherWrite.isSelected) {
permissions.add(PosixFilePermission.OTHERS_WRITE)
}
if (otherExecute.isSelected) {
permissions.add(PosixFilePermission.OTHERS_EXECUTE)
}
return permissions
}
}

View File

@@ -0,0 +1,20 @@
package app.termora.transport
import app.termora.*
import java.awt.event.ActionEvent
class SFTPAction : AnAction("SFTP", Icons.folder) {
override fun actionPerformed(evt: ActionEvent) {
val terminalTabbedManager = Application.getService(TerminalTabbedManager::class)
val tabs = terminalTabbedManager.getTerminalTabs()
for (tab in tabs) {
if (tab is SFTPTerminalTab) {
terminalTabbedManager.setSelectedTerminalTab(tab)
return
}
}
// 创建一个新的
terminalTabbedManager.addTerminalTab(SFTPTerminalTab())
}
}

View File

@@ -0,0 +1,321 @@
package app.termora.transport
import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.sftp.client.SftpClientFactory
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.jdesktop.swingx.JXBusyLabel
import org.jdesktop.swingx.JXHyperlink
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.event.ActionEvent
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.*
class SftpFileSystemPanel(
private val transportManager: TransportManager,
private var host: Host? = null
) : JPanel(BorderLayout()), Disposable,
FileSystemTransportListener.Provider {
companion object {
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java)
private enum class State {
Initialized,
Connecting,
Connected,
ConnectFailed,
}
}
@Volatile
private var state = State.Initialized
private val cardLayout = CardLayout()
private val cardPanel = JPanel(cardLayout)
private val connectingPanel = ConnectingPanel()
private val selectHostPanel = SelectHostPanel()
private val connectFailedPanel = ConnectFailedPanel()
private val listeners = mutableListOf<FileSystemTransportListener>()
private val isDisposed = AtomicBoolean(false)
private var client: SshClient? = null
private var session: ClientSession? = null
private var fileSystem: SftpFileSystem? = null
var fileSystemPanel: FileSystemPanel? = null
init {
initView()
initEvents()
}
private fun initView() {
cardPanel.add(selectHostPanel, State.Initialized.name)
cardPanel.add(connectingPanel, State.Connecting.name)
cardPanel.add(connectFailedPanel, State.ConnectFailed.name)
cardLayout.show(cardPanel, State.Initialized.name)
add(cardPanel, BorderLayout.CENTER)
}
private fun initEvents() {
}
@OptIn(DelicateCoroutinesApi::class)
fun connect() {
GlobalScope.launch(Dispatchers.IO) {
if (state != State.Connecting) {
state = State.Connecting
withContext(Dispatchers.Swing) {
connectingPanel.start()
cardLayout.show(cardPanel, State.Connecting.name)
}
runCatching { doConnect() }.onFailure {
if (log.isErrorEnabled) {
log.error(it.message, it)
}
withContext(Dispatchers.Swing) {
state = State.ConnectFailed
connectFailedPanel.errorLabel.text = ExceptionUtils.getRootCauseMessage(it)
cardLayout.show(cardPanel, State.ConnectFailed.name)
}
}
withContext(Dispatchers.Swing) {
connectingPanel.stop()
}
}
}
}
private suspend fun doConnect() {
val host = this.host ?: return
closeIO()
try {
val client = SshClients.openClient(host).apply { client = this }
withContext(Dispatchers.Swing) {
client.userInteraction =
TerminalUserInteraction(SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel))
}
val session = SshClients.openSession(host, client).apply { session = this }
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
session.addCloseFutureListener { onClose() }
} catch (e: Exception) {
closeIO()
throw e
}
if (isDisposed.get()) {
throw IllegalStateException("Closed")
}
val fileSystem = this.fileSystem ?: return
withContext(Dispatchers.Swing) {
state = State.Connected
val fileSystemPanel = FileSystemPanel(fileSystem, transportManager, host)
fileSystemPanel.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(
fileSystemPanel: FileSystemPanel,
workdir: Path,
isDirectory: Boolean,
path: Path
) {
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
}
})
cardPanel.add(fileSystemPanel, State.Connected.name)
cardLayout.show(cardPanel, State.Connected.name)
firePropertyChange("TabName", StringUtils.EMPTY, host.name)
this@SftpFileSystemPanel.fileSystemPanel = fileSystemPanel
// 立即加载
fileSystemPanel.reload()
}
}
private fun onClose() {
if (isDisposed.get()) {
return
}
SwingUtilities.invokeLater {
closeIO()
state = State.ConnectFailed
connectFailedPanel.errorLabel.text = I18n.getString("termora.transport.sftp.closed")
cardLayout.show(cardPanel, State.ConnectFailed.name)
}
}
private fun closeIO() {
val host = host
fileSystemPanel?.let { Disposer.dispose(it) }
fileSystemPanel = null
runCatching { IOUtils.closeQuietly(fileSystem) }
runCatching { IOUtils.closeQuietly(session) }
runCatching { IOUtils.closeQuietly(client) }
if (host != null && log.isInfoEnabled) {
log.info("Sftp ${host.name} is closed")
}
}
override fun dispose() {
if (isDisposed.compareAndSet(false, true)) {
closeIO()
}
}
private class ConnectingPanel : JPanel(BorderLayout()) {
private val busyLabel = JXBusyLabel()
init {
initView()
}
private fun initView() {
val formMargin = "7dlu"
val layout = FormLayout(
"default:grow, pref, default:grow",
"40dlu, pref, $formMargin, pref"
)
val label = JLabel(I18n.getString("termora.transport.sftp.connecting"))
label.horizontalAlignment = SwingConstants.CENTER
busyLabel.horizontalAlignment = SwingConstants.CENTER
busyLabel.verticalAlignment = SwingConstants.CENTER
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(busyLabel).xy(2, 2, "fill, center")
builder.add(label).xy(2, 4)
add(builder.build(), BorderLayout.CENTER)
}
fun start() {
busyLabel.isBusy = true
}
fun stop() {
busyLabel.isBusy = false
}
}
private inner class ConnectFailedPanel : JPanel(BorderLayout()) {
val errorLabel = JLabel()
init {
initView()
}
private fun initView() {
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow, pref, default:grow",
"40dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
errorLabel.horizontalAlignment = SwingConstants.CENTER
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(FlatOptionPaneErrorIcon()).xy(2, 2)
builder.add(errorLabel).xyw(1, 4, 3, "fill, center")
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.retry")) {
override fun actionPerformed(e: ActionEvent) {
connect()
}
}).apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
isFocusable = false
}).xy(2, 6)
builder.add(JXHyperlink(object :
AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) {
override fun actionPerformed(e: ActionEvent) {
state = State.Initialized
this@SftpFileSystemPanel.firePropertyChange("TabName", StringUtils.SPACE, StringUtils.EMPTY)
cardLayout.show(cardPanel, State.Initialized.name)
}
}).apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
isFocusable = false
}).xy(2, 8)
add(builder.build(), BorderLayout.CENTER)
}
}
private inner class SelectHostPanel : JPanel(BorderLayout()) {
init {
initView()
}
private fun initView() {
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow, pref, default:grow",
"40dlu, pref, $formMargin, pref, $formMargin, pref"
)
val errorInfo = JLabel(I18n.getString("termora.transport.sftp.connect-a-host"))
errorInfo.horizontalAlignment = SwingConstants.CENTER
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(FlatOptionPaneInformationIcon()).xy(2, 2)
builder.add(errorInfo).xyw(1, 4, 3, "fill, center")
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.select-host")) {
override fun actionPerformed(e: ActionEvent) {
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel))
dialog.allowMulti = false
dialog.setLocationRelativeTo(this@SelectHostPanel)
dialog.isVisible = true
this@SftpFileSystemPanel.host = dialog.hosts.firstOrNull() ?: return
connect()
}
}).apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
isFocusable = false
}).xy(2, 6)
add(builder.build(), BorderLayout.CENTER)
}
}
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.add(listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.remove(listener)
}
}

View File

@@ -0,0 +1,274 @@
package app.termora.transport
import app.termora.Disposable
import org.apache.commons.io.IOUtils
import org.apache.commons.net.io.CopyStreamEvent
import org.apache.commons.net.io.CopyStreamListener
import org.apache.commons.net.io.Util
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.slf4j.LoggerFactory
import java.nio.file.FileSystem
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import kotlin.io.path.exists
enum class TransportState {
Waiting,
Transporting,
Done,
Failed,
Cancelled,
}
abstract class Transport(
val name: String,
// 源路径
val source: Path,
// 目标路径
val target: Path,
val sourceHolder: Disposable,
val targetHolder: Disposable,
) : Disposable, Runnable {
private val listeners = ArrayList<TransportListener>()
@Volatile
var state = TransportState.Waiting
protected set(value) {
field = value
listeners.forEach { it.onTransportChanged(this) }
}
// 0 - 1
var progress = 0.0
protected set(value) {
field = value
listeners.forEach { it.onTransportChanged(this) }
}
/**
* 要传输的大小
*/
var size = -1L
protected set
/**
* 已经传输的大小
*/
var transferredSize = 0L
protected set
/**
* 传输速度
*/
open val speed get() = 0L
open val getSourcePath by lazy {
getFileSystemName(source.fileSystem) + ":" + source.toAbsolutePath().normalize().toString()
}
open val getTargetPath by lazy {
getFileSystemName(target.fileSystem) + ":" + target.toAbsolutePath().normalize().toString()
}
fun addTransportListener(listener: TransportListener) {
listeners.add(listener)
}
fun removeTransportListener(listener: TransportListener) {
listeners.remove(listener)
}
override fun run() {
if (state != TransportState.Waiting) {
throw IllegalStateException("$name has already been started")
}
state = TransportState.Transporting
}
open fun stop() {
if (state == TransportState.Waiting || state == TransportState.Transporting) {
state = TransportState.Cancelled
}
}
private fun getFileSystemName(fileSystem: FileSystem): String {
if (fileSystem is SftpFileSystem) {
val clientSession = fileSystem.session
if (clientSession is JGitClientSession) {
return clientSession.hostConfigEntry.host
}
}
return "file"
}
}
private class SlidingWindowByteCounter {
private val events = ConcurrentLinkedQueue<Pair<Long, Long>>()
private val oneSecondInMillis = TimeUnit.SECONDS.toMillis(1)
fun addBytes(bytes: Long, time: Long) {
// 添加当前事件
events.add(time to bytes)
// 移除过期事件(超过 1 秒的记录)
while (events.isNotEmpty() && events.peek().first < time - oneSecondInMillis) {
events.poll()
}
}
fun getLastSecondBytes(): Long {
val currentTime = System.currentTimeMillis()
// 累加最近 1 秒内的字节数
return events.filter { it.first >= currentTime - oneSecondInMillis }
.sumOf { it.second }
}
fun clear() {
events.clear()
}
}
/**
* 文件传输
*/
class FileTransport(
name: String, source: Path, target: Path,
sourceHolder: Disposable, targetHolder: Disposable,
) : Transport(
name, source, target, sourceHolder, targetHolder,
), CopyStreamListener {
companion object {
private val log = LoggerFactory.getLogger(FileTransport::class.java)
}
private var lastVisitTime = 0L
private val input by lazy { Files.newInputStream(source) }
private val output by lazy { Files.newOutputStream(target) }
private val counter = SlidingWindowByteCounter()
override val speed: Long
get() = counter.getLastSecondBytes()
override fun run() {
try {
super.run()
doTransport()
state = TransportState.Done
} catch (e: Exception) {
if (state == TransportState.Cancelled) {
if (log.isWarnEnabled) {
log.warn("Transport $name is canceled")
}
return
}
if (log.isErrorEnabled) {
log.error(e.message, e)
}
state = TransportState.Failed
} finally {
counter.clear()
}
}
override fun stop() {
// 如果在传输中,那么直接关闭流
if (state == TransportState.Transporting) {
runCatching { IOUtils.closeQuietly(input) }
runCatching { IOUtils.closeQuietly(output) }
}
super.stop()
counter.clear()
}
private fun doTransport() {
size = Files.size(source)
try {
Util.copyStream(
input,
output,
Util.DEFAULT_COPY_BUFFER_SIZE * 8,
size,
this
)
} finally {
IOUtils.closeQuietly(input, output)
}
}
override fun bytesTransferred(event: CopyStreamEvent?) {
throw UnsupportedOperationException()
}
override fun bytesTransferred(totalBytesTransferred: Long, bytesTransferred: Int, streamSize: Long) {
if (state == TransportState.Cancelled) {
throw IllegalStateException("$name has already been cancelled")
}
val now = System.currentTimeMillis()
val progress = totalBytesTransferred * 1.0 / streamSize
counter.addBytes(bytesTransferred.toLong(), now)
if (now - lastVisitTime < 750) {
if (progress < 1.0) {
return
}
}
this.transferredSize = totalBytesTransferred
this.progress = progress
lastVisitTime = now
}
}
/**
* 创建文件夹
*/
class DirectoryTransport(
name: String, source: Path, target: Path,
sourceHolder: Disposable,
targetHolder: Disposable,
) : Transport(name, source, target, sourceHolder, targetHolder) {
companion object {
private val log = LoggerFactory.getLogger(DirectoryTransport::class.java)
}
override fun run() {
try {
super.run()
if (!target.exists()) {
Files.createDirectory(target)
}
state = TransportState.Done
} catch (e: FileAlreadyExistsException) {
if (log.isWarnEnabled) {
log.warn("Directory $name already exists")
}
state = TransportState.Done
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
state = TransportState.Failed
}
}
}

View File

@@ -0,0 +1,20 @@
package app.termora.transport
import java.util.*
interface TransportListener : EventListener {
/**
* Added
*/
fun onTransportAdded(transport: Transport)
/**
* Removed
*/
fun onTransportRemoved(transport: Transport)
/**
* 状态变化
*/
fun onTransportChanged(transport: Transport)
}

View File

@@ -0,0 +1,130 @@
package app.termora.transport
import app.termora.Disposable
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.milliseconds
class TransportManager : Disposable {
private val transports = Collections.synchronizedList(mutableListOf<Transport>())
private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }
private val isProcessing = AtomicBoolean(false)
private val listeners = mutableListOf<TransportListener>()
private val listener = object : TransportListener {
override fun onTransportAdded(transport: Transport) {
listeners.forEach { it.onTransportAdded(transport) }
}
override fun onTransportRemoved(transport: Transport) {
listeners.forEach { it.onTransportRemoved(transport) }
}
override fun onTransportChanged(transport: Transport) {
listeners.forEach { it.onTransportChanged(transport) }
}
}
companion object {
private val log = LoggerFactory.getLogger(TransportManager::class.java)
}
fun getTransports(): List<Transport> = transports
fun addTransport(transport: Transport) {
synchronized(transports) {
transport.addTransportListener(listener)
if (transports.add(transport)) {
listeners.forEach { it.onTransportAdded(transport) }
if (isProcessing.compareAndSet(false, true)) {
coroutineScope.launch(Dispatchers.IO) { process() }
}
}
}
}
fun removeTransport(transport: Transport) {
synchronized(transports) {
transport.stop()
if (transports.remove(transport)) {
listeners.forEach { it.onTransportRemoved(transport) }
}
}
}
fun removeAllTransports() {
synchronized(transports) {
while (transports.isNotEmpty()) {
removeTransport(transports.last())
}
}
}
fun addTransportListener(listener: TransportListener) {
listeners.add(listener)
}
fun removeTransportListener(listener: TransportListener) {
listeners.remove(listener)
}
private suspend fun process() {
var needDelay = false
while (coroutineScope.isActive) {
try {
// 如果为空或者其中一个正在传输中那么挑过
if (needDelay || transports.isEmpty()) {
needDelay = false
delay(250.milliseconds)
continue
}
val transport = synchronized(transports) {
var transport: Transport? = null
for (e in transports) {
if (e.state != TransportState.Waiting) {
continue
}
// 遇到传输中,那么直接跳过
if (e.state == TransportState.Transporting) {
needDelay = true
break
}
transport = e
break
}
return@synchronized transport
}
if (transport == null) {
needDelay = true
continue
}
transport.run()
// 成功之后 删除
if (transport.state == TransportState.Done) {
// remove
removeTransport(transport)
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
override fun dispose() {
transports.clear()
coroutineScope.cancel()
}
}

View File

@@ -0,0 +1,194 @@
package app.termora.transport
import app.termora.Disposable
import app.termora.Disposer
import app.termora.DynamicColor
import app.termora.assertEventDispatchThread
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.io.File
import java.nio.file.Path
import javax.swing.BorderFactory
import javax.swing.JPanel
import javax.swing.JSplitPane
/**
* 传输面板
*/
class TransportPanel : JPanel(BorderLayout()), Disposable {
companion object {
private val log = LoggerFactory.getLogger(TransportPanel::class.java)
}
val transportManager = TransportManager()
val leftFileSystemTabbed = FileSystemTabbed(transportManager, true)
val rightFileSystemTabbed = FileSystemTabbed(transportManager, false)
private val fileTransportPanel = FileTransportPanel(transportManager)
init {
initView()
initEvents()
}
private fun initView() {
Disposer.register(this, transportManager)
Disposer.register(this, leftFileSystemTabbed)
Disposer.register(this, rightFileSystemTabbed)
Disposer.register(this, fileTransportPanel)
leftFileSystemTabbed.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
rightFileSystemTabbed.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor)
val splitPane = JSplitPane()
splitPane.leftComponent = leftFileSystemTabbed
splitPane.rightComponent = rightFileSystemTabbed
splitPane.resizeWeight = 0.5
splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
splitPane.addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
removeComponentListener(this)
splitPane.setDividerLocation(splitPane.resizeWeight)
}
})
fileTransportPanel.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
val rootSplitPane = JSplitPane()
rootSplitPane.orientation = JSplitPane.VERTICAL_SPLIT
rootSplitPane.topComponent = splitPane
rootSplitPane.bottomComponent = fileTransportPanel
rootSplitPane.resizeWeight = 0.75
rootSplitPane.addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
removeComponentListener(this)
rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight)
}
})
add(rootSplitPane, BorderLayout.CENTER)
}
@Suppress("DuplicatedCode")
private fun initEvents() {
transportManager.addTransportListener(object : TransportListener {
override fun onTransportAdded(transport: Transport) {
}
override fun onTransportRemoved(transport: Transport) {
}
override fun onTransportChanged(transport: Transport) {
if (transport.state == TransportState.Done) {
val targetHolder = transport.targetHolder
if (targetHolder is FileSystemPanel) {
if (transport.target.parent == targetHolder.workdir) {
targetHolder.reload()
}
}
}
}
})
leftFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
val target = rightFileSystemTabbed.getSelectedFileSystemPanel() ?: return
transport(
fileSystemPanel.workdir, target.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = fileSystemPanel,
targetHolder = target,
)
}
})
rightFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
val target = leftFileSystemTabbed.getSelectedFileSystemPanel() ?: return
transport(
fileSystemPanel.workdir, target.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = fileSystemPanel,
targetHolder = target,
)
}
})
}
fun getTargetFileSystemPanel(fileSystemPanel: FileSystemPanel): FileSystemPanel? {
assertEventDispatchThread()
for (i in 0 until leftFileSystemTabbed.tabCount) {
if (leftFileSystemTabbed.getFileSystemPanel(i) == fileSystemPanel) {
return rightFileSystemTabbed.getSelectedFileSystemPanel()
}
}
for (i in 0 until rightFileSystemTabbed.tabCount) {
if (rightFileSystemTabbed.getFileSystemPanel(i) == fileSystemPanel) {
return leftFileSystemTabbed.getSelectedFileSystemPanel()
}
}
return null
}
fun transport(
sourceWorkdir: Path,
targetWorkdir: Path,
isSourceDirectory: Boolean,
sourcePath: Path,
sourceHolder: Disposable,
targetHolder: Disposable
) {
val relativizePath = sourceWorkdir.relativize(sourcePath).toString()
if (StringUtils.isEmpty(relativizePath) || relativizePath == File.separator ||
relativizePath == sourceWorkdir.fileSystem.separator ||
relativizePath == targetWorkdir.fileSystem.separator
) {
return
}
val transport: Transport
if (isSourceDirectory) {
transport = DirectoryTransport(
name = sourcePath.fileName.toString(),
source = sourcePath,
target = targetWorkdir.resolve(relativizePath),
sourceHolder = sourceHolder,
targetHolder = targetHolder,
)
} else {
transport = FileTransport(
name = sourcePath.fileName.toString(),
source = sourcePath,
target = targetWorkdir.resolve(relativizePath),
sourceHolder = sourceHolder,
targetHolder = targetHolder,
)
}
transportManager.addTransport(transport)
}
override fun dispose() {
if (log.isInfoEnabled) {
log.info("Transport is disposed")
}
}
}

View File

@@ -8,6 +8,9 @@ termora.remove=Delete
termora.yes=Yes
termora.no=No
termora.date-format=MM/dd/yyyy hh:mm:ss a
termora.finder=Finder
termora.folder=Folder
termora.explorer=Explorer
# update
termora.update.title=New version
@@ -96,6 +99,7 @@ termora.find-everywhere.groups.open-new-hosts=Open a new host
termora.find-everywhere.groups.opened-hosts=Opened hosts
termora.find-everywhere.groups.tools=Tools
termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=Local Terminal
# Welcome
termora.welcome.my-hosts=My hosts
@@ -106,7 +110,7 @@ termora.welcome.contextmenu.rename=Rename
termora.welcome.contextmenu.expand-all=Expand all
termora.welcome.contextmenu.collapse-all=Collapse all
termora.welcome.contextmenu.new=New
termora.welcome.contextmenu.new.folder=Folder
termora.welcome.contextmenu.new.folder=${termora.folder}
termora.welcome.contextmenu.new.host=Host
termora.welcome.contextmenu.new.folder.name=New Folder
termora.welcome.contextmenu.property=Properties
@@ -127,6 +131,7 @@ termora.new-host.proxy=Proxy
termora.new-host.terminal=${termora.settings.terminal}
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
@@ -139,6 +144,10 @@ termora.new-host.tunneling.add=Add
termora.new-host.tunneling.edit=${termora.keymgr.edit}
termora.new-host.tunneling.delete=${termora.remove}
termora.new-host.test-connection=Test Connection
termora.new-host.test-connection-successful=Connection successful
# Key manager
termora.keymgr.title=Key Manager
@@ -161,6 +170,13 @@ termora.tabbed.contextmenu.close-other-tabs=Close Other Tabs
termora.tabbed.contextmenu.close-all-tabs=Close All Tabs
termora.tabbed.contextmenu.reconnect=Reconnect
# Terminal logger
termora.terminal-logger=Terminal Logger
termora.terminal-logger.start-recording=Start Recording
termora.terminal-logger.stop-recording=Stop Recording
termora.terminal-logger.open-log-viewer=Open Log Viewer
termora.terminal-logger.open-in-folder=Open in {0}
# Highlight
termora.highlight=Highlight Sets
@@ -182,12 +198,78 @@ termora.macro.playback=Playback
termora.macro.manager=Manage Macros
termora.macro.run=Run
# Tools
termora.tools.multiple=Send commands to multiple sessions
# Transport
termora.transport.local=Local
termora.transport.parent-folder=Parent Folder
termora.transport.file-already-exists=The file {0} already exists
termora.transport.bookmarks=Bookmarks Manager
termora.transport.bookmarks.up=Up
termora.transport.bookmarks.down=Down
termora.transport.table.filename=Filename
termora.transport.table.type=Type
termora.transport.table.type.folder=${termora.welcome.contextmenu.new.folder}
termora.transport.table.size=Size
termora.transport.table.modified-time=Modified
termora.transport.table.permissions=Permissions
termora.transport.table.owner=Owner
# contextmenu
termora.transport.table.contextmenu.transfer=Transfer
termora.transport.table.contextmenu.copy-path=Copy Path
termora.transport.table.contextmenu.open-in-folder=Open in {0}
termora.transport.table.contextmenu.rename=${termora.welcome.contextmenu.rename}
termora.transport.table.contextmenu.delete=${termora.remove}
termora.transport.table.contextmenu.delete-warning=If the folder is too large, deleting it may take some time
termora.transport.table.contextmenu.rm-warning=Using the rm -rf command to delete a file is very dangerous
termora.transport.table.contextmenu.change-permissions=Change Permissions...
termora.transport.table.contextmenu.refresh=Refresh
termora.transport.table.contextmenu.new=${termora.welcome.contextmenu.new}
termora.transport.table.contextmenu.new.folder=${termora.welcome.contextmenu.new.folder.name}
termora.transport.table.contextmenu.new.file=New File
# Permission
termora.transport.permissions=Change Permissions
termora.transport.permissions.file-folder-permissions=File/Folder Permissions
termora.transport.permissions.read=Read
termora.transport.permissions.write=Write
termora.transport.permissions.execute=Execute
termora.transport.permissions.owner=Owner
termora.transport.permissions.group=Group
termora.transport.permissions.others=Others
termora.transport.sftp.retry=Retry
termora.transport.sftp.select-another-host=Select another host
termora.transport.sftp.select-host=Select host
termora.transport.sftp.connect-a-host=Connect to a Host
termora.transport.sftp.connecting=Connecting...
termora.transport.sftp.closed=The connection has been closed
termora.transport.sftp.close-tab=Transfer is still in activated status. Are you sure you want to remove all jobs and close this session?
termora.transport.sftp.status.transporting=Transporting
termora.transport.sftp.status.waiting=Waiting
termora.transport.sftp.status.done=Done
termora.transport.sftp.status.failed=Failed
termora.transport.sftp.status.cancelled=Cancelled
# transport job
termora.transport.jobs.table.name=Name
termora.transport.jobs.table.status=Status
termora.transport.jobs.table.progress=Progress
termora.transport.jobs.table.size=Size
termora.transport.jobs.table.source-path=Source Path
termora.transport.jobs.table.target-path=Target Path
termora.transport.jobs.table.speed=Speed
termora.transport.jobs.table.estimated-time=Estimated time
termora.transport.jobs.contextmenu.delete=${termora.remove}
termora.transport.jobs.contextmenu.delete-all=Delete All
# ToolBar
termora.toolbar.customize-toolbar=Customize Toolbar...
# Terminal
termora.terminal.size=Size: {0} x {1}

View File

@@ -7,6 +7,9 @@ termora.remove=删除
termora.yes=
termora.no=
termora.date-format=yyyy-MM-dd HH:mm:ss
termora.finder=访达
termora.folder=文件夹
termora.explorer=文件管理器
# update
termora.update.title=新版本
@@ -55,6 +58,7 @@ termora.find-everywhere.groups.open-new-hosts=打开新的主机
termora.find-everywhere.groups.opened-hosts=已打开的主机
termora.find-everywhere.groups.tools=工具
termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=本地终端
termora.settings.terminal=终端
termora.settings.terminal.font=字体
@@ -118,11 +122,15 @@ termora.new-host.proxy=代理
termora.new-host.terminal=${termora.settings.terminal}
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.test-connection=测试连接
termora.new-host.test-connection-successful=连接成功
termora.new-host.tunneling=隧道
termora.new-host.tunneling.table.name=名称
termora.new-host.tunneling.table.type=类型
@@ -158,6 +166,15 @@ termora.tabbed.contextmenu.close-other-tabs=关闭其他标签页
termora.tabbed.contextmenu.close-all-tabs=关闭所有标签页
termora.tabbed.contextmenu.reconnect=重新连接
# Terminal logger
termora.terminal-logger=终端日志
termora.terminal-logger.start-recording=开始记录
termora.terminal-logger.stop-recording=停止记录
termora.terminal-logger.open-log-viewer=打开日志浏览器
termora.terminal-logger.open-in-folder=在 {0} 中打开
# Highlight
termora.highlight=关键词高亮
termora.highlight.text-color=文本颜色
@@ -180,6 +197,72 @@ termora.macro.manager=管理宏
termora.macro.run=运行
# Transport
termora.transport.local=本机
termora.transport.parent-folder=父文件夹
termora.transport.file-already-exists=文件 {0} 已存在
termora.transport.bookmarks=书签管理
termora.transport.bookmarks.up=上移
termora.transport.bookmarks.down=下移
termora.transport.table.filename=文件名
termora.transport.table.type=类型
termora.transport.table.size=大小
termora.transport.table.modified-time=修改时间
termora.transport.table.permissions=权限
termora.transport.table.owner=所有者
# contextmenu
termora.transport.table.contextmenu.transfer=传输
termora.transport.table.contextmenu.copy-path=复制路径
termora.transport.table.contextmenu.open-in-folder=在{0}中打开
termora.transport.table.contextmenu.change-permissions=更改权限...
termora.transport.table.contextmenu.refresh=刷新
termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件
termora.transport.table.contextmenu.delete-warning=如果文件夹太大,删除可能需要耗费一定时间
termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令删除文件存在很大风险
termora.transport.sftp.retry=重试
termora.transport.sftp.select-another-host=选择其他主机
termora.transport.sftp.select-host=选择主机
termora.transport.sftp.connect-a-host=连接一个主机
termora.transport.sftp.connecting=连接中...
termora.transport.sftp.closed=连接已经关闭
termora.transport.sftp.close-tab=传输还处于活动状态,是否删除所有传输任务并关闭此会话?
termora.transport.sftp.status.transporting=传输中
termora.transport.sftp.status.waiting=等待中
termora.transport.sftp.status.done=已完成
termora.transport.sftp.status.failed=已失败
termora.transport.sftp.status.cancelled=已取消
# Permission
termora.transport.permissions=更改权限
termora.transport.permissions.file-folder-permissions=文件/文件夹权限
termora.transport.permissions.read=读取
termora.transport.permissions.write=写入
termora.transport.permissions.execute=执行
termora.transport.permissions.owner=所有者
termora.transport.permissions.group=
termora.transport.permissions.others=其他
# transport job
termora.transport.jobs.table.name=名称
termora.transport.jobs.table.status=状态
termora.transport.jobs.table.progress=进度
termora.transport.jobs.table.size=大小
termora.transport.jobs.table.source-path=源路径
termora.transport.jobs.table.target-path=目标路径
termora.transport.jobs.table.speed=速度
termora.transport.jobs.table.estimated-time=剩余时间
termora.transport.jobs.contextmenu.delete-all=删除所有
# ToolBar
termora.toolbar.customize-toolbar=自定义工具栏...
termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已复制

View File

@@ -6,6 +6,9 @@ termora.remove=刪除
termora.yes=
termora.no=
termora.date-format=yyyy/MM/dd HH:mm:ss
termora.finder=訪達
termora.folder=資料夾
termora.explorer=檔案管理器
# update
termora.update.title=新版本
@@ -54,6 +57,7 @@ termora.find-everywhere.groups.open-new-hosts=開啟新的主機
termora.find-everywhere.groups.opened-hosts=已開啟的主機
termora.find-everywhere.groups.tools=工具
termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=本地端
termora.settings.terminal=終端
termora.settings.terminal.font=字體
@@ -95,7 +99,7 @@ termora.welcome.contextmenu.rename=重新命名
termora.welcome.contextmenu.expand-all=展開全部
termora.welcome.contextmenu.collapse-all=全部收縮
termora.welcome.contextmenu.new=新建
termora.welcome.contextmenu.new.folder=資料夾
termora.welcome.contextmenu.new.folder=${termora.folder}
termora.welcome.contextmenu.new.host=主機
termora.welcome.contextmenu.new.folder.name=新建資料夾
termora.welcome.contextmenu.property=屬性
@@ -116,8 +120,12 @@ termora.new-host.proxy=代理
termora.new-host.terminal=${termora.settings.terminal}
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.test-connection=測試連接
termora.new-host.test-connection-successful=連線成功
termora.new-host.tunneling=隧道
termora.new-host.tunneling.table.name=名稱
termora.new-host.tunneling.table.type=類型
@@ -153,6 +161,15 @@ termora.tabbed.contextmenu.close-all-tabs=關閉所有標籤
termora.tabbed.contextmenu.reconnect=重新連接
# Terminal logger
termora.terminal-logger=終端日誌
termora.terminal-logger.start-recording=開始記錄
termora.terminal-logger.stop-recording=停止記錄
termora.terminal-logger.open-log-viewer=開啟日誌瀏覽器
termora.terminal-logger.open-in-folder=在 {0} 中打開
# Highlight
termora.highlight=關鍵字高亮
termora.highlight.text-color=文字顏色
@@ -173,6 +190,59 @@ termora.macro.playback=回放
termora.macro.manager=管理宏
termora.macro.run=運行
# Transport
termora.transport.local=本機
termora.transport.parent-folder=父資料夾
termora.transport.file-already-exists=檔案 {0} 已存在
termora.transport.bookmarks=書籤管理
termora.transport.bookmarks.up=上移
termora.transport.bookmarks.down=下移
termora.transport.table.filename=檔名
termora.transport.table.type=類型
termora.transport.table.size=大小
termora.transport.table.modified-time=修改時間
termora.transport.table.permissions=權限
termora.transport.table.owner=所有者
# contextmenu
termora.transport.table.contextmenu.transfer=傳輸
termora.transport.table.contextmenu.copy-path=複製路徑
termora.transport.table.contextmenu.open-in-folder=在{0}中打開
termora.transport.table.contextmenu.change-permissions=更改權限...
termora.transport.table.contextmenu.refresh=刷新
termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件
termora.transport.table.contextmenu.delete-warning=如果資料夾太大,刪除可能需要耗費一定時間
termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令刪除資料存在很大風險
termora.transport.sftp.retry=重試
termora.transport.sftp.select-another-host=選擇其他主機
termora.transport.sftp.select-host=選擇主機
termora.transport.sftp.connect-a-host=連接一個主機
termora.transport.sftp.connecting=連接中...
termora.transport.sftp.closed=連線已經關閉
termora.transport.sftp.close-tab=傳輸仍處於活動狀態,是否刪除所有傳輸任務並關閉此會話?
termora.transport.sftp.status.transporting=傳輸中
termora.transport.sftp.status.waiting=等待中
termora.transport.sftp.status.done=已完成
termora.transport.sftp.status.failed=已失敗
termora.transport.sftp.status.cancelled=已取消
# transport job
termora.transport.jobs.table.name=名稱
termora.transport.jobs.table.status=狀態
termora.transport.jobs.table.progress=進度
termora.transport.jobs.table.size=大小
termora.transport.jobs.table.source-path=來源路徑
termora.transport.jobs.table.target-path=目標路徑
termora.transport.jobs.table.speed=速度
termora.transport.jobs.table.estimated-time=剩餘時間
termora.transport.jobs.contextmenu.delete-all=刪除所有
# ToolBar
termora.toolbar.customize-toolbar=自訂工具列...
termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已複製

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 12.5L13 8L8.5 3.5M3.5 12.5L8 8L3.5 3.5" stroke="#6C707E" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 12.5L13 8L8.5 3.5M3.5 12.5L8 8L3.5 3.5" stroke="#CED0D6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 3.5L3 8L7.5 12.5M12.5 3.5L8 8L12.5 12.5" stroke="#6C707E" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 3.5L3 8L7.5 12.5M12.5 3.5L8 8L12.5 12.5" stroke="#CED0D6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z" stroke="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z"
stroke="#6C707E"/>
<path d="M1.28258 1.98958L1.98969 1.28247L14.7176 14.0104L14.0105 14.7175L1.28258 1.98958Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 639 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z" stroke="#CED0D6"/>
<path d="M1.28258 1.98958L1.98969 1.28247L14.7176 14.0104L14.0105 14.7175L1.28258 1.98958Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z" stroke="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 3.25C3.91421 3.25 4.25 2.91421 4.25 2.5C4.25 2.08579 3.91421 1.75 3.5 1.75C3.08579 1.75 2.75 2.08579 2.75 2.5C2.75 2.91421 3.08579 3.25 3.5 3.25Z" fill="#6C707E"/>
<path d="M7.5 2C7.22386 2 7 2.22386 7 2.5C7 2.77614 7.22386 3 7.5 3H13.5C13.7761 3 14 2.77614 14 2.5C14 2.22386 13.7761 2 13.5 2H7.5Z" fill="#6C707E"/>
<path d="M7.5 7C7.22386 7 7 7.22386 7 7.5C7 7.77614 7.22386 8 7.5 8H13.5C13.7761 8 14 7.77614 14 7.5C14 7.22386 13.7761 7 13.5 7H7.5Z" fill="#6C707E"/>
<path d="M7.5 12C7.22386 12 7 12.2239 7 12.5C7 12.7761 7.22386 13 7.5 13H13.5C13.7761 13 14 12.7761 14 12.5C14 12.2239 13.7761 12 13.5 12H7.5Z" fill="#6C707E"/>
<path d="M3.5 8.25C3.91421 8.25 4.25 7.91421 4.25 7.5C4.25 7.08579 3.91421 6.75 3.5 6.75C3.08579 6.75 2.75 7.08579 2.75 7.5C2.75 7.91421 3.08579 8.25 3.5 8.25Z" fill="#6C707E"/>
<path d="M4.25 12.5C4.25 12.9142 3.91421 13.25 3.5 13.25C3.08579 13.25 2.75 12.9142 2.75 12.5C2.75 12.0858 3.08579 11.75 3.5 11.75C3.91421 11.75 4.25 12.0858 4.25 12.5Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 3.25C3.91421 3.25 4.25 2.91421 4.25 2.5C4.25 2.08579 3.91421 1.75 3.5 1.75C3.08579 1.75 2.75 2.08579 2.75 2.5C2.75 2.91421 3.08579 3.25 3.5 3.25Z" fill="#CED0D6"/>
<path d="M7.5 2C7.22386 2 7 2.22386 7 2.5C7 2.77614 7.22386 3 7.5 3H13.5C13.7761 3 14 2.77614 14 2.5C14 2.22386 13.7761 2 13.5 2H7.5Z" fill="#CED0D6"/>
<path d="M7.5 7C7.22386 7 7 7.22386 7 7.5C7 7.77614 7.22386 8 7.5 8H13.5C13.7761 8 14 7.77614 14 7.5C14 7.22386 13.7761 7 13.5 7H7.5Z" fill="#CED0D6"/>
<path d="M7.5 12C7.22386 12 7 12.2239 7 12.5C7 12.7761 7.22386 13 7.5 13H13.5C13.7761 13 14 12.7761 14 12.5C14 12.2239 13.7761 12 13.5 12H7.5Z" fill="#CED0D6"/>
<path d="M3.5 8.25C3.91421 8.25 4.25 7.91421 4.25 7.5C4.25 7.08579 3.91421 6.75 3.5 6.75C3.08579 6.75 2.75 7.08579 2.75 7.5C2.75 7.91421 3.08579 8.25 3.5 8.25Z" fill="#CED0D6"/>
<path d="M4.25 12.5C4.25 12.9142 3.91421 13.25 3.5 13.25C3.08579 13.25 2.75 12.9142 2.75 12.5C2.75 12.0858 3.08579 11.75 3.5 11.75C3.91421 11.75 4.25 12.0858 4.25 12.5Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,7 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="1.5" stroke="#6C707E"/>
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#6C707E"/>
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#6C707E"/>
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,7 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="1.5" stroke="#CED0D6"/>
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,9 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="1.5" width="11" height="13" rx="1.5" stroke="#6C707E"/>
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#6C707E"/>
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#6C707E"/>
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#6C707E"/>
<path d="M16 13.5C16 14.8807 14.8807 16 13.5 16C12.1193 16 11 14.8807 11 13.5C11 12.1193 12.1193 11 13.5 11C14.8807 11 16 12.1193 16 13.5Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@@ -0,0 +1,9 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="1.5" width="11" height="13" rx="1.5" stroke="#CED0D6"/>
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<path d="M16 13.5C16 14.8807 14.8807 16 13.5 16C12.1193 16 11 14.8807 11 13.5C11 12.1193 12.1193 11 13.5 11C14.8807 11 16 12.1193 16 13.5Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6.5" fill="#FFF7F7" stroke="#DB3B4B"/>
<path d="M8 4.5L8 8.5" stroke="#DB3B4B" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="8.0002" cy="10.8" r="0.5" fill="#DB3B4B" stroke="#DB3B4B" stroke-width="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6.5" fill="#402929" stroke="#DB5C5C"/>
<path d="M8 4.5L8 8.5" stroke="#DB5C5C" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="8.0002" cy="10.8" r="0.5" fill="#DB5C5C" stroke="#DB5C5C" stroke-width="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.8536 6.85355C15.0488 6.65829 15.0488 6.34171 14.8536 6.14645L11.8536 3.14645C11.6583 2.95118 11.3417 2.95118 11.1464 3.14645L8.14645 6.14645C7.95118 6.34171 7.95118 6.65829 8.14645 6.85355C8.34171 7.04882 8.65829 7.04882 8.85355 6.85355L11 4.70711V11.5C11 11.7761 11.2239 12 11.5 12C11.7761 12 12 11.7761 12 11.5V4.70711L14.1464 6.85355C14.3417 7.04882 14.6583 7.04882 14.8536 6.85355Z" fill="#6C707E"/>
<path d="M7.85355 9.14645C8.04882 9.34171 8.04882 9.65829 7.85355 9.85355L4.85355 12.8536C4.65829 13.0488 4.34171 13.0488 4.14645 12.8536L1.14645 9.85355C0.951184 9.65829 0.951184 9.34171 1.14645 9.14645C1.34171 8.95118 1.65829 8.95118 1.85355 9.14645L4 11.2929L4 4.5C4 4.22386 4.22386 4 4.5 4C4.77614 4 5 4.22386 5 4.5V11.2929L7.14645 9.14645C7.34171 8.95118 7.65829 8.95118 7.85355 9.14645Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.8536 6.85355C15.0488 6.65829 15.0488 6.34171 14.8536 6.14645L11.8536 3.14645C11.6583 2.95118 11.3417 2.95118 11.1464 3.14645L8.14645 6.14645C7.95118 6.34171 7.95118 6.65829 8.14645 6.85355C8.34171 7.04882 8.65829 7.04882 8.85355 6.85355L11 4.70711V11.5C11 11.7761 11.2239 12 11.5 12C11.7761 12 12 11.7761 12 11.5V4.70711L14.1464 6.85355C14.3417 7.04882 14.6583 7.04882 14.8536 6.85355Z" fill="#CED0D6"/>
<path d="M7.85355 9.14645C8.04882 9.34171 8.04882 9.65829 7.85355 9.85355L4.85355 12.8536C4.65829 13.0488 4.34171 13.0488 4.14645 12.8536L1.14645 9.85355C0.951184 9.65829 0.951184 9.34171 1.14645 9.14645C1.34171 8.95118 1.65829 8.95118 1.85355 9.14645L4 11.2929L4 4.5C4 4.22386 4.22386 4 4.5 4C4.77614 4 5 4.22386 5 4.5V11.2929L7.14645 9.14645C7.34171 8.95118 7.65829 8.95118 7.85355 9.14645Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -1,4 +1,5 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.10584 4.34613L8.25344 4.5H8.46667H13C13.8284 4.5 14.5 5.17157 14.5 6V12.1333C14.5 12.9529 13.932 13.5 13.3667 13.5H2.63333C2.06804 13.5 1.5 12.9529 1.5 12.1333V3.86667C1.5 3.04707 2.06804 2.5 2.63333 2.5H6.1217C6.25792 2.5 6.38824 2.55557 6.48253 2.65387L8.10584 4.34613Z" fill="#EBECF0" stroke="#6C707E"/>
<path d="M8.10584 4.34613L8.25344 4.5H8.46667H13C13.8284 4.5 14.5 5.17157 14.5 6V12.1333C14.5 12.9529 13.932 13.5 13.3667 13.5H2.63333C2.06804 13.5 1.5 12.9529 1.5 12.1333V3.86667C1.5 3.04707 2.06804 2.5 2.63333 2.5H6.1217C6.25792 2.5 6.38824 2.55557 6.48253 2.65387L8.10584 4.34613Z"
stroke="#6C707E"/>
</svg>

Before

Width:  |  Height:  |  Size: 549 B

After

Width:  |  Height:  |  Size: 548 B

View File

@@ -1,4 +1,5 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.10584 4.34613L8.25344 4.5H8.46667H13C13.8284 4.5 14.5 5.17157 14.5 6V12.1333C14.5 12.9529 13.932 13.5 13.3667 13.5H2.63333C2.06804 13.5 1.5 12.9529 1.5 12.1333V3.86667C1.5 3.04707 2.06804 2.5 2.63333 2.5H6.1217C6.25792 2.5 6.38824 2.55557 6.48253 2.65387L8.10584 4.34613Z" fill="#43454A" stroke="#CED0D6"/>
<path d="M8.10584 4.34613L8.25344 4.5H8.46667H13C13.8284 4.5 14.5 5.17157 14.5 6V12.1333C14.5 12.9529 13.932 13.5 13.3667 13.5H2.63333C2.06804 13.5 1.5 12.9529 1.5 12.1333V3.86667C1.5 3.04707 2.06804 2.5 2.63333 2.5H6.1217C6.25792 2.5 6.38824 2.55557 6.48253 2.65387L8.10584 4.34613Z"
stroke="#CED0D6"/>
</svg>

Before

Width:  |  Height:  |  Size: 549 B

After

Width:  |  Height:  |  Size: 548 B

Some files were not shown because too many files have changed in this diff Show More