Compare commits

...

29 Commits
1.0.1 ... 1.0.2

Author SHA1 Message Date
hstyi
a71493e52c release: 1.0.2 2025-01-15 13:59:35 +08:00
hstyi
cb327f218c fix: 修复 macOS 没有对二进制依赖进行签名的问题 2025-01-15 13:57:47 +08:00
hstyi
6881b6376f feat: 支持 macOS 签名以及 Windows MSI 安装包 (#71) 2025-01-14 19:03:41 +08:00
hstyi
5027fd9dfb fix: 修复 SFTP 拖拽单个文件上传失败的问题 2025-01-10 18:42:58 +08:00
hstyi
49cef39b8b fix: 修复在极端情况下可能导致部分样式错乱问题 2025-01-10 18:28:38 +08:00
hstyi
5c4acf85e8 chore: unix shell login 2025-01-10 16:36:26 +08:00
hstyi
07bee64b7f chore: 修改 SFTP 图标 2025-01-10 15:08:52 +08:00
hstyi
923afb7e99 feat: 弹窗位置以父窗口为中心 (#55) 2025-01-10 15:08:01 +08:00
hstyi
68df52bfc0 fix: 修复 Windows 切换标签页导致误输入的问题 (#54) 2025-01-10 14:40:26 +08:00
hstyi
c2ee6fc8ac feat: analytics 2025-01-10 13:28:37 +08:00
hstyi
9d4562e7e3 fix: 修复 SFTP 格式化进度可能报错的问题 2025-01-10 12:32:36 +08:00
hstyi
5733b5f485 fix: 修复在同步配置时 “宏” 没有禁用的问题 (#49) 2025-01-10 11:24:32 +08:00
hstyi
9dbdb5fd7a fix: 修复 ESC[?xm 私有模式导致不正常渲染的问题 2025-01-10 10:57:24 +08:00
hstyi
a1d1821553 feat: vim 支持鼠标滚动 2025-01-10 10:57:05 +08:00
hstyi
4a8faea8c5 chore: 在新窗口中打开时标题跟随之前的 2025-01-09 20:53:22 +08:00
hstyi
cfb841db00 feat: support Dracula 2025-01-09 20:53:07 +08:00
hstyi
a87d4ddf82 fix: 修复在 Linux 环境下无法移动窗口的问题 2025-01-09 16:07:39 +08:00
hstyi
6071b251a4 fix: 修复终端日志重复记录的问题 2025-01-09 14:58:04 +08:00
hstyi
950ff517bb fix: 修复自定义工具栏排序无效的问题 2025-01-09 14:45:40 +08:00
hstyi
70008978d8 feat: 在工具栏添加 SFTP 快速打开 2025-01-09 14:15:49 +08:00
hstyi
7c445bdadb feat: 优化自定义工具栏的存储结构 2025-01-09 14:05:53 +08:00
hstyi
f24151f6d8 feat: 支持自定义工具栏 2025-01-09 13:11:46 +08:00
hstyi
7d65a88d63 fix: 修复在开发环境 “设置 - 关于” 页面地址 404 问题 2025-01-08 17:45:46 +08:00
hstyi
ed57c3e5b4 chore: 改进 SFTP 传输进度 2025-01-08 17:43:57 +08:00
hstyi
00f11c9ed5 feat: 添加非 macOS 系统下的 复制/粘贴 快捷键 (#31) 2025-01-08 15:10:02 +08:00
hstyi
5ebea06a95 feat: 当停止记录终端日志的时候立即关闭文件流 2025-01-08 11:46:40 +08:00
hstyi
3e5df2161b feat: support keyboard-interactive 2025-01-07 23:12:39 +08:00
hstyi
ffcb4d028e feat: 支持 OSC 52 指令 2025-01-07 23:12:07 +08:00
hstyi
022ae402cc feat: 支持终端日志记录 (#7) 2025-01-07 17:43:59 +08:00
69 changed files with 1951 additions and 202 deletions

View File

@@ -46,6 +46,10 @@ flatlaf 3.5.4
Apache License 2.0 Apache License 2.0
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE 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 flatlaf-extras 3.5.4
Apache License 2.0 Apache License 2.0
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
@@ -229,3 +233,11 @@ https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
jediterm jediterm
Apache License 2.0 Apache License 2.0
https://github.com/JetBrains/jediterm/blob/master/LICENSE-APACHE-2.0.txt https://github.com/JetBrains/jediterm/blob/master/LICENSE-APACHE-2.0.txt
mixpanel-java 1.5.3
Apache License 2.0
https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
json-20231013
Public Domain.
https://github.com/stleary/JSON-java/blob/master/LICENSE

View File

@@ -1,9 +1,9 @@
import org.gradle.internal.jvm.Jvm import org.gradle.internal.jvm.Jvm
import org.gradle.kotlin.dsl.support.uppercaseFirstChar 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.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 org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
import java.nio.file.Files
plugins { plugins {
java java
@@ -14,11 +14,20 @@ plugins {
group = "app.termora" group = "app.termora"
version = "1.0.1" version = "1.0.2"
val os: DefaultOperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
var arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture() 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 { repositories {
mavenCentral() mavenCentral()
@@ -27,6 +36,9 @@ repositories {
} }
dependencies { dependencies {
// 由于签名和公证macOS 不携带 natives
val useNoNativesFlatLaf = os.isMacOsX && macOSNotary && System.getenv("ENABLE_BUILD").toBoolean()
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
testImplementation(libs.hutool) testImplementation(libs.hutool)
testImplementation(libs.sshj) testImplementation(libs.sshj)
@@ -50,9 +62,25 @@ dependencies {
implementation(libs.commons.compress) implementation(libs.commons.compress)
implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.coroutines.swing)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.flatlaf)
implementation(libs.flatlaf.extras) implementation(libs.flatlaf) {
implementation(libs.flatlaf.swingx) 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.kotlinx.serialization.json)
implementation(libs.swingx) implementation(libs.swingx)
implementation(libs.jgoodies.forms) implementation(libs.jgoodies.forms)
@@ -75,6 +103,7 @@ dependencies {
implementation(libs.xodus.environment) implementation(libs.xodus.environment)
implementation(libs.bip39) implementation(libs.bip39)
implementation(libs.colorpicker) implementation(libs.colorpicker)
implementation(libs.mixpanel)
} }
application { application {
@@ -103,8 +132,53 @@ tasks.test {
} }
tasks.register<Copy>("copy-dependencies") { tasks.register<Copy>("copy-dependencies") {
from(configurations.runtimeClasspath) val dir = layout.buildDirectory.dir("libs")
.into("${layout.buildDirectory.get()}/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") { tasks.register<Exec>("jlink") {
@@ -136,6 +210,7 @@ tasks.register<Exec>("jlink") {
} }
tasks.register<Exec>("jpackage") { tasks.register<Exec>("jpackage") {
val buildDir = layout.buildDirectory.get() val buildDir = layout.buildDirectory.get()
val options = mutableListOf( val options = mutableListOf(
"--add-exports java.base/sun.nio.ch=ALL-UNNAMED", "--add-exports java.base/sun.nio.ch=ALL-UNNAMED",
@@ -164,6 +239,9 @@ tasks.register<Exec>("jpackage") {
arguments.addAll(listOf("--temp", "$buildDir/jpackage")) arguments.addAll(listOf("--temp", "$buildDir/jpackage"))
arguments.addAll(listOf("--dest", "$buildDir/distributions")) arguments.addAll(listOf("--dest", "$buildDir/distributions"))
arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE))) 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) { if (os.isMacOsX) {
@@ -193,6 +271,12 @@ tasks.register<Exec>("jpackage") {
throw UnsupportedOperationException() throw UnsupportedOperationException()
} }
if (os.isMacOsX && macOSSign) {
arguments.add("--mac-sign")
arguments.add("--mac-signing-key-user-name")
arguments.add(macOSSignUsername)
}
commandLine(arguments) commandLine(arguments)
} }
@@ -207,12 +291,18 @@ tasks.register("dist") {
val distributionDir = layout.buildDirectory.dir("distributions").get() val distributionDir = layout.buildDirectory.dir("distributions").get()
val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath 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, "clean") }
// 打包并复制依赖 // 打包并复制依赖
exec { commandLine(gradlew, "jar", "copy-dependencies") } exec {
commandLine(gradlew, "jar", "copy-dependencies")
environment("ENABLE_BUILD" to true)
}
// 检查依赖的开源协议 // 检查依赖的开源协议
exec { commandLine(gradlew, "check-license") } exec { commandLine(gradlew, "check-license") }
@@ -224,30 +314,65 @@ tasks.register("dist") {
exec { commandLine(gradlew, "jpackage") } exec { commandLine(gradlew, "jpackage") }
// pack // pack
if (os.isWindows) { // zip and msi
// zip
exec { exec {
if (os.isWindows) { // zip
commandLine( commandLine(
"tar", "-vacf", "tar", "-vacf",
distributionDir.file("${project.name}-${project.version}-windows-${arch.name}.zip").asFile.absolutePath, distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile.absolutePath,
project.name.uppercaseFirstChar() project.name.uppercaseFirstChar()
) )
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
}
// msi
exec {
commandLine(
"cmd", "/c", "move",
"${project.name.uppercaseFirstChar()}-${project.version}.msi",
"${finalFilenameWithoutExtension}.msi"
)
workingDir = distributionDir.asFile
}
} else if (os.isLinux) { // tar.gz } else if (os.isLinux) { // tar.gz
exec {
commandLine( commandLine(
"tar", "-czvf", "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() project.name.uppercaseFirstChar()
) )
workingDir = distributionDir.asFile workingDir = distributionDir.asFile
}
} else if (os.isMacOsX) { // rename } else if (os.isMacOsX) { // rename
exec {
commandLine( commandLine(
"mv", "mv",
distributionDir.file("${project.name.uppercaseFirstChar()}-${project.version}.dmg").asFile.absolutePath, distributionDir.file("${project.name.uppercaseFirstChar()}-${project.version}.dmg").asFile.absolutePath,
distributionDir.file("${project.name}-${project.version}-osx-${arch.name}.dmg").asFile.absolutePath, macOSFinalFilePath,
) )
}
} else { } else {
throw GradleException("${os.name} is not supported") 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",
)
}
}
} }
} }
} }
@@ -271,21 +396,29 @@ tasks.register("check-license") {
thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license
thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first()) thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first())
} }
}
}
for (file in configurations.runtimeClasspath.get()) { /**
val name = file.nameWithoutExtension * macOS 对本地文件进行签名
if (!thirdParty.containsKey(name)) { */
if (logger.isWarnEnabled) { fun signMacOSLocalFile(file: File) {
logger.warn("$name does not exist in third-party") if (os.isMacOsX && macOSSign) {
} if (file.exists() && file.isFile) {
if (!thirdPartyNames.contains(name)) { exec {
throw GradleException("$name No license found") commandLine(
} "/usr/bin/codesign",
"-s", macOSSignUsername,
"--timestamp", "--force",
"-vvvv", "--options", "runtime",
file.absolutePath,
)
} }
} }
} }
} }
kotlin { kotlin {
jvmToolchain { jvmToolchain {
languageVersion = JavaLanguageVersion.of(21) languageVersion = JavaLanguageVersion.of(21)

View File

@@ -40,6 +40,7 @@ colorpicker = "2.0.1"
rhino = "1.7.15" rhino = "1.7.15"
delight-rhino-sandbox = "0.0.17" delight-rhino-sandbox = "0.0.17"
testcontainers = "1.20.4" testcontainers = "1.20.4"
mixpanel = "1.5.3"
[libraries] [libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
@@ -95,6 +96,7 @@ bip39 = { module = "cash.z.ecc.android:kotlin-bip39-jvm", version.ref = "bip39"
rhino = { module = "org.mozilla:rhino", version.ref = "rhino" } rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" } delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" } colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
[plugins] [plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,9 @@ package app.termora
object Icons { object Icons {
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") } 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 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 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 close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_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") } val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") }
@@ -14,6 +16,7 @@ object Icons {
val settings by lazy { DynamicIcon("icons/settings.svg", "icons/settings_dark.svg") } 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 pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") }
val empty by lazy { DynamicIcon("icons/empty.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 add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_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 networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
@@ -47,11 +50,11 @@ object Icons {
val google by lazy { DynamicIcon("icons/google-small.svg") } val google by lazy { DynamicIcon("icons/google-small.svg") }
val aliyun by lazy { DynamicIcon("icons/aliyun.svg") } val aliyun by lazy { DynamicIcon("icons/aliyun.svg") }
val yandexCloud by lazy { DynamicIcon("icons/yandexCloud.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 huawei by lazy { DynamicIcon("icons/huawei.svg") }
val baidu by lazy { DynamicIcon("icons/baiduyun.svg") } val baidu by lazy { DynamicIcon("icons/baiduyun.svg") }
val tianyi by lazy { DynamicIcon("icons/tianyiyun.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 terminalUnread by lazy { DynamicIcon("icons/terminalUnread.svg", "icons/terminalUnread_dark.svg") }
val dbPrimitive by lazy { DynamicIcon("icons/dbPrimitive.svg", "icons/dbPrimitive_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") } val linux by lazy { DynamicIcon("icons/linux.svg", "icons/linux_dark.svg") }
@@ -73,8 +76,23 @@ object Icons {
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_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 folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_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 fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_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 expand by lazy { DynamicIcon("icons/expand.svg", "icons/expand_dark.svg") }
val collapse by lazy { DynamicIcon("icons/collapse.svg", "icons/collapse_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") } val expandAll by lazy { DynamicIcon("icons/expandAll.svg", "icons/expandAll_dark.svg") }

View File

@@ -8,6 +8,51 @@ import com.formdev.flatlaf.FlatPropertiesLaf
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import java.util.* 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 { class LightLaf : FlatLightLaf(), ColorTheme {
override fun getColor(color: TerminalColor): Int { override fun getColor(color: TerminalColor): Int {
@@ -163,7 +208,7 @@ class TermiusDarkLaf : FlatPropertiesLaf("Termius Dark", Properties().apply {
TerminalColor.Basic.SELECTION_BACKGROUND, TerminalColor.Basic.SELECTION_BACKGROUND,
TerminalColor.Cursor.BACKGROUND -> 0x21b568 TerminalColor.Cursor.BACKGROUND -> 0x21b568
TerminalColor.Basic.SELECTION_FOREGROUND ->0 TerminalColor.Basic.SELECTION_FOREGROUND -> 0
TerminalColor.Basic.FOREGROUND -> 0x21b568 TerminalColor.Basic.FOREGROUND -> 0x21b568

View File

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

View File

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

View File

@@ -30,7 +30,12 @@ class PtyConnectorFactory {
envs.putAll(env) envs.putAll(env)
val command = database.terminal.localShell 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) .setEnvironment(envs)
.setInitialRows(rows) .setInitialRows(rows)
.setInitialColumns(cols) .setInitialColumns(cols)

View File

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

View File

@@ -20,7 +20,7 @@ class SFTPTerminalTab : Disposable, TerminalTab {
} }
override fun getIcon(): Icon { override fun getIcon(): Icon {
return Icons.fileTransfer return Icons.folder
} }
override fun addPropertyChangeListener(listener: PropertyChangeListener) { override fun addPropertyChangeListener(listener: PropertyChangeListener) {
@@ -34,6 +34,9 @@ class SFTPTerminalTab : Disposable, TerminalTab {
return transportPanel return transportPanel
} }
override fun canClone(): Boolean {
return false
}
override fun canClose(): Boolean { override fun canClose(): Boolean {
assertEventDispatchThread() assertEventDispatchThread()

View File

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

View File

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

View File

@@ -64,9 +64,12 @@ object SshClients {
} else if (host.authentication.type == AuthenticationType.PublicKey) { } else if (host.authentication.type == AuthenticationType.PublicKey) {
session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password) 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") throw SshException("Authentication failed")
} }
return session return session
} }

View File

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

View File

@@ -42,5 +42,10 @@ interface TerminalTab : Disposable {
*/ */
fun canClose(): Boolean = true fun canClose(): Boolean = true
/**
* 是否可以克隆
*/
fun canClone(): Boolean = true
} }

View File

@@ -4,30 +4,25 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhere import app.termora.findeverywhere.FindEverywhere
import app.termora.findeverywhere.FindEverywhereProvider import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult import app.termora.findeverywhere.FindEverywhereResult
import app.termora.transport.TransportPanel
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatTabbedPane 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 org.jdesktop.swingx.action.ActionManager
import java.awt.BorderLayout import java.awt.*
import java.awt.Component import java.awt.event.*
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.beans.PropertyChangeListener import java.beans.PropertyChangeListener
import javax.swing.* import javax.swing.*
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
import kotlin.math.min import kotlin.math.min
class TerminalTabbed( class TerminalTabbed(
private val toolbar: JToolBar, private val termoraToolBar: TermoraToolBar,
private val tabbedPane: FlatTabbedPane, private val tabbedPane: FlatTabbedPane,
) : JPanel(BorderLayout()), Disposable, TerminalTabbedManager { ) : JPanel(BorderLayout()), Disposable, TerminalTabbedManager {
private val tabs = mutableListOf<TerminalTab>() private val tabs = mutableListOf<TerminalTab>()
private val customizeToolBarAWTEventListener = CustomizeToolBarAWTEventListener()
private val toolbar = termoraToolBar.getJToolBar()
private val iconListener = PropertyChangeListener { e -> private val iconListener = PropertyChangeListener { e ->
val source = e.source val source = e.source
@@ -53,33 +48,6 @@ class TerminalTabbed(
tabbedPane.styleMap = mapOf( tabbedPane.styleMap = mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground") "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 tabbedPane.trailingComponent = toolbar
add(tabbedPane, BorderLayout.CENTER) add(tabbedPane, BorderLayout.CENTER)
@@ -92,8 +60,7 @@ class TerminalTabbed(
tabbedPane.setTabCloseCallback { _, i -> removeTabAt(i, true) } tabbedPane.setTabCloseCallback { _, i -> removeTabAt(i, true) }
// 选中变动 // 选中变动
tabbedPane.addPropertyChangeListener("selectedIndex", object : PropertyChangeListener { tabbedPane.addPropertyChangeListener("selectedIndex") { evt ->
override fun propertyChange(evt: PropertyChangeEvent) {
val oldIndex = evt.oldValue as Int val oldIndex = evt.oldValue as Int
val newIndex = evt.newValue as Int val newIndex = evt.newValue as Int
if (oldIndex >= 0 && tabs.size > newIndex) { if (oldIndex >= 0 && tabs.size > newIndex) {
@@ -103,7 +70,6 @@ class TerminalTabbed(
tabs[newIndex].onGrabFocus() tabs[newIndex].onGrabFocus()
} }
} }
})
// 选择变动 // 选择变动
tabbedPane.addChangeListener { tabbedPane.addChangeListener {
@@ -174,7 +140,8 @@ class TerminalTabbed(
override fun find(pattern: String): List<FindEverywhereResult> { override fun find(pattern: String): List<FindEverywhereResult> {
val results = mutableListOf<FindEverywhereResult>() val results = mutableListOf<FindEverywhereResult>()
for (i in 0 until tabbedPane.tabCount) { 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 continue
} }
results.add( results.add(
@@ -208,6 +175,9 @@ class TerminalTabbed(
} }
}) })
// 监听全局事件
toolkit.addAWTEventListener(customizeToolBarAWTEventListener, AWTEvent.MOUSE_EVENT_MASK)
} }
private fun removeTabAt(index: Int, disposable: Boolean = true) { private fun removeTabAt(index: Int, disposable: Boolean = true) {
@@ -248,6 +218,7 @@ class TerminalTabbed(
private fun showContextMenu(tabIndex: Int, e: MouseEvent) { private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
val c = tabbedPane.getComponentAt(tabIndex) as JComponent val c = tabbedPane.getComponentAt(tabIndex) as JComponent
val tab = tabs[tabIndex]
val popupMenu = FlatPopupMenu() val popupMenu = FlatPopupMenu()
@@ -272,29 +243,26 @@ class TerminalTabbed(
// 克隆 // 克隆
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone")) val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
clone.addActionListener { clone.addActionListener {
val index = tabbedPane.selectedIndex
if (index > 0) {
val tab = tabs[index]
if (tab is HostTerminalTab) { if (tab is HostTerminalTab) {
ActionManager.getInstance() ActionManager.getInstance()
.getAction(Actions.OPEN_HOST) .getAction(Actions.OPEN_HOST)
.actionPerformed(OpenHostActionEvent(this, tab.host)) .actionPerformed(OpenHostActionEvent(this, tab.host))
} }
} }
}
// 在新窗口中打开 // 在新窗口中打开
val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window")) val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window"))
openInNewWindow.addActionListener { openInNewWindow.addActionListener {
val index = tabbedPane.selectedIndex val index = tabbedPane.selectedIndex
if (index > 0) { if (index > 0) {
val tab = tabs[index] val title = tabbedPane.getTitleAt(index)
removeTabAt(index, false) removeTabAt(index, false)
val dialog = TerminalTabDialog( val dialog = TerminalTabDialog(
owner = SwingUtilities.getWindowAncestor(this), owner = SwingUtilities.getWindowAncestor(this),
terminalTab = tab, terminalTab = tab,
size = Dimension(min(size.width, 1280), min(size.height, 800)) size = Dimension(min(size.width, 1280), min(size.height, 800))
) )
dialog.title = title
Disposer.register(dialog, tab) Disposer.register(dialog, tab)
Disposer.register(this, dialog) Disposer.register(this, dialog)
dialog.isVisible = true dialog.isVisible = true
@@ -332,12 +300,11 @@ class TerminalTabbed(
clone.isEnabled = close.isEnabled clone.isEnabled = close.isEnabled
openInNewWindow.isEnabled = close.isEnabled openInNewWindow.isEnabled = close.isEnabled
// SFTP不允许克隆 // 如果不允许克隆
if (clone.isEnabled && getSelectedTerminalTab() is SFTPTerminalTab) { if (clone.isEnabled && !tab.canClone()) {
clone.isEnabled = false clone.isEnabled = false
} }
if (close.isEnabled) { if (close.isEnabled) {
popupMenu.addSeparator() popupMenu.addSeparator()
val reconnect = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.reconnect")) val reconnect = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.reconnect"))
@@ -370,6 +337,64 @@ class TerminalTabbed(
Disposer.register(this, tab) 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 inner class SwitchFindEverywhereResult(
private val title: String, private val title: String,
private val icon: Icon?, private val icon: Icon?,

View File

@@ -4,6 +4,8 @@ import app.termora.findeverywhere.FindEverywhere
import app.termora.highlight.KeywordHighlightDialog import app.termora.highlight.KeywordHighlightDialog
import app.termora.keymgr.KeyManagerDialog import app.termora.keymgr.KeyManagerDialog
import app.termora.macro.MacroAction import app.termora.macro.MacroAction
import app.termora.tlog.TerminalLoggerAction
import app.termora.transport.SFTPAction
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatDesktop import com.formdev.flatlaf.extras.FlatDesktop
@@ -43,12 +45,12 @@ class TermoraFrame : JFrame() {
private val log = LoggerFactory.getLogger(TermoraFrame::class.java) private val log = LoggerFactory.getLogger(TermoraFrame::class.java)
} }
private val toolbar = JToolBar() private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
private val tabbedPane = MyTabbedPane() private val tabbedPane = MyTabbedPane()
private val toolbar = TermoraToolBar(titleBar, tabbedPane)
private lateinit var terminalTabbed: TerminalTabbed private lateinit var terminalTabbed: TerminalTabbed
private val disposable = Disposer.newDisposable() private val disposable = Disposer.newDisposable()
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() } private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
private val updaterManager get() = UpdaterManager.instance private val updaterManager get() = UpdaterManager.instance
private val preferencesHandler = object : Runnable { private val preferencesHandler = object : Runnable {
@@ -66,6 +68,7 @@ class TermoraFrame : JFrame() {
FlatDesktop.setPreferencesHandler(that) FlatDesktop.setPreferencesHandler(that)
} }
}) })
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true dialog.isVisible = true
} }
} }
@@ -80,38 +83,6 @@ class TermoraFrame : JFrame() {
private fun initEvents() { 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() forceHitTest()
// macos 需要判断是否全部删除 // macos 需要判断是否全部删除
@@ -209,7 +180,7 @@ class TermoraFrame : JFrame() {
// Keyword Highlight // Keyword Highlight
ActionManager.getInstance().addAction(Actions.KEYWORD_HIGHLIGHT_EVERYWHERE, object : AnAction( ActionManager.getInstance().addAction(Actions.KEYWORD_HIGHLIGHT, object : AnAction(
I18n.getString("termora.highlight"), I18n.getString("termora.highlight"),
Icons.edit Icons.edit
) { ) {
@@ -233,6 +204,12 @@ class TermoraFrame : JFrame() {
} }
}) })
// 终端日志记录
ActionManager.getInstance().addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
// SFTP
ActionManager.getInstance().addAction(Actions.SFTP, SFTPAction())
// macro // macro
ActionManager.getInstance().addAction(Actions.MACRO, MacroAction()) ActionManager.getInstance().addAction(Actions.MACRO, MacroAction())
@@ -246,7 +223,9 @@ class TermoraFrame : JFrame() {
val focusWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow val focusWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
val frame = this@TermoraFrame val frame = this@TermoraFrame
if (focusWindow == frame) { if (focusWindow == frame) {
FindEverywhere(frame).isVisible = true val dialog = FindEverywhere(frame)
dialog.setLocationRelativeTo(frame)
dialog.isVisible = true
} }
} }
} }
@@ -405,7 +384,7 @@ class TermoraFrame : JFrame() {
} }
override fun mousePressed(e: MouseEvent) { override fun mousePressed(e: MouseEvent) {
if (e.source == toolbar) { if (e.source == toolbar.getJToolBar()) {
if (!isWindowDecorationsSupported && SwingUtilities.isLeftMouseButton(e)) { if (!isWindowDecorationsSupported && SwingUtilities.isLeftMouseButton(e)) {
if (JBR.isWindowMoveSupported()) { if (JBR.isWindowMoveSupported()) {
JBR.getWindowMove().startMovingTogetherWithMouse(this@TermoraFrame, e.button) JBR.getWindowMove().startMovingTogetherWithMouse(this@TermoraFrame, e.button)
@@ -440,8 +419,8 @@ class TermoraFrame : JFrame() {
tabbedPane.addMouseListener(mouseAdapter) tabbedPane.addMouseListener(mouseAdapter)
tabbedPane.addMouseMotionListener(mouseAdapter) tabbedPane.addMouseMotionListener(mouseAdapter)
toolbar.addMouseListener(mouseAdapter) toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.addMouseMotionListener(mouseAdapter) toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
} }
private fun initDesktopHandler() { private fun initDesktopHandler() {

View File

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

View File

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

View File

@@ -5,7 +5,12 @@ import app.termora.I18n
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
class QuickActionsFindEverywhereProvider : FindEverywhereProvider { 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> { override fun find(pattern: String): List<FindEverywhereResult> {
val actionManager = ActionManager.getInstance() val actionManager = ActionManager.getInstance()
return actions return actions

View File

@@ -7,11 +7,11 @@ import java.awt.event.ActionEvent
import javax.swing.Icon import javax.swing.Icon
class QuickCommandFindEverywhereProvider : FindEverywhereProvider { class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
private val actionManager get() = ActionManager.getInstance()
override fun find(pattern: String): List<FindEverywhereResult> { override fun find(pattern: String): List<FindEverywhereResult> {
val list = mutableListOf<FindEverywhereResult>() val list = mutableListOf<FindEverywhereResult>()
ActionManager.getInstance().getAction(Actions.ADD_HOST)?.let { actionManager?.let {
list.add(CreateHostFindEverywhereResult()) list.add(CreateHostFindEverywhereResult())
} }
@@ -21,8 +21,7 @@ class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
Icons.terminal Icons.terminal
) { ) {
override fun actionPerformed(evt: ActionEvent) { override fun actionPerformed(evt: ActionEvent) {
ActionManager.getInstance().getAction(Actions.OPEN_HOST) actionManager.getAction(Actions.OPEN_HOST)?.actionPerformed(
?.actionPerformed(
OpenHostActionEvent( OpenHostActionEvent(
this, Host( this, Host(
name = name, name = name,
@@ -34,21 +33,9 @@ class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
})) }))
// SFTP // SFTP
list.add(ActionFindEverywhereResult(object : AnAction("SFTP", Icons.fileTransfer) { actionManager.getAction(Actions.SFTP)?.let {
override fun actionPerformed(evt: ActionEvent) { list.add(ActionFindEverywhereResult(it))
val terminalTabbedManager = Application.getService(TerminalTabbedManager::class)
val tabs = terminalTabbedManager.getTerminalTabs()
for (i in tabs.indices) {
val tab = tabs[i]
if (tab is SFTPTerminalTab) {
terminalTabbedManager.setSelectedTerminalTab(tab)
return
} }
}
// 创建一个新的
terminalTabbedManager.addTerminalTab(SFTPTerminalTab())
}
}))
return list return list
} }

View File

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

View File

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

View File

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

View File

@@ -769,6 +769,11 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
args.append("0") args.append("0")
} else if (args.startsWithMoreMark()) { } else if (args.startsWithMoreMark()) {
return return
} else if (args.startsWithQuestionMark()) {
if (log.isWarnEnabled) {
log.warn("ignore SGR: {}", args)
}
return
} }
val iterator = args.controlSequences().iterator() val iterator = args.controlSequences().iterator()

View File

@@ -1,6 +1,9 @@
package app.termora.terminal package app.termora.terminal
import org.apache.commons.codec.binary.Base64
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader) : class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader) :
AbstractProcessor(terminal, reader) { AbstractProcessor(terminal, reader) {
@@ -95,6 +98,25 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
replyColor(mode, terminalColor) 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 -> { else -> {
if (log.isWarnEnabled) { if (log.isWarnEnabled) {
log.warn("Unknown OSC: $prefix") log.warn("Unknown OSC: $prefix")

View File

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

View File

@@ -7,12 +7,11 @@ import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException import java.awt.datatransfer.UnsupportedFlavorException
import java.awt.event.InputEvent
import java.awt.event.KeyEvent import java.awt.event.KeyEvent
import javax.swing.KeyStroke import javax.swing.KeyStroke
class TerminalCopyAction(private val terminalPanel: TerminalPanel) : TerminalAction( class TerminalCopyAction(private val terminalPanel: TerminalPanel) : TerminalPredicateAction {
KeyStroke.getKeyStroke(KeyEvent.VK_C, terminalPanel.toolkit.menuShortcutKeyMaskEx)
) {
companion object { companion object {
private val log = LoggerFactory.getLogger(TerminalCopyAction::class.java) 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 { override fun test(keyStroke: KeyStroke, e: KeyEvent): Boolean {
if (!SystemInfo.isMacOS) { if (SystemInfo.isMacOS) {
return false 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 { private class EmptyTransferable : Transferable {

View File

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

View File

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

View File

@@ -65,10 +65,11 @@ class TerminalPanelMouseTrackingAdapter(
} }
override fun mouseWheelMoved(e: MouseWheelEvent) { override fun mouseWheelMoved(e: MouseWheelEvent) {
if (shouldSendMouseData) { if (this.shouldSendMouseData || terminalModel.isAlternateScreenBuffer()) {
val unitsToScroll = e.unitsToScroll val unitsToScroll = e.unitsToScroll
val encode = terminal.getKeyEncoder() val encode = terminal.getKeyEncoder()
.encode(TerminalKeyEvent(if (e.wheelRotation < 0) KeyEvent.VK_UP else KeyEvent.VK_DOWN)) .encode(TerminalKeyEvent(if (e.wheelRotation < 0) KeyEvent.VK_UP else KeyEvent.VK_DOWN))
if (encode.isBlank()) return
for (i in 0 until abs(unitsToScroll)) { for (i in 0 until abs(unitsToScroll)) {
ptyConnector.write(encode) ptyConnector.write(encode)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -239,7 +239,7 @@ class FileSystemPanel(
} }
} else { } else {
transportPanel.transport( transportPanel.transport(
sourceWorkdir = localFileSystemPanel.workdir, sourceWorkdir = path.path.parent,
targetWorkdir = workdir, targetWorkdir = workdir,
isSourceDirectory = false, isSourceDirectory = false,
sourcePath = path.path, sourcePath = path.path,

View File

@@ -74,11 +74,14 @@ class FileTransportTableModel(transportManager: TransportManager) : DefaultTable
val speed = if (isTransporting) transport.speed else 0 val speed = if (isTransporting) transport.speed else 0
val estimatedTime = if (isTransporting && speed > 0) val estimatedTime = if (isTransporting && speed > 0)
(transport.size - transport.transferredSize) / speed else 0 (transport.size - transport.transferredSize) / speed else 0
val progress = transport.progress * 100.0
return when (column) { return when (column) {
COLUMN_NAME -> " ${transport.name}" COLUMN_NAME -> " ${transport.name}"
COLUMN_STATUS -> formatStatus(transport.state) COLUMN_STATUS -> formatStatus(transport.state)
COLUMN_PROGRESS -> String.format("%.0f%%", transport.progress * 100.0)
// 如果进度已经完成但是状态还是传输中那么进度显示99%
COLUMN_PROGRESS -> String.format("%.0f%%", if (progress >= 100.0 && isTransporting) 99.0 else progress)
// 大小 // 大小
COLUMN_SIZE -> if (transport.size < 0) "-" COLUMN_SIZE -> if (transport.size < 0) "-"

View File

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

View File

@@ -1,6 +1,7 @@
package app.termora.transport package app.termora.transport
import app.termora.* import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
@@ -113,6 +114,10 @@ class SftpFileSystemPanel(
try { try {
val client = SshClients.openClient(host).apply { client = this } 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 } val session = SshClients.openSession(host, client).apply { session = this }
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session) fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
session.addCloseFutureListener { onClose() } session.addCloseFutureListener { onClose() }

View File

@@ -102,6 +102,7 @@ class TransportManager : Disposable {
} }
if (transport == null) { if (transport == null) {
needDelay = true
continue continue
} }

View File

@@ -170,6 +170,13 @@ termora.tabbed.contextmenu.close-other-tabs=Close Other Tabs
termora.tabbed.contextmenu.close-all-tabs=Close All Tabs termora.tabbed.contextmenu.close-all-tabs=Close All Tabs
termora.tabbed.contextmenu.reconnect=Reconnect 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 # Highlight
termora.highlight=Highlight Sets termora.highlight=Highlight Sets
@@ -261,7 +268,8 @@ termora.transport.jobs.table.estimated-time=Estimated time
termora.transport.jobs.contextmenu.delete=${termora.remove} termora.transport.jobs.contextmenu.delete=${termora.remove}
termora.transport.jobs.contextmenu.delete-all=Delete All termora.transport.jobs.contextmenu.delete-all=Delete All
# ToolBar
termora.toolbar.customize-toolbar=Customize Toolbar...
# Terminal # Terminal
termora.terminal.size=Size: {0} x {1} termora.terminal.size=Size: {0} x {1}

View File

@@ -166,6 +166,15 @@ termora.tabbed.contextmenu.close-other-tabs=关闭其他标签页
termora.tabbed.contextmenu.close-all-tabs=关闭所有标签页 termora.tabbed.contextmenu.close-all-tabs=关闭所有标签页
termora.tabbed.contextmenu.reconnect=重新连接 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 # Highlight
termora.highlight=关键词高亮 termora.highlight=关键词高亮
termora.highlight.text-color=文本颜色 termora.highlight.text-color=文本颜色
@@ -251,6 +260,8 @@ termora.transport.jobs.table.speed=速度
termora.transport.jobs.table.estimated-time=剩余时间 termora.transport.jobs.table.estimated-time=剩余时间
termora.transport.jobs.contextmenu.delete-all=删除所有 termora.transport.jobs.contextmenu.delete-all=删除所有
# ToolBar
termora.toolbar.customize-toolbar=自定义工具栏...
termora.terminal.size=大小: {0} x {1} termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已复制 termora.terminal.copied=已复制

View File

@@ -161,6 +161,15 @@ termora.tabbed.contextmenu.close-all-tabs=關閉所有標籤
termora.tabbed.contextmenu.reconnect=重新連接 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 # Highlight
termora.highlight=關鍵字高亮 termora.highlight=關鍵字高亮
termora.highlight.text-color=文字顏色 termora.highlight.text-color=文字顏色
@@ -231,6 +240,8 @@ termora.transport.jobs.table.speed=速度
termora.transport.jobs.table.estimated-time=剩餘時間 termora.transport.jobs.table.estimated-time=剩餘時間
termora.transport.jobs.contextmenu.delete-all=刪除所有 termora.transport.jobs.contextmenu.delete-all=刪除所有
# ToolBar
termora.toolbar.customize-toolbar=自訂工具列...
termora.terminal.size=大小: {0} x {1} termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已複製 termora.terminal.copied=已複製

View File

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

After

Width:  |  Height:  |  Size: 330 B

View File

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

After

Width:  |  Height:  |  Size: 330 B

View File

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

After

Width:  |  Height:  |  Size: 331 B

View File

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

After

Width:  |  Height:  |  Size: 331 B

View File

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

After

Width:  |  Height:  |  Size: 501 B

View File

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

After

Width:  |  Height:  |  Size: 501 B

View File

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

After

Width:  |  Height:  |  Size: 663 B

View File

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

After

Width:  |  Height:  |  Size: 663 B

View File

@@ -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. --> <!-- 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"> <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> </svg>

Before

Width:  |  Height:  |  Size: 549 B

After

Width:  |  Height:  |  Size: 548 B

View File

@@ -1,4 +1,5 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. --> <!-- 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"> <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> </svg>

Before

Width:  |  Height:  |  Size: 549 B

After

Width:  |  Height:  |  Size: 548 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M6.3464 3.64645C6.54166 3.45118 6.85824 3.45118 7.0535 3.64645C7.24877 3.84171 7.24877 4.15829 7.0535 4.35355L3.90352 7.50354L13.5035 7.50354C13.7797 7.50354 14.0035 7.7274 14.0035 8.00354C14.0035 8.27968 13.7797 8.50354 13.5035 8.50354L3.91059 8.50354L7.05351 11.6464C7.24877 11.8417 7.24877 12.1583 7.05351 12.3536C6.85824 12.5488 6.54166 12.5488 6.3464 12.3536L2.3464 8.35355L1.99284 8L2.3464 7.64645L6.3464 3.64645Z"
fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 741 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M6.3464 3.64645C6.54166 3.45118 6.85824 3.45118 7.0535 3.64645C7.24877 3.84171 7.24877 4.15829 7.0535 4.35355L3.90352 7.50354L13.5035 7.50354C13.7797 7.50354 14.0035 7.7274 14.0035 8.00354C14.0035 8.27968 13.7797 8.50354 13.5035 8.50354L3.91059 8.50354L7.05351 11.6464C7.24877 11.8417 7.24877 12.1583 7.05351 12.3536C6.85824 12.5488 6.54166 12.5488 6.3464 12.3536L2.3464 8.35355L1.99284 8L2.3464 7.64645L6.3464 3.64645Z"
fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 741 B

View File

@@ -0,0 +1,8 @@
<!-- 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 fill-rule="evenodd" clip-rule="evenodd"
d="M11.8536 10.1535C12.0488 9.95828 12.0488 9.6417 11.8536 9.44644C11.6583 9.25117 11.3417 9.25117 11.1464 9.44644L8.5 12.0929L8.5 4.5C8.5 4.22386 8.27615 4 8 4C7.72386 4 7.5 4.22386 7.5 4.5L7.5 12.0929L4.85355 9.44644C4.65829 9.25118 4.34171 9.25118 4.14645 9.44644C3.95118 9.6417 3.95118 9.95828 4.14645 10.1535L7.64645 13.6535L8 14.0071L8.35355 13.6535L11.8536 10.1535Z"
fill="#6C707E"/>
<rect x="2" y="15" width="12" height="1" rx="0.5" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 763 B

View File

@@ -0,0 +1,8 @@
<!-- 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 fill-rule="evenodd" clip-rule="evenodd"
d="M11.8536 10.1535C12.0488 9.95828 12.0488 9.6417 11.8536 9.44644C11.6583 9.25117 11.3417 9.25117 11.1464 9.44644L8.5 12.0929L8.5 4.5C8.5 4.22386 8.27615 4 8 4C7.72386 4 7.5 4.22386 7.5 4.5L7.5 12.0929L4.85355 9.44644C4.65829 9.25118 4.34171 9.25118 4.14645 9.44644C3.95118 9.6417 3.95118 9.95828 4.14645 10.1535L7.64645 13.6535L8 14.0071L8.35355 13.6535L11.8536 10.1535Z"
fill="#CED0D6"/>
<rect x="2" y="15" width="12" height="1" rx="0.5" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 763 B

View File

@@ -0,0 +1,7 @@
<!-- 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" y="0" width="12" height="1" rx="0.5" fill="#6C707E"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.8536 5.85354C12.0488 6.0488 12.0488 6.36538 11.8536 6.56064C11.6583 6.75591 11.3417 6.75591 11.1464 6.56064L8.5 3.9142L8.5 11.5071C8.5 11.7832 8.27615 12.0071 8 12.0071C7.72386 12.0071 7.5 11.7832 7.5 11.5071L7.5 3.91419L4.85355 6.56064C4.65829 6.7559 4.34171 6.7559 4.14645 6.56064C3.95118 6.36538 3.95118 6.0488 4.14645 5.85353L7.64645 2.35354L8 1.99998L8.35355 2.35354L11.8536 5.85354Z"
fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 783 B

View File

@@ -0,0 +1,7 @@
<!-- 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" y="0" width="12" height="1" rx="0.5" fill="#CED0D6"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.8536 5.85354C12.0488 6.0488 12.0488 6.36538 11.8536 6.56064C11.6583 6.75591 11.3417 6.75591 11.1464 6.56064L8.5 3.9142L8.5 11.5071C8.5 11.7832 8.27615 12.0071 8 12.0071C7.72386 12.0071 7.5 11.7832 7.5 11.5071L7.5 3.91419L4.85355 6.56064C4.65829 6.7559 4.34171 6.7559 4.14645 6.56064C3.95118 6.36538 3.95118 6.0488 4.14645 5.85353L7.64645 2.35354L8 1.99998L8.35355 2.35354L11.8536 5.85354Z"
fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 783 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M9.65355 12.3536C9.45829 12.5488 9.14171 12.5488 8.94645 12.3536C8.75118 12.1583 8.75118 11.8417 8.94645 11.6464L12.0894 8.50354L2.49646 8.50354C2.22032 8.50354 1.99646 8.27968 1.99646 8.00354C1.99646 7.7274 2.22032 7.50354 2.49646 7.50354L12.0964 7.50354L8.94645 4.35355C8.75118 4.15829 8.75118 3.84171 8.94645 3.64645C9.14171 3.45118 9.45829 3.45118 9.65355 3.64645L13.6536 7.64645L14.0071 8L13.6536 8.35355L9.65355 12.3536Z"
fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 748 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M9.65355 12.3536C9.45829 12.5488 9.14171 12.5488 8.94645 12.3536C8.75118 12.1583 8.75118 11.8417 8.94645 11.6464L12.0894 8.50354L2.49646 8.50354C2.22032 8.50354 1.99646 8.27968 1.99646 8.00354C1.99646 7.7274 2.22032 7.50354 2.49646 7.50354L12.0964 7.50354L8.94645 4.35355C8.75118 4.15829 8.75118 3.84171 8.94645 3.64645C9.14171 3.45118 9.45829 3.45118 9.65355 3.64645L13.6536 7.64645L14.0071 8L13.6536 8.35355L9.65355 12.3536Z"
fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 748 B

View File

@@ -0,0 +1,9 @@
<!-- 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">
<path d="M12 14C12.5523 14 13 13.5523 13 13C13 12.4477 12.5523 12 12 12C11.4477 12 11 12.4477 11 13C11 13.5523 11.4477 14 12 14Z" fill="#3574F0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 16C9 16 8 13 8 13C8 13 9 10 12 10C15 10 16 13 16 13C16 13 15 16 12 16ZM14.9024 12.9785L14.9131 13L14.9024 13.0215C14.787 13.2525 14.6081 13.5582 14.3568 13.8598C13.8602 14.4557 13.1189 15 12 15C10.8811 15 10.1398 14.4557 9.64322 13.8598C9.39186 13.5582 9.21302 13.2525 9.09755 13.0215L9.08686 13L9.09755 12.9785C9.21302 12.7475 9.39186 12.4418 9.64322 12.1402C10.1398 11.5443 10.8811 11 12 11C13.1189 11 13.8602 11.5443 14.3568 12.1402C14.6081 12.4418 14.787 12.7475 14.9024 12.9785Z" fill="#3574F0"/>
<path d="M12 1C13.1046 1 14 1.89543 14 3V9.40904C13.6947 9.2748 13.3618 9.16675 13 9.09548V3C13 2.44772 12.5523 2 12 2H4C3.44772 2 3 2.44772 3 3V13C3 13.5523 3.44772 14 4 14H7.35149C7.49887 14.2842 7.7089 14.637 7.99347 15H4C2.89543 15 2 14.1046 2 13V3C2 2.15611 2.52265 1.4343 3.26192 1.1406C3.49028 1.04987 3.73932 1 4 1H12Z" fill="#6C707E"/>
<path d="M9.00093 10C8.64732 10.2692 8.35059 10.5672 8.10677 10.8598C8.06775 10.9066 8.03 10.9534 7.99347 11H5.5C5.22386 11 5 10.7761 5 10.5C5 10.2239 5.22386 10 5.5 10H9.00093Z" fill="#6C707E"/>
<path d="M10.5 6C10.7761 6 11 5.77614 11 5.5C11 5.22386 10.7761 5 10.5 5H5.5C5.22386 5 5 5.22386 5 5.5C5 5.77614 5.22386 6 5.5 6H10.5Z" fill="#6C707E"/>
<path d="M11 8C11 7.72386 10.7761 7.5 10.5 7.5H5.5C5.22386 7.5 5 7.72386 5 8C5 8.27614 5.22386 8.5 5.5 8.5H10.5C10.7761 8.5 11 8.27614 11 8Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,9 @@
<!-- 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">
<path d="M12 14C12.5523 14 13 13.5523 13 13C13 12.4477 12.5523 12 12 12C11.4477 12 11 12.4477 11 13C11 13.5523 11.4477 14 12 14Z" fill="#548AF7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 16C9 16 8 13 8 13C8 13 9 10 12 10C15 10 16 13 16 13C16 13 15 16 12 16ZM14.9024 12.9785L14.9131 13L14.9024 13.0215C14.787 13.2525 14.6081 13.5582 14.3568 13.8598C13.8602 14.4557 13.1189 15 12 15C10.8811 15 10.1398 14.4557 9.64322 13.8598C9.39186 13.5582 9.21302 13.2525 9.09755 13.0215L9.08686 13L9.09755 12.9785C9.21302 12.7475 9.39186 12.4418 9.64322 12.1402C10.1398 11.5443 10.8811 11 12 11C13.1189 11 13.8602 11.5443 14.3568 12.1402C14.6081 12.4418 14.787 12.7475 14.9024 12.9785Z" fill="#548AF7"/>
<path d="M12 1C13.1046 1 14 1.89543 14 3V9.40904C13.6947 9.2748 13.3618 9.16675 13 9.09548V3C13 2.44772 12.5523 2 12 2H4C3.44772 2 3 2.44772 3 3V13C3 13.5523 3.44772 14 4 14H7.35149C7.49887 14.2842 7.7089 14.637 7.99347 15H4C2.89543 15 2 14.1046 2 13V3C2 2.15611 2.52265 1.4343 3.26192 1.1406C3.49028 1.04987 3.73932 1 4 1H12Z" fill="#CED0D6"/>
<path d="M9.00093 10C8.64732 10.2692 8.35059 10.5672 8.10677 10.8598C8.06775 10.9066 8.03 10.9534 7.99347 11H5.5C5.22386 11 5 10.7761 5 10.5C5 10.2239 5.22386 10 5.5 10H9.00093Z" fill="#CED0D6"/>
<path d="M10.5 6C10.7761 6 11 5.77614 11 5.5C11 5.22386 10.7761 5 10.5 5H5.5C5.22386 5 5 5.22386 5 5.5C5 5.77614 5.22386 6 5.5 6H10.5Z" fill="#CED0D6"/>
<path d="M11 8C11 7.72386 10.7761 7.5 10.5 7.5H5.5C5.22386 7.5 5 7.72386 5 8C5 8.27614 5.22386 8.5 5.5 8.5H10.5C10.7761 8.5 11 8.27614 11 8Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,14 @@
package app.termora
import org.junit.jupiter.api.assertDoesNotThrow
import java.util.*
import kotlin.test.Test
import kotlin.test.assertFailsWith
class StringFormatTest {
@Test
fun test() {
assertFailsWith(IllegalFormatConversionException::class) { String.format("%.0f%%", 99) }
assertDoesNotThrow { String.format("%.0f%%", 99.0) }
}
}