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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,9 @@ package app.termora
object Icons {
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") }
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") }
@@ -14,6 +16,7 @@ object Icons {
val settings by lazy { DynamicIcon("icons/settings.svg", "icons/settings_dark.svg") }
val pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") }
val empty by lazy { DynamicIcon("icons/empty.svg") }
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") }
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
@@ -47,11 +50,11 @@ object Icons {
val google by lazy { DynamicIcon("icons/google-small.svg") }
val aliyun by lazy { DynamicIcon("icons/aliyun.svg") }
val yandexCloud by lazy { DynamicIcon("icons/yandexCloud.svg") }
val aws by lazy { DynamicIcon("icons/aws.svg","icons/aws_dark.svg") }
val aws by lazy { DynamicIcon("icons/aws.svg", "icons/aws_dark.svg") }
val huawei by lazy { DynamicIcon("icons/huawei.svg") }
val baidu by lazy { DynamicIcon("icons/baiduyun.svg") }
val tianyi by lazy { DynamicIcon("icons/tianyiyun.svg") }
val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg","icons/digitalocean_dark.svg") }
val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg", "icons/digitalocean_dark.svg") }
val terminalUnread by lazy { DynamicIcon("icons/terminalUnread.svg", "icons/terminalUnread_dark.svg") }
val dbPrimitive by lazy { DynamicIcon("icons/dbPrimitive.svg", "icons/dbPrimitive_dark.svg") }
val linux by lazy { DynamicIcon("icons/linux.svg", "icons/linux_dark.svg") }
@@ -73,8 +76,23 @@ object Icons {
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") }
val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
val left by lazy { DynamicIcon("icons/left.svg", "icons/left_dark.svg") }
val right by lazy { DynamicIcon("icons/right.svg", "icons/right_dark.svg") }
val dotListFiles by lazy { DynamicIcon("icons/dotListFiles.svg", "icons/dotListFiles_dark.svg") }
val fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") }
val applyNotConflictsLeft by lazy {
DynamicIcon(
"icons/applyNotConflictsLeft.svg",
"icons/applyNotConflictsLeft_dark.svg"
)
}
val applyNotConflictsRight by lazy {
DynamicIcon(
"icons/applyNotConflictsRight.svg",
"icons/applyNotConflictsRight_dark.svg"
)
}
val expand by lazy { DynamicIcon("icons/expand.svg", "icons/expand_dark.svg") }
val collapse by lazy { DynamicIcon("icons/collapse.svg", "icons/collapse_dark.svg") }
val expandAll by lazy { DynamicIcon("icons/expandAll.svg", "icons/expandAll_dark.svg") }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,9 +64,12 @@ object SshClients {
} else if (host.authentication.type == AuthenticationType.PublicKey) {
session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password)
}
if (!session.auth().verify(timeout).await(timeout)) {
val verifyTimeout = Duration.ofSeconds(timeout.seconds * 5)
if (!session.auth().verify(verifyTimeout).await(verifyTimeout)) {
throw SshException("Authentication failed")
}
return session
}

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ import app.termora.findeverywhere.FindEverywhere
import app.termora.highlight.KeywordHighlightDialog
import app.termora.keymgr.KeyManagerDialog
import app.termora.macro.MacroAction
import app.termora.tlog.TerminalLoggerAction
import app.termora.transport.SFTPAction
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatDesktop
@@ -43,12 +45,12 @@ class TermoraFrame : JFrame() {
private val log = LoggerFactory.getLogger(TermoraFrame::class.java)
}
private val toolbar = JToolBar()
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
private val tabbedPane = MyTabbedPane()
private val toolbar = TermoraToolBar(titleBar, tabbedPane)
private lateinit var terminalTabbed: TerminalTabbed
private val disposable = Disposer.newDisposable()
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
private val updaterManager get() = UpdaterManager.instance
private val preferencesHandler = object : Runnable {
@@ -66,6 +68,7 @@ class TermoraFrame : JFrame() {
FlatDesktop.setPreferencesHandler(that)
}
})
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
}
}
@@ -80,38 +83,6 @@ class TermoraFrame : JFrame() {
private fun initEvents() {
// 监听窗口大小变动,然后修改边距避开控制按钮
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
if (SystemInfo.isMacOS) {
val left = titleBar.leftInset.toInt()
if (tabbedPane.tabAreaInsets.left != left) {
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0)
}
} else if (SystemInfo.isWindows || SystemInfo.isLinux) {
val right = titleBar.rightInset.toInt()
for (i in 0 until toolbar.componentCount) {
val c = toolbar.getComponent(i)
if (c.name == "spacing") {
if (c.width == right) {
return
}
toolbar.remove(i)
break
}
}
if (right > 0) {
val spacing = Box.createHorizontalStrut(right)
spacing.name = "spacing"
toolbar.add(spacing)
}
}
}
})
forceHitTest()
// macos 需要判断是否全部删除
@@ -209,7 +180,7 @@ class TermoraFrame : JFrame() {
// Keyword Highlight
ActionManager.getInstance().addAction(Actions.KEYWORD_HIGHLIGHT_EVERYWHERE, object : AnAction(
ActionManager.getInstance().addAction(Actions.KEYWORD_HIGHLIGHT, object : AnAction(
I18n.getString("termora.highlight"),
Icons.edit
) {
@@ -233,6 +204,12 @@ class TermoraFrame : JFrame() {
}
})
// 终端日志记录
ActionManager.getInstance().addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
// SFTP
ActionManager.getInstance().addAction(Actions.SFTP, SFTPAction())
// macro
ActionManager.getInstance().addAction(Actions.MACRO, MacroAction())
@@ -246,7 +223,9 @@ class TermoraFrame : JFrame() {
val focusWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
val frame = this@TermoraFrame
if (focusWindow == frame) {
FindEverywhere(frame).isVisible = true
val dialog = FindEverywhere(frame)
dialog.setLocationRelativeTo(frame)
dialog.isVisible = true
}
}
}
@@ -405,7 +384,7 @@ class TermoraFrame : JFrame() {
}
override fun mousePressed(e: MouseEvent) {
if (e.source == toolbar) {
if (e.source == toolbar.getJToolBar()) {
if (!isWindowDecorationsSupported && SwingUtilities.isLeftMouseButton(e)) {
if (JBR.isWindowMoveSupported()) {
JBR.getWindowMove().startMovingTogetherWithMouse(this@TermoraFrame, e.button)
@@ -440,8 +419,8 @@ class TermoraFrame : JFrame() {
tabbedPane.addMouseListener(mouseAdapter)
tabbedPane.addMouseMotionListener(mouseAdapter)
toolbar.addMouseListener(mouseAdapter)
toolbar.addMouseMotionListener(mouseAdapter)
toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
}
private fun initDesktopHandler() {

View File

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

View File

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

View File

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

View File

@@ -7,11 +7,11 @@ import java.awt.event.ActionEvent
import javax.swing.Icon
class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
private val actionManager get() = ActionManager.getInstance()
override fun find(pattern: String): List<FindEverywhereResult> {
val list = mutableListOf<FindEverywhereResult>()
ActionManager.getInstance().getAction(Actions.ADD_HOST)?.let {
actionManager?.let {
list.add(CreateHostFindEverywhereResult())
}
@@ -21,8 +21,7 @@ class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
Icons.terminal
) {
override fun actionPerformed(evt: ActionEvent) {
ActionManager.getInstance().getAction(Actions.OPEN_HOST)
?.actionPerformed(
actionManager.getAction(Actions.OPEN_HOST)?.actionPerformed(
OpenHostActionEvent(
this, Host(
name = name,
@@ -34,21 +33,9 @@ class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
}))
// SFTP
list.add(ActionFindEverywhereResult(object : AnAction("SFTP", Icons.fileTransfer) {
override fun actionPerformed(evt: ActionEvent) {
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
actionManager.getAction(Actions.SFTP)?.let {
list.add(ActionFindEverywhereResult(it))
}
}
// 创建一个新的
terminalTabbedManager.addTerminalTab(SFTPTerminalTab())
}
}))
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 com.formdev.flatlaf.util.SystemInfo
import de.jangassen.jfa.foundation.Foundation
import de.jangassen.jfa.foundation.Foundation.NSArray
import jnafilechooser.api.JnaFileChooser
import org.apache.commons.lang3.StringUtils
import java.awt.Window
import java.io.File
import java.util.concurrent.CompletableFuture
@@ -17,6 +17,12 @@ class FileChooser {
var allowsOtherFileTypes = true
var canCreateDirectories = true
var win32Filters = mutableListOf<Pair<String, List<String>>>()
var osxAllowedFileTypes = emptyList<String>()
/**
* 默认的打开目录
*/
var defaultDirectory = StringUtils.EMPTY
fun showOpenDialog(owner: Window? = null): CompletableFuture<List<File>> {
val future = CompletableFuture<List<File>>()
@@ -26,6 +32,17 @@ class FileChooser {
val fileChooser = JnaFileChooser()
fileChooser.isMultiSelectionEnabled = allowsMultiSelection
fileChooser.setTitle(title)
if (defaultDirectory.isNotBlank()) {
fileChooser.setCurrentDirectory(defaultDirectory)
}
if (win32Filters.isNotEmpty()) {
for ((name, filters) in win32Filters) {
fileChooser.addFilter(name, *filters.toTypedArray())
}
}
if (fileChooser.showOpenDialog(owner)) {
future.complete(fileChooser.selectedFiles.toList())
} else {
@@ -91,6 +108,27 @@ class FileChooser {
// 是否允许多选
Foundation.invoke(openPanelInstance, "setAllowsMultipleSelection:", allowsMultiSelection)
// 限制文件类型
if (osxAllowedFileTypes.isNotEmpty()) {
Foundation.invoke(
openPanelInstance,
"setAllowedFileTypes:",
Foundation.fillArray(osxAllowedFileTypes.toTypedArray())
)
}
if (defaultDirectory.isNotBlank()) {
Foundation.invoke(
openPanelInstance,
"setDirectoryURL:",
Foundation.invoke(
"NSURL",
"fileURLWithPath:",
Foundation.nsString(defaultDirectory)
)
)
}
// 标题
if (title.isNotBlank()) {
Foundation.invoke(openPanelInstance, "setTitle:", Foundation.nsString(title))
@@ -103,7 +141,7 @@ class FileChooser {
}
val files = mutableListOf<File>()
val urls = NSArray(Foundation.invoke(openPanelInstance, "URLs"))
val urls = Foundation.NSArray(Foundation.invoke(openPanelInstance, "URLs"))
for (i in 0 until urls.count()) {
val url = Foundation.invoke(urls.at(i), "path")
if (url != null) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,11 +74,14 @@ class FileTransportTableModel(transportManager: TransportManager) : DefaultTable
val speed = if (isTransporting) transport.speed else 0
val estimatedTime = if (isTransporting && speed > 0)
(transport.size - transport.transferredSize) / speed else 0
val progress = transport.progress * 100.0
return when (column) {
COLUMN_NAME -> " ${transport.name}"
COLUMN_STATUS -> formatStatus(transport.state)
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) "-"

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

View File

@@ -102,6 +102,7 @@ class TransportManager : Disposable {
}
if (transport == null) {
needDelay = true
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.reconnect=Reconnect
# Terminal logger
termora.terminal-logger=Terminal Logger
termora.terminal-logger.start-recording=Start Recording
termora.terminal-logger.stop-recording=Stop Recording
termora.terminal-logger.open-log-viewer=Open Log Viewer
termora.terminal-logger.open-in-folder=Open in {0}
# Highlight
termora.highlight=Highlight Sets
@@ -261,7 +268,8 @@ termora.transport.jobs.table.estimated-time=Estimated time
termora.transport.jobs.contextmenu.delete=${termora.remove}
termora.transport.jobs.contextmenu.delete-all=Delete All
# ToolBar
termora.toolbar.customize-toolbar=Customize Toolbar...
# Terminal
termora.terminal.size=Size: {0} x {1}

View File

@@ -166,6 +166,15 @@ termora.tabbed.contextmenu.close-other-tabs=关闭其他标签页
termora.tabbed.contextmenu.close-all-tabs=关闭所有标签页
termora.tabbed.contextmenu.reconnect=重新连接
# Terminal logger
termora.terminal-logger=终端日志
termora.terminal-logger.start-recording=开始记录
termora.terminal-logger.stop-recording=停止记录
termora.terminal-logger.open-log-viewer=打开日志浏览器
termora.terminal-logger.open-in-folder=在 {0} 中打开
# Highlight
termora.highlight=关键词高亮
termora.highlight.text-color=文本颜色
@@ -251,6 +260,8 @@ termora.transport.jobs.table.speed=速度
termora.transport.jobs.table.estimated-time=剩余时间
termora.transport.jobs.contextmenu.delete-all=删除所有
# ToolBar
termora.toolbar.customize-toolbar=自定义工具栏...
termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已复制

View File

@@ -161,6 +161,15 @@ termora.tabbed.contextmenu.close-all-tabs=關閉所有標籤
termora.tabbed.contextmenu.reconnect=重新連接
# Terminal logger
termora.terminal-logger=終端日誌
termora.terminal-logger.start-recording=開始記錄
termora.terminal-logger.stop-recording=停止記錄
termora.terminal-logger.open-log-viewer=開啟日誌瀏覽器
termora.terminal-logger.open-in-folder=在 {0} 中打開
# Highlight
termora.highlight=關鍵字高亮
termora.highlight.text-color=文字顏色
@@ -231,6 +240,8 @@ termora.transport.jobs.table.speed=速度
termora.transport.jobs.table.estimated-time=剩餘時間
termora.transport.jobs.contextmenu.delete-all=刪除所有
# ToolBar
termora.toolbar.customize-toolbar=自訂工具列...
termora.terminal.size=大小: {0} x {1}
termora.terminal.copied=已複製

View File

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

After

Width:  |  Height:  |  Size: 330 B

View File

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

After

Width:  |  Height:  |  Size: 330 B

View File

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

After

Width:  |  Height:  |  Size: 331 B

View File

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

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1,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. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.10584 4.34613L8.25344 4.5H8.46667H13C13.8284 4.5 14.5 5.17157 14.5 6V12.1333C14.5 12.9529 13.932 13.5 13.3667 13.5H2.63333C2.06804 13.5 1.5 12.9529 1.5 12.1333V3.86667C1.5 3.04707 2.06804 2.5 2.63333 2.5H6.1217C6.25792 2.5 6.38824 2.55557 6.48253 2.65387L8.10584 4.34613Z" fill="#EBECF0" stroke="#6C707E"/>
<path d="M8.10584 4.34613L8.25344 4.5H8.46667H13C13.8284 4.5 14.5 5.17157 14.5 6V12.1333C14.5 12.9529 13.932 13.5 13.3667 13.5H2.63333C2.06804 13.5 1.5 12.9529 1.5 12.1333V3.86667C1.5 3.04707 2.06804 2.5 2.63333 2.5H6.1217C6.25792 2.5 6.38824 2.55557 6.48253 2.65387L8.10584 4.34613Z"
stroke="#6C707E"/>
</svg>

Before

Width:  |  Height:  |  Size: 549 B

After

Width:  |  Height:  |  Size: 548 B

View File

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

Before

Width:  |  Height:  |  Size: 549 B

After

Width:  |  Height:  |  Size: 548 B

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) }
}
}