Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71493e52c | ||
|
|
cb327f218c | ||
|
|
6881b6376f | ||
|
|
5027fd9dfb | ||
|
|
49cef39b8b | ||
|
|
5c4acf85e8 | ||
|
|
07bee64b7f | ||
|
|
923afb7e99 | ||
|
|
68df52bfc0 | ||
|
|
c2ee6fc8ac | ||
|
|
9d4562e7e3 | ||
|
|
5733b5f485 | ||
|
|
9dbdb5fd7a | ||
|
|
a1d1821553 | ||
|
|
4a8faea8c5 | ||
|
|
cfb841db00 | ||
|
|
a87d4ddf82 | ||
|
|
6071b251a4 | ||
|
|
950ff517bb | ||
|
|
70008978d8 | ||
|
|
7c445bdadb | ||
|
|
f24151f6d8 | ||
|
|
7d65a88d63 | ||
|
|
ed57c3e5b4 | ||
|
|
00f11c9ed5 | ||
|
|
5ebea06a95 | ||
|
|
3e5df2161b | ||
|
|
ffcb4d028e | ||
|
|
022ae402cc | ||
|
|
75f8d1de99 | ||
|
|
b9ed8258d1 | ||
|
|
6b6ceb1409 | ||
|
|
550ad85415 | ||
|
|
9d6fd7871b | ||
|
|
7f40a67c28 | ||
|
|
89fa153c1e | ||
|
|
46af9a44b2 | ||
|
|
babc440841 | ||
|
|
72057418aa | ||
|
|
568962dc41 | ||
|
|
d578d1529c | ||
|
|
fe1106658a | ||
|
|
401712c5b5 | ||
|
|
3ff6d93279 | ||
|
|
38496b9f1b | ||
|
|
db3e15508c |
@@ -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`。
|
||||
|
||||
|
||||
14
THIRDPARTY
@@ -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
|
||||
193
build.gradle.kts
@@ -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
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/sftp-zh_TW.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
docs/sftp.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}秒"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
373
src/main/kotlin/app/termora/CustomizeToolBarDialog.kt
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ data class Options(
|
||||
* 连接成功后立即发送命令
|
||||
*/
|
||||
val startupCommand: String = StringUtils.EMPTY,
|
||||
/**
|
||||
* SSH 心跳间隔
|
||||
*/
|
||||
val heartbeatInterval: Int = 30
|
||||
) {
|
||||
companion object {
|
||||
val Default = Options()
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
119
src/main/kotlin/app/termora/HostTreeDialog.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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") }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
56
src/main/kotlin/app/termora/SFTPTerminalTab.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,5 +37,15 @@ interface TerminalTab : Disposable {
|
||||
fun onLostFocus() {}
|
||||
fun onGrabFocus() {}
|
||||
|
||||
/**
|
||||
* @return 返回 false 则不可关闭
|
||||
*/
|
||||
fun canClose(): Boolean = true
|
||||
|
||||
/**
|
||||
* 是否可以克隆
|
||||
*/
|
||||
fun canClone(): Boolean = true
|
||||
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -4,4 +4,5 @@ interface TerminalTabbedManager {
|
||||
fun addTerminalTab(tab: TerminalTab)
|
||||
fun getSelectedTerminalTab(): TerminalTab?
|
||||
fun getTerminalTabs(): List<TerminalTab>
|
||||
fun setSelectedTerminalTab(tab: TerminalTab)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
175
src/main/kotlin/app/termora/TermoraToolBar.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
/**
|
||||
* 排序
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
11
src/main/kotlin/app/termora/keymgr/ByteArrayIoResource.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
57
src/main/kotlin/app/termora/tlog/LogViewerTerminal.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/main/kotlin/app/termora/tlog/LogViewerTerminalTab.kt
Normal 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
|
||||
}
|
||||
}
|
||||
110
src/main/kotlin/app/termora/tlog/TerminalLoggerAction.kt
Normal 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
|
||||
}
|
||||
}
|
||||
194
src/main/kotlin/app/termora/tlog/TerminalLoggerDataListener.kt
Normal 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
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
164
src/main/kotlin/app/termora/transport/BookmarkButton.kt
Normal 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) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
157
src/main/kotlin/app/termora/transport/BookmarksDialog.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
787
src/main/kotlin/app/termora/transport/FileSystemPanel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
205
src/main/kotlin/app/termora/transport/FileSystemTabbed.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
232
src/main/kotlin/app/termora/transport/FileSystemTableModel.kt
Normal 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) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
162
src/main/kotlin/app/termora/transport/FileTransportPanel.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
126
src/main/kotlin/app/termora/transport/FileTransportTableModel.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
20
src/main/kotlin/app/termora/transport/SFTPAction.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
321
src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
274
src/main/kotlin/app/termora/transport/Transport.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/main/kotlin/app/termora/transport/TransportListener.kt
Normal 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)
|
||||
}
|
||||
130
src/main/kotlin/app/termora/transport/TransportManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
194
src/main/kotlin/app/termora/transport/TransportPanel.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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=已复制
|
||||
|
||||
|
||||
@@ -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=已複製
|
||||
|
||||
|
||||
4
src/main/resources/icons/applyNotConflictsLeft.svg
Normal 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 |
4
src/main/resources/icons/applyNotConflictsLeft_dark.svg
Normal 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 |
4
src/main/resources/icons/applyNotConflictsRight.svg
Normal 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 |
4
src/main/resources/icons/applyNotConflictsRight_dark.svg
Normal 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 |
4
src/main/resources/icons/bookmarks.svg
Normal 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 |
6
src/main/resources/icons/bookmarksOff.svg
Normal 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 |
6
src/main/resources/icons/bookmarksOff_dark.svg
Normal 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 |
4
src/main/resources/icons/bookmarks_dark.svg
Normal 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 |
8
src/main/resources/icons/bulletList.svg
Normal 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 |
8
src/main/resources/icons/bulletList_dark.svg
Normal 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 |
7
src/main/resources/icons/changelog.svg
Normal 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 |
7
src/main/resources/icons/changelog_dark.svg
Normal 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 |
9
src/main/resources/icons/dotListFiles.svg
Normal 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 |
9
src/main/resources/icons/dotListFiles_dark.svg
Normal 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 |
6
src/main/resources/icons/errorIntroduction.svg
Normal 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 |
6
src/main/resources/icons/errorIntroduction_dark.svg
Normal 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 |
4
src/main/resources/icons/fileTransfer.svg
Normal 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 |
4
src/main/resources/icons/fileTransfer_dark.svg
Normal 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 |
@@ -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 |
@@ -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 |