Compare commits

...

31 Commits
1.0.8 ... 1.0.9

Author SHA1 Message Date
hstyi
ef9caf2578 release: 1.0.9 2025-02-24 13:23:13 +08:00
hstyi
b85bdf840e feat: support automatic download of update packages (#305) 2025-02-24 12:38:30 +08:00
hstyi
a2d7f3b5bb chore: Inno Setup 2025-02-24 00:51:53 +08:00
hstyi
02a96d73c8 fix: linux won't restart 2025-02-23 22:33:15 +08:00
hstyi
9fb12c7a71 feat: SFTP command add key shortcut 2025-02-23 21:32:25 +08:00
hstyi
145d8fc802 chore: automatically notarise macOS releases when released 2025-02-23 15:00:42 +08:00
hstyi
72c9dba806 feat: support restart (#299) 2025-02-23 11:32:44 +08:00
hstyi
de20bd654c feat: supports importing hosts from PuTTY (#297) 2025-02-22 16:47:50 +08:00
hstyi
35b3a10746 feat: supports importing hosts from electerm (#296) 2025-02-22 15:59:43 +08:00
hstyi
05fe6a0eb1 feat: supports importing hosts from FinalShell (#295) 2025-02-22 15:32:48 +08:00
hstyi
0552917c26 feat: supports importing hosts from SecureCRT (#294) 2025-02-22 14:51:32 +08:00
hstyi
51c355c113 feat: supports importing hosts from MobaXterm (#293) 2025-02-22 14:04:31 +08:00
hstyi
034ee3791d feat: supports importing hosts from Xshell (#292) 2025-02-22 13:15:45 +08:00
hstyi
adabaf8f2d feat: supports importing hosts from CSV (#291) 2025-02-22 12:23:31 +08:00
hstyi
1f392c52a1 chore: win 7z 2025-02-21 22:31:40 +08:00
hstyi
28fe4c725f feat: supports importing hosts from WindTerm (#289) 2025-02-21 21:44:51 +08:00
hstyi
18fe92cb11 chore: upgrade dependency versions 2025-02-21 19:42:08 +08:00
hstyi
c49acf7b51 feat: support fixed SFTP tab (#286) 2025-02-21 17:04:50 +08:00
hstyi
7df317a1b9 feat: refactoring HostTree & support sorting (#285) 2025-02-21 16:24:45 +08:00
hstyi
219e5420f5 fix: memory parsing error (#284) 2025-02-20 21:24:38 +08:00
hstyi
aefb7c3014 chore: exclude sshd-osgi 2025-02-20 20:53:12 +08:00
hstyi
f0c7f06ff5 chore: optimize package size 2025-02-20 20:41:34 +08:00
hstyi
604e07b43a fix: memory leaks 2025-02-20 17:17:23 +08:00
hstyi
0000e4610a feat: nvidia smi (#280) 2025-02-20 16:45:53 +08:00
hstyi
510324d7c4 fix: tunnels causes connection failure (#279) 2025-02-20 12:13:27 +08:00
hstyi
33a359fcbf feat: system information (#278) 2025-02-20 12:05:45 +08:00
hstyi
0b84d3271c feat: FindEverywhere show more info 2025-02-19 15:24:28 +08:00
hstyi
57547c95cb feat: blink (#273) 2025-02-19 13:17:59 +08:00
hstyi
503cfa9a4e fix: terminal cursor error (#272) 2025-02-19 10:29:31 +08:00
hstyi
af1f979e31 feat: ⌘ + Q to exit prompt 2025-02-18 23:29:04 +08:00
hstyi
3cd9f92ea9 feat: support setting sftp path (#267) 2025-02-18 18:09:33 +08:00
84 changed files with 4631 additions and 1407 deletions

View File

@@ -33,6 +33,16 @@ jobs:
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
- name: Setup the Notary Information
if: github.ref_type == 'tag' && github.repository == 'TermoraDev/termora'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
STORE_CREDENTIALS: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz
@@ -57,8 +67,11 @@ jobs:
# dist
- name: Dist
env:
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' }}
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
# 只有发布版本时才需要公证
TERMORA_MAC_NOTARY: ${{ github.ref_type == 'tag' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
./gradlew dist --no-daemon
@@ -66,4 +79,6 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: termora-osx-aarch64
path: build/distributions/*.dmg
path: |
build/distributions/*.zip
build/distributions/*.dmg

View File

@@ -33,6 +33,16 @@ jobs:
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
- name: Setup the Notary Information
if: github.ref_type == 'tag' && github.repository == 'TermoraDev/termora'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
STORE_CREDENTIALS: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz
@@ -59,8 +69,11 @@ jobs:
# dist
- name: Dist
env:
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' }}
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
# 只有发布版本时才需要公证
TERMORA_MAC_NOTARY: ${{ github.ref_type == 'tag' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
./gradlew dist --no-daemon
@@ -68,4 +81,6 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: termora-osx-x86-64
path: build/distributions/*.dmg
path: |
build/distributions/*.zip
build/distributions/*.dmg

View File

@@ -10,11 +10,20 @@ jobs:
with:
fetch-depth: 0
- name: Install zip
run: |
$system32 = [System.Environment]::GetEnvironmentVariable("WINDIR") + "\System32"
Invoke-WebRequest -Uri "http://stahlworks.com/dev/zip.exe" -OutFile "$system32\zip.exe"
Invoke-WebRequest -Uri "http://stahlworks.com/dev/unzip.exe" -OutFile "$system32\unzip.exe"
- name: Install 7z
uses: milliewalky/setup-7-zip@v2
- name: Installing Java
uses: actions/setup-java@v4
with:
distribution: 'jetbrains'
java-version: '21'
run: |
curl -s --output ${{ runner.temp }}\java_package.zip -L https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-windows-x64-b895.91.zip
unzip -q ${{ runner.temp }}\java_package.zip -d ${{ runner.temp }}\jbr
echo "JAVA_HOME=${{ runner.temp }}\jbr\jbrsdk-21.0.6-windows-x64-b895.91" >> $env:GITHUB_ENV
- uses: actions/cache@v4
with:
@@ -37,3 +46,4 @@ jobs:
path: |
build/distributions/*.zip
build/distributions/*.msi
build/distributions/*.exe

View File

@@ -7,7 +7,8 @@ jobs:
runs-on: windows-latest
steps:
- uses: vedantmgoyal9/winget-releaser@main
if: github.repository == 'TermoraDev/termora'
with:
identifier: TermoraDev.Termora
installers-regex: 'x86-64\.msi$' # Only x86-64.msi files
installers-regex: 'x86-64\.exe$' # Only x86-64.exe files
token: ${{ secrets.WINGET_TOKEN }}

View File

@@ -14,7 +14,7 @@ commonmark 0.24.0
BSD 2-Clause "Simplified" License
https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt
commons-codec 1.17.1
commons-codec 1.18.0
Apache License 2.0
https://github.com/apache/commons-codec/blob/master/LICENSE.txt
@@ -34,10 +34,18 @@ commons-net 3.11.1
Apache License 2.0
https://github.com/apache/commons-net/blob/master/LICENSE.txt
commons-text 1.12.0
commons-text 1.13.0
Apache License 2.0
https://github.com/apache/commons-text/blob/master/LICENSE.txt
commons-csv 1.13.0
Apache License 2.0
https://github.com/apache/commons-csv/blob/master/LICENSE.txt
ini4j 0.5.5-2
Apache License 2.0
http://www.apache.org/licenses/LICENSE-2.0.txt
eddsa 0.3.0
Creative Commons Zero v1.0 Universal
https://github.com/str4d/ed25519-java/blob/master/LICENSE.txt
@@ -110,7 +118,7 @@ kotlin-logging 1.7.9
Apache License 2.0
https://github.com/oshai/kotlin-logging/blob/master/LICENSE
kotlin-stdlib 2.1.0
kotlin-stdlib 2.1.10
Apache License 2.0
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
@@ -126,6 +134,10 @@ kotlin-stdlib-jdk8 1.9.10
Apache License 2.0
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
restart4j 0.0.1
Apache License 2.0
https://github.com/hstyi/restart4j/blob/main/LICENSE
kotlinx-coroutines-core-jvm 1.10.1
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
@@ -134,11 +146,11 @@ kotlinx-coroutines-swing 1.10.1
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
kotlinx-serialization-core-jvm 1.7.3
kotlinx-serialization-core-jvm 1.8.0
Apache License 2.0
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
kotlinx-serialization-json-jvm 1.7.3
kotlinx-serialization-json-jvm 1.8.0
Apache License 2.0
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt

View File

@@ -3,8 +3,12 @@ import org.gradle.kotlin.dsl.support.uppercaseFirstChar
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils
import org.jetbrains.kotlin.org.apache.commons.io.filefilter.FileFilterUtils
import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
import java.io.FileNotFoundException
import java.nio.file.Files
import java.util.concurrent.Executors
import java.util.concurrent.Future
plugins {
java
@@ -16,7 +20,7 @@ plugins {
group = "app.termora"
version = "1.0.8"
version = "1.0.9"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
@@ -59,6 +63,7 @@ dependencies {
implementation(libs.commons.codec)
implementation(libs.commons.io)
implementation(libs.commons.lang3)
implementation(libs.commons.csv)
implementation(libs.commons.net)
implementation(libs.commons.text)
implementation(libs.commons.compress)
@@ -98,7 +103,7 @@ dependencies {
implementation(libs.sshd.core)
implementation(libs.commonmark)
implementation(libs.jgit)
implementation(libs.jgit.sshd)
implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.jnafilechooser)
implementation(libs.xodus.vfs)
implementation(libs.xodus.openAPI)
@@ -107,6 +112,8 @@ dependencies {
implementation(libs.colorpicker)
implementation(libs.mixpanel)
implementation(libs.jSerialComm)
implementation(libs.ini4j)
implementation(libs.restart4j)
}
application {
@@ -142,16 +149,17 @@ tasks.test {
tasks.register<Copy>("copy-dependencies") {
val dir = layout.buildDirectory.dir("libs")
from(configurations.runtimeClasspath).into(dir)
val jna = libs.jna.asProvider().get()
val pty4j = libs.pty4j.get()
val jSerialComm = libs.jSerialComm.get()
val restart4j = libs.restart4j.get()
// 对 JNA 和 PTY4J 的本地库提取
// 提取出来是为了单独签名,不然无法通过公证
if (os.isMacOsX && macOSSign) {
doLast {
val jna = libs.jna.asProvider().get()
val archName = if (arch.isArm) "aarch64" else "x86_64"
val dylib = dir.get().dir("dylib").asFile
val pty4j = libs.pty4j.get()
val jSerialComm = libs.jSerialComm.get()
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
val targetDir = File(dylib, jna.name)
@@ -177,7 +185,6 @@ tasks.register<Copy>("copy-dependencies") {
// 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "resources/*") }
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
val archName = if (arch.isArm) "aarch64" else "x86_64"
val targetDir = FileUtils.getFile(dylib, jSerialComm.name, "OSX", archName)
FileUtils.forceMkdir(targetDir)
// @formatter:off
@@ -191,6 +198,24 @@ tasks.register<Copy>("copy-dependencies") {
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
} else if ("${restart4j.name}-${restart4j.version}" == file.nameWithoutExtension) {
val targetDir = FileUtils.getFile(dylib, restart4j.name)
FileUtils.forceMkdir(targetDir)
// @formatter:off
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "darwin/${archName}/*", "-d", targetDir.absolutePath) }
// @formatter:on
// 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "win32/*") }
exec { commandLine("zip", "-d", file.absolutePath, "darwin/*") }
exec { commandLine("zip", "-d", file.absolutePath, "linux/*") }
// 设置可执行权限
for (e in FileUtils.listFiles(
targetDir,
FileFilterUtils.trueFileFilter(),
FileFilterUtils.falseFileFilter()
)) {
e.setExecutable(true)
}
}
}
@@ -203,6 +228,73 @@ tasks.register<Copy>("copy-dependencies") {
}
}
}
} else if (os.isLinux || os.isWindows) { // 缩减安装包
doLast {
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/darwin-*") }
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/freebsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/dragonflybsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/aix-*") }
if (os.isWindows) {
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/linux-*") }
if (arch.isArm) {
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-x86*") }
} else {
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-aarch64/*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-x86/*") }
}
} else if (os.isLinux) {
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-*") }
}
} else if ("${pty4j.name}-${pty4j.version}" == file.nameWithoutExtension) {
exec { commandLine("zip", "-d", file.absolutePath, "resources/*darwin*") }
exec { commandLine("zip", "-d", file.absolutePath, "resources/*freebsd*") }
if (os.isWindows) {
exec { commandLine("zip", "-d", file.absolutePath, "resources/*linux*") }
if (arch.isArm) {
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86/*") }
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86-64*") }
} else {
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86/*") }
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/aarch64/*") }
}
} else if (os.isLinux) {
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win*") }
}
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
exec { commandLine("zip", "-d", file.absolutePath, "Android/*") }
exec { commandLine("zip", "-d", file.absolutePath, "FreeBSD/*") }
exec { commandLine("zip", "-d", file.absolutePath, "OpenBSD/*") }
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
if (os.isWindows) {
exec { commandLine("zip", "-d", file.absolutePath, "Linux/*") }
} else if (os.isLinux) {
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
}
} else if ("${restart4j.name}-${restart4j.version}" == file.nameWithoutExtension) {
exec { commandLine("zip", "-d", file.absolutePath, "darwin/*") }
if (os.isWindows) {
exec { commandLine("zip", "-d", file.absolutePath, "linux/*") }
if (arch.isArm) {
exec { commandLine("zip", "-d", file.absolutePath, "win32/x86_64/*") }
} else {
exec { commandLine("zip", "-d", file.absolutePath, "win32/aarch64/*") }
}
} else if (os.isLinux) {
exec { commandLine("zip", "-d", file.absolutePath, "win32/*") }
if (arch.isArm) {
exec { commandLine("zip", "-d", file.absolutePath, "linux/x86_64/*") }
} else {
exec { commandLine("zip", "-d", file.absolutePath, "linux/aarch64/*") }
}
}
}
}
}
}
}
@@ -335,11 +427,8 @@ tasks.register("dist") {
throw GradleException("JVM: $vendor is not supported")
}
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") }
@@ -359,125 +448,8 @@ tasks.register("dist") {
// 打包
exec { commandLine(gradlew, "jpackage") }
// pack
if (os.isWindows) { // zip and msi
// zip
exec {
commandLine(
"tar", "-vacf",
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("${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,
macOSFinalFilePath,
)
}
} else {
throw GradleException("${os.name} is not supported")
}
// AppImage
if (os.isLinux) {
// Download AppImageKit
val appimagetool = FileUtils.getFile(projectDir, ".gradle", "appimagetool")
if (!appimagetool.exists()) {
exec {
commandLine(
"wget",
"-O", appimagetool.absolutePath,
"https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-${if (arch.isArm) "aarch64" else "x86_64"}.AppImage"
)
workingDir = distributionDir.asFile
}
// AppImageKit chmod
exec { commandLine("chmod", "+x", appimagetool.absolutePath) }
}
// Desktop file
val termoraName = project.name.uppercaseFirstChar()
val desktopFile = distributionDir.file(termoraName + File.separator + termoraName + ".desktop").asFile
desktopFile.writeText(
"""[Desktop Entry]
Type=Application
Name=${termoraName}
Comment=Terminal emulator and SSH client
Icon=/lib/${termoraName}
Categories=Development;
Terminal=false
""".trimIndent()
)
// AppRun file
val appRun = File(desktopFile.parentFile, "AppRun")
val sb = StringBuilder()
sb.append("#!/bin/sh").appendLine()
sb.append("SELF=$(readlink -f \"$0\")").appendLine()
sb.append("HERE=\${SELF%/*}").appendLine()
sb.append("exec \"\${HERE}/bin/${termoraName}\" \"$@\"")
appRun.writeText(sb.toString())
appRun.setExecutable(true)
exec {
commandLine(appimagetool.absolutePath, termoraName, "${finalFilenameWithoutExtension}.AppImage")
workingDir = distributionDir.asFile
}
}
// 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",
)
}
// 绑定公证信息
exec {
commandLine(
"/usr/bin/xcrun",
"stapler", "staple", macOSFinalFilePath,
)
}
}
}
// 根据不同的系统构建不同的二进制包
pack()
}
}
@@ -515,6 +487,198 @@ tasks.register("check-license") {
}
}
/**
* 构建包
*/
fun pack() {
val osName = if (os.isMacOsX) "osx" else if (os.isWindows) "windows" else "linux"
val distributionDir = layout.buildDirectory.dir("distributions").get()
val finalFilenameWithoutExtension = "${project.name}-${project.version}-${osName}-${arch.name}"
val projectName = project.name.uppercaseFirstChar()
if (os.isWindows) {
packOnWindows(distributionDir, finalFilenameWithoutExtension, projectName)
} else if (os.isLinux) {
packOnLinux(distributionDir, finalFilenameWithoutExtension, projectName)
} else if (os.isMacOsX) {
packOnMac(distributionDir, finalFilenameWithoutExtension, projectName)
} else {
throw GradleException("${os.name} is not supported")
}
}
/**
* 创建 zip、7z、msi
*/
fun packOnWindows(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
// zip
exec {
commandLine(
"tar", "-vacf",
distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile.absolutePath,
projectName
)
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
}
// exe
exec {
commandLine(
"iscc",
"/DMyAppId=${projectName}",
"/DMyAppName=${projectName}",
"/DMyAppVersion=${project.version}",
"/DMyOutputDir=${distributionDir.asFile.absolutePath}",
"/DMySetupIconFile=${FileUtils.getFile(projectDir, "src", "main", "resources", "icons", "termora.ico")}",
"/DMySourceDir=${layout.buildDirectory.dir("jpackage/images/win-msi.image/${projectName}").get().asFile}",
"/F${finalFilenameWithoutExtension}",
FileUtils.getFile(projectDir, "src", "main", "resources", "termora.iss")
)
}
// msi
exec {
commandLine(
"cmd", "/c", "move",
"${projectName}-${project.version}.msi",
"${finalFilenameWithoutExtension}.msi"
)
workingDir = distributionDir.asFile
}
}
/**
* 对于 macOS 先对 jpackage 构建的 dmg 重命名 -> 签名 -> 公证,另外还会创建一个 zip 包
*/
fun packOnMac(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
val dmgFile = distributionDir.file("${finalFilenameWithoutExtension}.dmg").asFile
val zipFile = distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile
// rename
// @formatter:off
exec { commandLine("mv", distributionDir.file("${projectName}-${project.version}.dmg").asFile.absolutePath, dmgFile.absolutePath,) }
// @formatter:on
// sign dmg
if (macOSSign) signMacOSLocalFile(dmgFile)
// 找到 .app
val imageFile = layout.buildDirectory.dir("jpackage/images/").get().asFile
val appFile = imageFile.listFiles()?.firstOrNull()?.listFiles()?.firstOrNull()
?: throw FileNotFoundException("${projectName}.app")
// zip
// @formatter:off
exec { commandLine("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", appFile.absolutePath, zipFile.absolutePath) }
// @formatter:on
// sign zip
if (macOSSign) signMacOSLocalFile(zipFile)
// 公证
if (macOSNotary) {
val pool = Executors.newCachedThreadPool()
val jobs = mutableListOf<Future<*>>()
// zip
pool.submit {
// 对 zip 公证
notaryMacOSLocalFile(zipFile)
// 对 .app 盖章
stapleMacOSLocalFile(appFile)
// 删除旧的 zip ,旧的 zip 仅仅是为了公证
FileUtils.deleteQuietly(zipFile)
// 再对盖完章的 app 打成 zip 包
// @formatter:off
exec { commandLine("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", appFile.absolutePath, zipFile.absolutePath) }
// @formatter:on
// 再对 zip 签名
signMacOSLocalFile(zipFile)
}.apply { jobs.add(this) }
// dmg
pool.submit {
// 公证
notaryMacOSLocalFile(dmgFile)
// 盖章
stapleMacOSLocalFile(dmgFile)
}.apply { jobs.add(this) }
// join ...
jobs.forEach { it.get() }
// shutdown
pool.shutdown()
}
}
/**
* 创建 tar.gz 和 AppImage
*/
fun packOnLinux(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
// tar.gz
exec {
commandLine(
"tar", "-czvf",
distributionDir.file("${finalFilenameWithoutExtension}.tar.gz").asFile.absolutePath,
projectName
)
workingDir = distributionDir.asFile
}
// AppImage
// Download AppImageKit
val appimagetool = FileUtils.getFile(projectDir, ".gradle", "appimagetool")
if (!appimagetool.exists()) {
exec {
commandLine(
"wget",
"-O", appimagetool.absolutePath,
"https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-${if (arch.isArm) "aarch64" else "x86_64"}.AppImage"
)
workingDir = distributionDir.asFile
}
// AppImageKit chmod
exec { commandLine("chmod", "+x", appimagetool.absolutePath) }
}
// Desktop file
val termoraName = project.name.uppercaseFirstChar()
val desktopFile = distributionDir.file(termoraName + File.separator + termoraName + ".desktop").asFile
desktopFile.writeText(
"""[Desktop Entry]
Type=Application
Name=${termoraName}
Comment=Terminal emulator and SSH client
Icon=/lib/${termoraName}
Categories=Development;
Terminal=false
""".trimIndent()
)
// AppRun file
val appRun = File(desktopFile.parentFile, "AppRun")
val sb = StringBuilder()
sb.append("#!/bin/sh").appendLine()
sb.append("SELF=$(readlink -f \"$0\")").appendLine()
sb.append("HERE=\${SELF%/*}").appendLine()
sb.append("export LinuxAppImage=true").appendLine()
sb.append("exec \"\${HERE}/bin/${termoraName}\" \"$@\"")
appRun.writeText(sb.toString())
appRun.setExecutable(true)
// AppImage
exec {
commandLine(appimagetool.absolutePath, termoraName, "${finalFilenameWithoutExtension}.AppImage")
workingDir = distributionDir.asFile
}
}
/**
* macOS 对本地文件进行签名
*/
@@ -534,6 +698,40 @@ fun signMacOSLocalFile(file: File) {
}
}
/**
* macOS 对本地文件进行公证
*/
fun notaryMacOSLocalFile(file: File) {
if (os.isMacOsX && macOSNotary) {
if (file.exists()) {
exec {
commandLine(
"/usr/bin/xcrun", "notarytool",
"submit", file,
"--keychain-profile", macOSNotaryKeychainProfile,
"--wait",
)
}
}
}
}
/**
* 盖章
*/
fun stapleMacOSLocalFile(file: File) {
if (os.isMacOsX && macOSNotary) {
if (file.exists()) {
exec {
commandLine(
"/usr/bin/xcrun",
"stapler", "staple", file,
)
}
}
}
}
kotlin {
jvmToolchain {

View File

@@ -1,16 +1,17 @@
[versions]
kotlin = "2.1.0"
kotlin = "2.1.10"
slf4j = "2.0.16"
pty4j = "0.13.2"
tinylog = "2.7.0"
kotlinx-coroutines = "1.10.1"
flatlaf = "3.5.4"
trove4j = "1.0.20200330"
kotlinx-serialization-json = "1.7.3"
commons-codec = "1.17.1"
kotlinx-serialization-json = "1.8.0"
commons-codec = "1.18.0"
commons-lang3 = "3.17.0"
commons-csv = "1.13.0"
commons-net = "3.11.1"
commons-text = "1.12.0"
commons-text = "1.13.0"
commons-compress = "1.27.1"
koin-bom = "4.0.0"
swingx = "1.6.5-1"
@@ -41,7 +42,9 @@ rhino = "1.7.15"
delight-rhino-sandbox = "0.0.17"
testcontainers = "1.20.4"
mixpanel = "1.5.3"
jSerialComm="2.11.0"
jSerialComm = "2.11.0"
ini4j = "0.5.5-2"
restart4j = "0.0.1"
[libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
@@ -53,9 +56,11 @@ tinylog-impl = { group = "org.tinylog", name = "tinylog-impl", version.ref = "ti
commons-codec = { group = "commons-codec", name = "commons-codec", version.ref = "commons-codec" }
commons-net = { group = "commons-net", name = "commons-net", version.ref = "commons-net" }
commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version.ref = "commons-lang3" }
commons-csv = { group = "org.apache.commons", name = "commons-csv", version.ref = "commons-csv" }
commons-text = { group = "org.apache.commons", name = "commons-text", version.ref = "commons-text" }
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" }
pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" }
ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" }
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" }
trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" }
@@ -72,6 +77,7 @@ versioncompare = { module = "io.github.g00fy2:versioncompare", version.ref = "ve
jfa = { module = "de.jangassen:jfa", version.ref = "jfa" }
oshi-core = { module = "com.github.oshi:oshi-core", version.ref = "oshi" }
commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" }
restart4j = { module = "com.github.hstyi:restart4j", version.ref = "restart4j" }
jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" }
flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" }
leveldb = { module = "org.iq80.leveldb:leveldb", version.ref = "leveldb" }

View File

@@ -4,6 +4,8 @@ import app.termora.actions.ActionManager
import app.termora.keymap.KeymapManager
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatDesktop
import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse
import com.formdev.flatlaf.extras.FlatInspector
import com.formdev.flatlaf.util.SystemInfo
import com.jthemedetecor.OsThemeDetector
@@ -20,12 +22,14 @@ import org.apache.commons.lang3.SystemUtils
import org.json.JSONObject
import org.slf4j.LoggerFactory
import org.tinylog.configuration.Configuration
import java.awt.KeyboardFocusManager
import java.io.File
import java.nio.channels.FileChannel
import java.nio.channels.FileLock
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.util.*
import java.util.function.Consumer
import javax.swing.*
import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis
@@ -100,16 +104,8 @@ class ApplicationRunner {
@Suppress("OPT_IN_USAGE")
private fun clearTemporary() {
GlobalScope.launch(Dispatchers.IO) {
// 启动时清除
FileUtils.cleanDirectory(Application.getTemporaryDir())
// 关闭时清除
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
override fun dispose() {
FileUtils.cleanDirectory(Application.getTemporaryDir())
}
})
}
}
@@ -123,7 +119,37 @@ class ApplicationRunner {
}
private fun startMainFrame() {
TermoraFrameManager.getInstance().createWindow().isVisible = true
if (SystemUtils.IS_OS_MAC_OSX) {
SwingUtilities.invokeLater {
FlatDesktop.setQuitHandler(object : Consumer<QuitResponse> {
override fun accept(response: QuitResponse) {
quitHandler(response)
}
})
}
}
}
private fun quitHandler(response: QuitResponse) {
val keyboardFocusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
if (OptionPane.showConfirmDialog(
keyboardFocusManager.focusedWindow,
I18n.getString("termora.quit-confirm", Application.getName()),
optionType = JOptionPane.YES_NO_OPTION,
) != JOptionPane.YES_OPTION
) {
response.cancelQuit()
return
}
for (frame in TermoraFrameManager.getInstance().getWindows()) {
frame.dispose()
}
}
private fun loadSettings() {
@@ -161,7 +187,7 @@ class ApplicationRunner {
themeManager.change(theme, true)
if (Application.isUnknownVersion())
FlatInspector.install("ctrl shift alt X");
FlatInspector.install("ctrl shift alt X")
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)

View File

@@ -400,10 +400,10 @@ class Database private constructor(private val env: Environment) : Disposable {
protected inner class CursorStylePropertyDelegate(defaultValue: CursorStyle) :
PropertyDelegate<CursorStyle>(defaultValue) {
override fun convertValue(value: String): CursorStyle {
try {
return CursorStyle.valueOf(value)
} catch (e: Exception) {
return initializer.invoke()
return try {
CursorStyle.valueOf(value)
} catch (_: Exception) {
initializer.invoke()
}
}
}
@@ -458,6 +458,11 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var beep by BooleanPropertyDelegate(true)
/**
* 光标闪烁
*/
var cursorBlink by BooleanPropertyDelegate(false)
/**
* 选中复制
*/
@@ -588,6 +593,18 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var editCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* sftp command
*/
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* 是否固定在标签栏
*/
var pinTab by BooleanPropertyDelegate(false)
}
/**

View File

@@ -54,7 +54,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
protected fun init() {
defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
defaultCloseOperation = DISPOSE_ON_CLOSE
initTitleBar()
initEvents()
@@ -158,12 +158,14 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
openPopup = true
}
val window = SwingUtilities.windowForComponent(c)
val windows = window.ownedWindows
for (w in windows) {
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
openPopup = true
w.dispose()
val window = c as? Window ?: SwingUtilities.windowForComponent(c)
if (window != null) {
val windows = window.ownedWindows
for (w in windows) {
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
openPopup = true
w.dispose()
}
}
}

View File

@@ -0,0 +1,156 @@
package app.termora
import org.apache.commons.lang3.ArrayUtils
import java.util.function.Function
import javax.swing.JTree
import javax.swing.SwingUtilities
import javax.swing.event.TreeModelEvent
import javax.swing.event.TreeModelListener
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.TreeModel
import javax.swing.tree.TreeNode
import javax.swing.tree.TreePath
class FilterableHostTreeModel(
private val tree: JTree,
/**
* 如果返回 true 则空文件夹也展示
*/
private val showEmptyFolder: () -> Boolean = { true }
) : TreeModel {
private val model = tree.model
private val root = ReferenceTreeNode(model.root)
private var listeners = emptyArray<TreeModelListener>()
private var filters = emptyArray<Function<HostTreeNode, Boolean>>()
private val mapping = mutableMapOf<TreeNode, ReferenceTreeNode>()
init {
refresh()
initEvents()
}
/**
* @param a 旧的
* @param b 新的
*/
private fun cloneTree(a: HostTreeNode, b: DefaultMutableTreeNode) {
b.removeAllChildren()
for (c in a.children()) {
if (c !is HostTreeNode) {
continue
}
if (c.host.protocol != Protocol.Folder) {
if (filters.isNotEmpty() && filters.none { it.apply(c) }) {
continue
}
}
val n = ReferenceTreeNode(c).apply { mapping[c] = this }.apply { b.add(this) }
// 文件夹递归复制
if (c.host.protocol == Protocol.Folder) {
cloneTree(c, n)
}
// 如果是文件夹
if (c.host.protocol == Protocol.Folder) {
if (n.childCount == 0) {
if (showEmptyFolder.invoke()) {
continue
}
n.removeFromParent()
}
}
}
}
private fun initEvents() {
model.addTreeModelListener(object : TreeModelListener {
override fun treeNodesChanged(e: TreeModelEvent) {
refresh()
}
override fun treeNodesInserted(e: TreeModelEvent) {
refresh()
}
override fun treeNodesRemoved(e: TreeModelEvent) {
refresh()
}
override fun treeStructureChanged(e: TreeModelEvent) {
refresh()
}
})
}
override fun getRoot(): Any {
return root.userObject
}
override fun getChild(parent: Any, index: Int): Any {
val c = map(parent)?.getChildAt(index)
if (c is ReferenceTreeNode) {
return c.userObject
}
throw IndexOutOfBoundsException("Index out of bounds")
}
override fun getChildCount(parent: Any): Int {
return map(parent)?.childCount ?: 0
}
private fun map(parent: Any): ReferenceTreeNode? {
if (parent is TreeNode) {
return mapping[parent]
}
return null
}
override fun isLeaf(node: Any?): Boolean {
return (node as TreeNode).isLeaf
}
override fun valueForPathChanged(path: TreePath, newValue: Any) {
}
override fun getIndexOfChild(parent: Any, child: Any): Int {
val c = map(parent) ?: return -1
for (i in 0 until c.childCount) {
val e = c.getChildAt(i)
if (e is ReferenceTreeNode && e.userObject == child) {
return i
}
}
return -1
}
override fun addTreeModelListener(l: TreeModelListener) {
listeners = ArrayUtils.addAll(listeners, l)
}
override fun removeTreeModelListener(l: TreeModelListener) {
listeners = ArrayUtils.removeElement(listeners, l)
}
fun addFilter(f: Function<HostTreeNode, Boolean>) {
filters = ArrayUtils.add(filters, f)
}
fun refresh() {
mapping.clear()
mapping[model.root as TreeNode] = root
cloneTree(model.root as HostTreeNode, root)
SwingUtilities.updateComponentTreeUI(tree)
}
fun getModel(): TreeModel {
return model
}
private class ReferenceTreeNode(any: Any) : DefaultMutableTreeNode(any)
}

View File

@@ -260,7 +260,7 @@ data class Host(
val tunnelings: List<Tunneling> = emptyList(),
/**
* 排序
* 排序,越小越靠前
*/
val sort: Long = 0,
/**
@@ -307,4 +307,8 @@ data class Host(
result = 31 * result + ownerId.hashCode()
return result
}
override fun toString(): String {
return name
}
}

View File

@@ -1,13 +1,5 @@
package app.termora
import java.util.*
interface HostListener : EventListener {
fun hostAdded(host: Host) {}
fun hostRemoved(id: String) {}
fun hostsChanged() {}
}
class HostManager private constructor() {
companion object {
@@ -17,39 +9,38 @@ class HostManager private constructor() {
}
private val database get() = Database.getDatabase()
private val listeners = mutableListOf<HostListener>()
private var hosts = mutableMapOf<String, Host>()
fun addHost(host: Host, notify: Boolean = true) {
/**
* 修改缓存并存入数据库
*/
fun addHost(host: Host) {
assertEventDispatchThread()
database.addHost(host)
if (notify) listeners.forEach { it.hostAdded(host) }
}
fun removeHost(id: String) {
assertEventDispatchThread()
database.removeHost(id)
listeners.forEach { it.hostRemoved(id) }
if (host.deleted) {
hosts.entries.removeIf { it.value.id == host.id || it.value.parentId == host.id }
} else {
hosts[host.id] = host
}
}
/**
* 第一次调用从数据库中获取,后续从缓存中获取
*/
fun hosts(): List<Host> {
return database.getHosts()
if (hosts.isEmpty()) {
database.getHosts().filter { !it.deleted }
.forEach { hosts[it.id] = it }
}
return hosts.values.filter { !it.deleted }
.sortedWith(compareBy<Host> { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort })
}
fun removeAll() {
assertEventDispatchThread()
database.removeAllHost()
listeners.forEach { it.hostsChanged() }
/**
* 从缓存中获取
*/
fun getHost(id: String): Host? {
return hosts[id]
}
fun addHostListener(listener: HostListener) {
listeners.add(listener)
}
fun removeHostListener(listener: HostListener) {
listeners.remove(listener)
}
}

View File

@@ -1134,16 +1134,16 @@ open class HostOptionsPane : OptionsPane() {
private fun initEvents() {
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent?) {
val dialog = HostTreeDialog(owner) { host ->
jumpHosts.none { it.id == host.id } && filter.invoke(host)
}
val dialog = NewHostTreeDialog(owner)
dialog.setFilter { node -> jumpHosts.none { it.id == node.host.id } && filter.invoke(node.host) }
dialog.setTreeName("HostOptionsPane.JumpHostsOption.Tree")
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
val hosts = dialog.hosts
if (hosts.isEmpty()) {
return
}
hosts.forEach {
val rowCount = model.rowCount
jumpHosts.add(it)

View File

@@ -1,660 +0,0 @@
package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.NewHostAction
import app.termora.actions.OpenHostAction
import app.termora.transport.SFTPAction
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeOpenIcon
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
import java.awt.Component
import java.awt.Dimension
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.*
import javax.swing.*
import javax.swing.event.CellEditorListener
import javax.swing.event.ChangeEvent
import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener
import javax.swing.tree.TreePath
import javax.swing.tree.TreeSelectionModel
class HostTree : JTree(), Disposable {
private val hostManager get() = HostManager.getInstance()
private val editor = OutlineTextField(64)
var contextmenu = true
/**
* 双击是否打开连接
*/
var doubleClickConnection = true
val model = HostTreeModel()
val searchableModel = SearchableHostTreeModel(model)
init {
initView()
initEvents()
}
private fun initView() {
setModel(model)
isEditable = true
dropMode = DropMode.ON_OR_INSERT
dragEnabled = true
selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
editor.preferredSize = Dimension(220, 0)
setCellRenderer(object : DefaultXTreeCellRenderer() {
private val properties get() = Database.getDatabase().properties
override fun getTreeCellRendererComponent(
tree: JTree,
value: Any,
sel: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean
): Component {
val host = value as Host
var text = host.name
// 是否显示更多信息
if (properties.getString("HostTree.showMoreInfo", "false").toBoolean()) {
val color = if (sel) {
if (this@HostTree.hasFocus()) {
UIManager.getColor("textHighlightText")
} else {
this.foreground
}
} else {
UIManager.getColor("textInactiveText")
}
if (host.protocol == Protocol.SSH) {
text = """
<html>${host.name}
&nbsp;&nbsp;
<font color=rgb(${color.red},${color.green},${color.blue})>${host.username}@${host.host}</font></html>
""".trimIndent()
} else if (host.protocol == Protocol.Serial) {
text = """
<html>${host.name}
&nbsp;&nbsp;
<font color=rgb(${color.red},${color.green},${color.blue})>${host.options.serialComm.port}</font></html>
""".trimIndent()
}
}
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
icon = when (host.protocol) {
Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
Protocol.Serial -> if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin
else -> if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal
}
return c
}
})
setCellEditor(object : DefaultCellEditor(editor) {
override fun isCellEditable(e: EventObject?): Boolean {
if (e is MouseEvent) {
return false
}
return super.isCellEditable(e)
}
})
val state = Database.getDatabase().properties.getString("HostTreeExpansionState")
if (state != null) {
TreeUtils.loadExpansionState(this@HostTree, state)
}
}
override fun convertValueToText(
value: Any?,
selected: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean
): String {
if (value is Host) {
return value.name
}
return super.convertValueToText(value, selected, expanded, leaf, row, hasFocus)
}
private fun initEvents() {
// 右键选中
addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
if (!SwingUtilities.isRightMouseButton(e)) {
return
}
requestFocusInWindow()
val selectionRows = selectionModel.selectionRows
val selRow = getClosestRowForLocation(e.x, e.y)
if (selRow < 0) {
selectionModel.clearSelection()
return
} else if (selectionRows != null && selectionRows.contains(selRow)) {
return
}
selectionPath = getPathForLocation(e.x, e.y)
setSelectionRow(selRow)
}
override fun mouseClicked(e: MouseEvent) {
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val host = lastSelectedPathComponent
if (host is Host && host.protocol != Protocol.Folder) {
ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(e.source, host, e))
}
}
}
})
// contextmenu
addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
if (!(SwingUtilities.isRightMouseButton(e))) {
return
}
if (Objects.isNull(lastSelectedPathComponent)) {
return
}
SwingUtilities.invokeLater { showContextMenu(e) }
}
})
// rename
getCellEditor().addCellEditorListener(object : CellEditorListener {
override fun editingStopped(e: ChangeEvent) {
val lastHost = lastSelectedPathComponent
if (lastHost !is Host || editor.text.isBlank() || editor.text == lastHost.name) {
return
}
runCatchingHost(lastHost.copy(name = editor.text))
}
override fun editingCanceled(e: ChangeEvent) {
}
})
// drag
transferHandler = object : TransferHandler() {
override fun createTransferable(c: JComponent): Transferable {
val nodes = selectionModel.selectionPaths
.map { it.lastPathComponent }
.filterIsInstance<Host>()
.toMutableList()
val iterator = nodes.iterator()
while (iterator.hasNext()) {
val node = iterator.next()
val parents = model.getPathToRoot(node).filter { it != node }
if (parents.any { nodes.contains(it) }) {
iterator.remove()
}
}
return MoveHostTransferable(nodes)
}
override fun getSourceActions(c: JComponent?): Int {
return MOVE
}
override fun canImport(support: TransferSupport): Boolean {
if (!support.isDrop) {
return false
}
val dropLocation = support.dropLocation
if (dropLocation !is JTree.DropLocation || support.component != this@HostTree
|| dropLocation.childIndex != -1
) {
return false
}
val lastNode = dropLocation.path.lastPathComponent
if (lastNode !is Host || lastNode.protocol != Protocol.Folder) {
return false
}
if (support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) {
val nodes = support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>
if (nodes.any { it == lastNode }) {
return false
}
for (parent in model.getPathToRoot(lastNode).filter { it != lastNode }) {
if (nodes.any { it == parent }) {
return false
}
}
}
support.setShowDropLocation(true)
return support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)
}
override fun importData(support: TransferSupport): Boolean {
if (!support.isDrop) {
return false
}
val dropLocation = support.dropLocation
if (dropLocation !is JTree.DropLocation) {
return false
}
val lastNode = dropLocation.path.lastPathComponent
if (lastNode !is Host || lastNode.protocol != Protocol.Folder) {
return false
}
if (!support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) {
return false
}
val hosts = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>)
.filterIsInstance<Host>().toMutableList()
if (hosts.isEmpty()) {
return false
}
// 记录展开的节点
val expandedHosts = mutableListOf<String>()
for (host in hosts) {
model.visit(host) {
if (it.protocol == Protocol.Folder) {
if (isExpanded(TreePath(model.getPathToRoot(it)))) {
expandedHosts.addFirst(it.id)
}
}
}
}
var now = System.currentTimeMillis()
for (host in hosts) {
model.removeNodeFromParent(host)
val newHost = host.copy(
parentId = lastNode.id,
sort = ++now,
updateDate = now
)
runCatchingHost(newHost)
}
expandNode(lastNode)
// 展开
for (id in expandedHosts) {
model.getHost(id)?.let { expandNode(it) }
}
return true
}
}
}
override fun isPathEditable(path: TreePath?): Boolean {
if (path == null) return false
if (path.lastPathComponent == model.root) return false
return super.isPathEditable(path)
}
override fun getLastSelectedPathComponent(): Any? {
val last = super.getLastSelectedPathComponent() ?: return null
if (last is Host) {
return model.getHost(last.id) ?: last
}
return last
}
private fun showContextMenu(event: MouseEvent) {
if (!contextmenu) return
val lastHost = lastSelectedPathComponent
if (lastHost !is Host) {
return
}
val properties = Database.getDatabase().properties
val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new"))
val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder"))
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host"))
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect"))
val openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu
val openWithSFTP = openWith.add("SFTP")
val openWithSFTPCommand = openWith.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
val openInNewWindow = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open-in-new-window"))
popupMenu.addSeparator()
val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy"))
val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove"))
val rename = popupMenu.add(I18n.getString("termora.welcome.contextmenu.rename"))
popupMenu.addSeparator()
val expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all"))
val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all"))
popupMenu.addSeparator()
popupMenu.add(newMenu)
popupMenu.addSeparator()
val showMoreInfo = JCheckBoxMenuItem(I18n.getString("termora.welcome.contextmenu.show-more-info"))
showMoreInfo.isSelected = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
showMoreInfo.addActionListener {
properties.putString(
"HostTree.showMoreInfo",
showMoreInfo.isSelected.toString()
)
SwingUtilities.updateComponentTreeUI(this)
}
popupMenu.add(showMoreInfo)
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
open.addActionListener { openHosts(it, false) }
openWithSFTP.addActionListener { openWithSFTP(it) }
openWithSFTPCommand.addActionListener { openWithSFTPCommand(it) }
openInNewWindow.addActionListener { openHosts(it, true) }
// 如果选中了 SSH 服务器,那么才启用
openWithSFTP.isEnabled = getSelectionNodes().any { it.protocol == Protocol.SSH }
openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled
openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled }
rename.addActionListener {
startEditingAtPath(TreePath(model.getPathToRoot(lastHost)))
}
expandAll.addActionListener {
getSelectionNodes().forEach { expandNode(it, true) }
}
colspanAll.addActionListener {
selectionModel.selectionPaths.map { it.lastPathComponent }
.filterIsInstance<Host>()
.filter { it.protocol == Protocol.Folder }
.forEach { collapseNode(it) }
}
copy.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val parent = model.getParent(lastHost) ?: return
val node = copyNode(parent, lastHost)
selectionPath = TreePath(model.getPathToRoot(node))
}
})
remove.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
I18n.getString("termora.remove"),
JOptionPane.YES_NO_OPTION,
JOptionPane.QUESTION_MESSAGE
) == JOptionPane.YES_OPTION
) {
var lastParent: Host? = null
while (!selectionModel.isSelectionEmpty) {
val host = lastSelectedPathComponent ?: break
if (host !is Host) {
break
} else {
lastParent = model.getParent(host)
}
model.visit(host) { hostManager.removeHost(it.id) }
}
if (lastParent != null) {
selectionPath = TreePath(model.getPathToRoot(lastParent))
}
}
}
newFolder.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (lastHost.protocol != Protocol.Folder) {
return
}
val host = Host(
id = UUID.randomUUID().toSimpleString(),
protocol = Protocol.Folder,
name = I18n.getString("termora.welcome.contextmenu.new.folder.name"),
sort = System.currentTimeMillis(),
parentId = lastHost.id
)
runCatchingHost(host)
expandNode(lastHost)
selectionPath = TreePath(model.getPathToRoot(host))
startEditingAtPath(selectionPath)
}
})
newHost.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
ActionManager.getInstance().getAction(NewHostAction.NEW_HOST)
?.actionPerformed(e)
}
})
property.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = HostDialog(SwingUtilities.getWindowAncestor(this@HostTree), lastHost)
dialog.title = lastHost.name
dialog.isVisible = true
val host = dialog.host ?: return
runCatchingHost(host)
}
})
// 初始化状态
newFolder.isEnabled = lastHost.protocol == Protocol.Folder
newHost.isEnabled = newFolder.isEnabled
remove.isEnabled = !getSelectionNodes().any { it == model.root }
copy.isEnabled = remove.isEnabled
rename.isEnabled = remove.isEnabled
property.isEnabled = lastHost.protocol != Protocol.Folder
popupMenu.addPopupMenuListener(object : PopupMenuListener {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
this@HostTree.grabFocus()
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
this@HostTree.requestFocusInWindow()
}
override fun popupMenuCanceled(e: PopupMenuEvent) {
}
})
popupMenu.show(this, event.x, event.y)
}
private fun openHosts(evt: EventObject, openInNewWindow: Boolean) {
assertEventDispatchThread()
val nodes = getSelectionNodes().filter { it.protocol != Protocol.Folder }
if (nodes.isEmpty()) return
val openHostAction = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return
val source = if (openInNewWindow)
TermoraFrameManager.getInstance().createWindow().apply { isVisible = true }
else evt.source
nodes.forEach { openHostAction.actionPerformed(OpenHostActionEvent(source, it, evt)) }
}
private fun openWithSFTP(evt: EventObject) {
val nodes = getSelectionNodes().filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return
val sftpAction = ActionManager.getInstance().getAction(Actions.SFTP) as SFTPAction? ?: return
val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return
for (node in nodes) {
sftpAction.connectHost(node, tab)
}
}
private fun openWithSFTPCommand(evt: EventObject) {
val nodes = getSelectionNodes().filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return
val action = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return
for (host in nodes) {
action.actionPerformed(OpenHostActionEvent(this, host.copy(protocol = Protocol.SFTPPty), evt))
}
}
fun expandNode(node: Host, including: Boolean = false) {
expandPath(TreePath(model.getPathToRoot(node)))
if (including) {
model.getChildren(node).forEach { expandNode(it, true) }
}
}
private fun copyNode(
parent: Host,
host: Host,
idGenerator: () -> String = { UUID.randomUUID().toSimpleString() }
): Host {
val now = System.currentTimeMillis()
val newHost = host.copy(
name = "${host.name} ${I18n.getString("termora.welcome.contextmenu.copy")}",
id = idGenerator.invoke(),
parentId = parent.id,
updateDate = now,
createDate = now,
sort = now
)
runCatchingHost(newHost)
if (host.protocol == Protocol.Folder) {
for (child in model.getChildren(host)) {
copyNode(newHost, child, idGenerator)
}
if (isExpanded(TreePath(model.getPathToRoot(host)))) {
expandNode(newHost)
}
}
return newHost
}
private fun runCatchingHost(host: Host) {
hostManager.addHost(host)
}
private fun collapseNode(node: Host) {
model.getChildren(node).forEach { collapseNode(it) }
collapsePath(TreePath(model.getPathToRoot(node)))
}
fun getSelectionNodes(): List<Host> {
val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent }
.filterIsInstance<Host>()
if (selectionNodes.isEmpty()) {
return emptyList()
}
val nodes = mutableListOf<Host>()
val parents = mutableListOf<Host>()
for (node in selectionNodes) {
if (node.protocol == Protocol.Folder) {
parents.add(node)
}
nodes.add(node)
}
while (parents.isNotEmpty()) {
val p = parents.removeFirst()
for (i in 0 until getModel().getChildCount(p)) {
val child = getModel().getChild(p, i) as Host
nodes.add(child)
parents.add(child)
}
}
// 确保是最新的
for (i in 0 until nodes.size) {
nodes[i] = model.getHost(nodes[i].id) ?: continue
}
return nodes
}
override fun dispose() {
Database.getDatabase().properties.putString(
"HostTreeExpansionState",
TreeUtils.saveExpansionState(this)
)
}
private abstract class HostTreeNodeTransferable(val hosts: List<Host>) :
Transferable {
override fun getTransferDataFlavors(): Array<DataFlavor> {
return arrayOf(getDataFlavor())
}
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean {
return getDataFlavor() == flavor
}
override fun getTransferData(flavor: DataFlavor): Any {
return hosts
}
abstract fun getDataFlavor(): DataFlavor
}
private class MoveHostTransferable(hosts: List<Host>) : HostTreeNodeTransferable(hosts) {
companion object {
val dataFlavor =
DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveHostTransferable::class.java.name}")
}
override fun getDataFlavor(): DataFlavor {
return dataFlavor
}
}
}

View File

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

View File

@@ -1,159 +0,0 @@
package app.termora
import org.apache.commons.lang3.StringUtils
import javax.swing.event.TreeModelEvent
import javax.swing.event.TreeModelListener
import javax.swing.tree.TreeModel
import javax.swing.tree.TreePath
class HostTreeModel : TreeModel {
val listeners = mutableListOf<TreeModelListener>()
private val hostManager get() = HostManager.getInstance()
private val hosts = mutableMapOf<String, Host>()
private val myRoot by lazy {
Host(
id = "0",
protocol = Protocol.Folder,
name = I18n.getString("termora.welcome.my-hosts"),
host = StringUtils.EMPTY,
port = 0,
remark = StringUtils.EMPTY,
username = StringUtils.EMPTY
)
}
init {
for (host in hostManager.hosts()) {
hosts[host.id] = host
}
hostManager.addHostListener(object : HostListener {
override fun hostRemoved(id: String) {
val host = hosts[id] ?: return
removeNodeFromParent(host)
}
override fun hostAdded(host: Host) {
// 如果已经存在,那么是修改
if (hosts.containsKey(host.id)) {
val oldHost = hosts.getValue(host.id)
// 父级结构变了
if (oldHost.parentId != host.parentId) {
hostRemoved(host.id)
hostAdded(host)
} else {
hosts[host.id] = host
val event = TreeModelEvent(this, getPathToRoot(host))
listeners.forEach { it.treeStructureChanged(event) }
}
} else {
hosts[host.id] = host
val parent = getParent(host) ?: return
val path = TreePath(getPathToRoot(parent))
val event = TreeModelEvent(this, path, intArrayOf(getIndexOfChild(parent, host)), arrayOf(host))
listeners.forEach { it.treeNodesInserted(event) }
}
}
override fun hostsChanged() {
hosts.clear()
for (host in hostManager.hosts()) {
hosts[host.id] = host
}
val event = TreeModelEvent(this, getPathToRoot(root), null, null)
listeners.forEach { it.treeStructureChanged(event) }
}
})
}
override fun getRoot(): Host {
return myRoot
}
override fun getChild(parent: Any?, index: Int): Any {
return getChildren(parent)[index]
}
override fun getChildCount(parent: Any?): Int {
return getChildren(parent).size
}
override fun isLeaf(node: Any?): Boolean {
return getChildCount(node) == 0
}
fun getParent(node: Host): Host? {
if (node.parentId == root.id || root.id == node.id) {
return root
}
return hosts.values.firstOrNull { it.id == node.parentId }
}
override fun valueForPathChanged(path: TreePath?, newValue: Any?) {
}
override fun getIndexOfChild(parent: Any?, child: Any?): Int {
return getChildren(parent).indexOf(child)
}
override fun addTreeModelListener(listener: TreeModelListener) {
listeners.add(listener)
}
override fun removeTreeModelListener(listener: TreeModelListener) {
listeners.remove(listener)
}
/**
* 仅从结构中删除
*/
fun removeNodeFromParent(host: Host) {
val parent = getParent(host) ?: return
val index = getIndexOfChild(parent, host)
val event = TreeModelEvent(this, TreePath(getPathToRoot(parent)), intArrayOf(index), arrayOf(host))
hosts.remove(host.id)
listeners.forEach { it.treeNodesRemoved(event) }
}
fun visit(host: Host, visitor: (host: Host) -> Unit) {
if (host.protocol == Protocol.Folder) {
getChildren(host).forEach { visit(it, visitor) }
visitor.invoke(host)
} else {
visitor.invoke(host)
}
}
fun getHost(id: String): Host? {
return hosts[id]
}
fun getPathToRoot(host: Host): Array<Host> {
if (host.id == root.id) {
return arrayOf(root)
}
val parents = mutableListOf(host)
var pId = host.parentId
while (pId != root.id) {
val e = hosts[(pId)] ?: break
parents.addFirst(e)
pId = e.parentId
}
parents.addFirst(root)
return parents.toTypedArray()
}
fun getChildren(parent: Any?): List<Host> {
val pId = if (parent is Host) parent.id else root.id
return hosts.values.filter { it.parentId == pId }
.sortedWith(compareBy<Host> { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort })
}
}

View File

@@ -0,0 +1,97 @@
package app.termora
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.TreeNode
class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
companion object {
private val hostManager get() = HostManager.getInstance()
}
/**
* 如果要重新赋值,记得修改 [Host.updateDate] 否则下次取出时可能时缓存的
*/
var host: Host
get() {
val cacheHost = hostManager.getHost((userObject as Host).id)
val myHost = userObject as Host
if (cacheHost == null) {
return myHost
}
return if (cacheHost.updateDate > myHost.updateDate) cacheHost else myHost
}
set(value) = setUserObject(value)
val folderCount
get() = children().toList().count { if (it is HostTreeNode) it.host.protocol == Protocol.Folder else false }
override fun getParent(): HostTreeNode? {
return super.getParent() as HostTreeNode?
}
fun getAllChildren(): List<HostTreeNode> {
val children = mutableListOf<HostTreeNode>()
for (child in children()) {
if (child is HostTreeNode) {
children.add(child)
children.addAll(child.getAllChildren())
}
}
return children
}
fun childrenNode(): List<HostTreeNode> {
return children?.map { it as HostTreeNode } ?: emptyList()
}
/**
* 深度克隆
* @param scopes 克隆的范围
*/
fun clone(scopes: Set<Protocol> = emptySet()): HostTreeNode {
val newNode = clone() as HostTreeNode
deepClone(newNode, this, scopes)
return newNode
}
private fun deepClone(newNode: HostTreeNode, oldNode: HostTreeNode, scopes: Set<Protocol> = emptySet()) {
for (child in oldNode.childrenNode()) {
if (scopes.isNotEmpty() && !scopes.contains(child.host.protocol)) continue
val newChildNode = child.clone() as HostTreeNode
deepClone(newChildNode, child, scopes)
newNode.add(newChildNode)
}
}
override fun clone(): Any {
val newNode = HostTreeNode(host)
newNode.children = null
newNode.parent = null
return newNode
}
override fun isNodeChild(aNode: TreeNode?): Boolean {
if (aNode is HostTreeNode) {
for (node in childrenNode()) {
if (node.host == aNode.host) {
return true
}
}
}
return super.isNodeChild(aNode)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HostTreeNode
return host == other.host
}
override fun hashCode(): Int {
return host.hashCode()
}
}

View File

@@ -10,6 +10,8 @@ object Icons {
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 openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") }
val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") }
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
@@ -26,6 +28,9 @@ object Icons {
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 locate by lazy { DynamicIcon("icons/locate.svg", "icons/locate_dark.svg") }
val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") }
val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
@@ -114,5 +119,6 @@ object Icons {
val listKey by lazy { DynamicIcon("icons/listKey.svg", "icons/listKey_dark.svg") }
val forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") }
val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_dark.svg") }
val nvidia by lazy { DynamicIcon("icons/nvidia.svg", "icons/nvidia_dark.svg") }
}

View File

@@ -50,4 +50,9 @@ private fun setupNativeLibraries() {
if (jSerialComm.exists()) {
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
}
val restart4j = FileUtils.getFile(dylib, "restart4j", "restarter")
if (restart4j.exists()) {
System.setProperty("restarter.path", restart4j.absolutePath)
}
}

View File

@@ -1,6 +1,7 @@
package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
@@ -13,11 +14,13 @@ import kotlin.math.abs
class MyTabbedPane : FlatTabbedPane() {
private val owner: Window get() = SwingUtilities.getWindowAncestor(this)
private val dragMouseAdaptor = DragMouseAdaptor()
private val terminalTabbedManager
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
.getData(DataProviders.TerminalTabbedManager)
private val owner
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
.getData(DataProviders.TermoraFrame) as TermoraFrame
init {
initEvents()
@@ -145,11 +148,11 @@ class MyTabbedPane : FlatTabbedPane() {
// 如果等于 null 表示在空地方释放,那么单独一个窗口
if (c == null) {
val window = TermoraFrameManager.getInstance().createWindow()
dragToAnotherWindow(window)
dragToAnotherWindow(owner, window)
window.location = releasedPoint
window.isVisible = true
} else if (c != owner && c is TermoraFrame) { // 如果在某个窗口内释放,那么就移动到某个窗口内
dragToAnotherWindow(c)
dragToAnotherWindow(owner, c)
} else {
val tab = this.terminalTab
val terminalTabbedManager = terminalTabbedManager
@@ -224,20 +227,29 @@ class MyTabbedPane : FlatTabbedPane() {
}
private fun dragToAnotherWindow(frame: TermoraFrame) {
private fun dragToAnotherWindow(oldFrame: TermoraFrame, frame: TermoraFrame) {
val tab = this.terminalTab ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return
val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return
val windowScope = frame.getData(DataProviders.WindowScope) ?: return
val oldWindowScope = oldFrame.getData(DataProviders.WindowScope) ?: return
val location = Point(MouseInfo.getPointerInfo().location)
SwingUtilities.convertPointFromScreen(location, tabbedPane)
val index = tabbedPane.indexAtLocation(location.x, location.y)
moveTab(
tabbedManager,
tab,
index
)
TerminalPanelFactory.getInstance(oldWindowScope).removeTerminalPanel(terminalPanel)
TerminalPanelFactory.getInstance(windowScope).addTerminalPanel(terminalPanel)
if (frame.hasFocus()) {
return
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
package app.termora
import org.apache.commons.lang3.StringUtils
import java.awt.Dimension
import java.awt.Window
import java.util.function.Function
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.JScrollPane
import javax.swing.UIManager
class NewHostTreeDialog(
owner: Window,
) : DialogWrapper(owner) {
var hosts = emptyList<Host>()
var allowMulti = true
private var filter: Function<HostTreeNode, Boolean> = Function<HostTreeNode, Boolean> { true }
private val tree = NewHostTree()
init {
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
isModal = true
isResizable = false
controlsVisible = false
title = I18n.getString("termora.transport.sftp.select-host")
tree.contextmenu = false
tree.doubleClickConnection = false
tree.dragEnabled = false
init()
setLocationRelativeTo(null)
}
fun setFilter(filter: Function<HostTreeNode, Boolean>) {
tree.model = FilterableHostTreeModel(tree) { false }.apply {
addFilter(filter)
refresh()
}
}
override fun createCenterPanel(): JComponent {
val scrollPane = JScrollPane(tree)
scrollPane.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(4, 6, 4, 6)
)
return scrollPane
}
override fun doCancelAction() {
hosts = emptyList()
super.doCancelAction()
}
override fun doOKAction() {
hosts = tree.getSelectionHostTreeNodes(true)
.filter { filter.apply(it) }
.map { it.host }
if (hosts.isEmpty()) return
if (!allowMulti && hosts.size > 1) return
super.doOKAction()
}
fun setTreeName(treeName: String) {
Disposer.register(disposable, object : Disposable {
private val key = "${treeName}.state"
private val properties get() = Database.getDatabase().properties
init {
TreeUtils.loadExpansionState(tree, properties.getString(key, StringUtils.EMPTY))
}
override fun dispose() {
properties.putString(key, TreeUtils.saveExpansionState(tree))
}
})
}
}

View File

@@ -0,0 +1,83 @@
package app.termora
import org.apache.commons.lang3.StringUtils
import javax.swing.tree.DefaultTreeModel
import javax.swing.tree.MutableTreeNode
import javax.swing.tree.TreeNode
class NewHostTreeModel : DefaultTreeModel(
HostTreeNode(
Host(
id = "0",
protocol = Protocol.Folder,
name = I18n.getString("termora.welcome.my-hosts"),
host = StringUtils.EMPTY,
port = 0,
remark = StringUtils.EMPTY,
username = StringUtils.EMPTY
)
)
) {
private val Host.isRoot get() = this.parentId == "0" || this.parentId.isBlank()
private val hostManager get() = HostManager.getInstance()
init {
reload()
}
override fun getRoot(): HostTreeNode {
return super.getRoot() as HostTreeNode
}
override fun reload(parent: TreeNode) {
if (parent !is HostTreeNode) {
super.reload(parent)
return
}
parent.removeAllChildren()
val hosts = hostManager.hosts()
val nodes = linkedMapOf<String, HostTreeNode>()
// 遍历 Host 列表,构建树节点
for (host in hosts) {
val node = HostTreeNode(host)
nodes[host.id] = node
}
for (host in hosts) {
val node = nodes[host.id] ?: continue
if (host.isRoot) continue
val p = nodes[host.parentId] ?: continue
p.add(node)
}
for ((_, v) in nodes.entries) {
if (parent.host.id == v.host.parentId) {
parent.add(v)
}
}
super.reload(parent)
}
override fun insertNodeInto(newChild: MutableTreeNode, parent: MutableTreeNode, index: Int) {
super.insertNodeInto(newChild, parent, index)
// 重置所有排序
if (parent is HostTreeNode) {
for ((i, c) in parent.children().toList().filterIsInstance<HostTreeNode>().withIndex()) {
val sort = i.toLong()
if (c.host.sort == sort) continue
c.host = c.host.copy(sort = sort, updateDate = System.currentTimeMillis())
hostManager.addHost(c.host)
}
}
}
}

View File

@@ -13,12 +13,13 @@ import java.awt.event.ItemEvent
import javax.swing.*
import kotlin.math.max
class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(owner) {
private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
private val rememberCheckBox = JCheckBox("Remember")
private val passwordPanel = JPanel(BorderLayout())
private val passwordPasswordField = OutlinePasswordField()
private val usernameTextField = OutlineTextField()
private val publicKeyComboBox = OutlineComboBox<OhKeyPair>()
private val keyManager get() = KeyManager.getInstance()
private var authentication = Authentication.No
@@ -64,6 +65,8 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
}
}
usernameTextField.text = host.username
}
override fun createCenterPanel(): JComponent {
@@ -72,7 +75,7 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
val formMargin = "7dlu"
val layout = FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref"
"pref, $formMargin, pref, $formMargin, pref"
)
switchPasswordComponent()
@@ -81,8 +84,10 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
.layout(layout)
.add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, 1)
.add(authenticationTypeComboBox).xy(3, 1)
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 3)
.add(passwordPanel).xy(3, 3)
.add("${I18n.getString("termora.new-host.general.username")}:").xy(1, 3)
.add(usernameTextField).xy(3, 3)
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 5)
.add(passwordPanel).xy(3, 5)
.build()
}
@@ -134,7 +139,13 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
fun getAuthentication(): Authentication {
isModal = true
SwingUtilities.invokeLater { passwordPasswordField.requestFocusInWindow() }
SwingUtilities.invokeLater {
if (usernameTextField.text.isBlank()) {
usernameTextField.requestFocusInWindow()
} else {
passwordPasswordField.requestFocusInWindow()
}
}
isVisible = true
return authentication
}
@@ -143,4 +154,8 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
return rememberCheckBox.isSelected
}
fun getUsername(): String {
return usernameTextField.text
}
}

View File

@@ -27,6 +27,7 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand
companion object {
val canSupports by lazy {
@@ -42,14 +43,14 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
override suspend fun openPtyConnector(): PtyConnector {
val useJumpHosts = host.options.jumpHosts.isNotEmpty() || host.proxy.type != ProxyType.No
val commands = mutableListOf("sftp")
val commands = mutableListOf(StringUtils.defaultIfBlank(sftpCommand, "sftp"))
var host = this.host
// 如果配置了跳板机或者代理,那么通过 SSH 的端口转发到本地
if (useJumpHosts) {
host = host.copy(
updateDate = System.currentTimeMillis(),
tunnelings = listOf(
Tunneling(
type = TunnelingType.Local,
@@ -66,7 +67,11 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
// 打开通道
for (tunneling in host.tunnelings) {
val address = SshClients.openTunneling(sshSession, host, tunneling)
host = host.copy(host = address.hostName, port = address.port)
host = host.copy(
host = address.hostName,
port = address.port,
updateDate = System.currentTimeMillis(),
)
}
}
@@ -128,9 +133,9 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
private fun setAuthentication(commands: MutableList<String>, host: Host) {
// 如果通过公钥连接
if (host.authentication.type == AuthenticationType.PublicKey) {
val keyPair = keyManager.getOhKeyPair(host.authentication.password)
if (keyPair != null) {
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(keyPair)
val ohKeyPair = keyManager.getOhKeyPair(host.authentication.password)
if (ohKeyPair != null) {
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(ohKeyPair)
val privateKeyPath = Application.createSubTemporaryDir()
val privateKeyFile = Files.createTempFile(privateKeyPath, Application.getName(), StringUtils.EMPTY)
Files.newOutputStream(privateKeyFile)

View File

@@ -12,10 +12,11 @@ import javax.swing.SwingUtilities
class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
private val transportPanel by lazy {
TransportPanel().apply {
Disposer.register(this@SFTPTerminalTab, this)
}
private val sftp get() = Database.getDatabase().sftp
private val transportPanel = TransportPanel()
init {
Disposer.register(this, transportPanel)
}
override fun getTitle(): String {
@@ -43,6 +44,11 @@ class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
override fun canClose(): Boolean {
assertEventDispatchThread()
if (sftp.pinTab) {
return false
}
val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true
if (transportManager.getTransports().isEmpty()) {
return true

View File

@@ -36,10 +36,13 @@ import javax.swing.SwingUtilities
class SSHTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
companion object {
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
val SSHSession = DataKey(ClientSession::class)
private val log = LoggerFactory.getLogger(SSHTerminalTab::class.java)
}
private val mutex = Mutex()
private val tab = this
private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null
@@ -86,7 +89,8 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
terminal.write("SSH client is opening...\r\n")
}
var host = this.host.copy(authentication = this.host.authentication.copy())
var host =
this.host.copy(authentication = this.host.authentication.copy(), updateDate = System.currentTimeMillis())
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
val client = SshClients.openClient(host).also { sshClient = it }
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
@@ -95,12 +99,21 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
if (host.authentication.type == AuthenticationType.No) {
withContext(Dispatchers.Swing) {
val dialog = RequestAuthenticationDialog(owner)
val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication()
host = host.copy(authentication = authentication)
host = host.copy(
authentication = authentication,
username = dialog.getUsername(),
updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(this@SSHTerminalTab.host.copy(authentication = authentication))
HostManager.getInstance().addHost(
tab.host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
}
}
@@ -155,7 +168,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
if (Database.getDatabase().terminal.autoCloseTabWhenDisconnected) {
terminalTabbedManager?.let { manager ->
SwingUtilities.invokeLater {
manager.closeTerminalTab(this@SSHTerminalTab, true)
manager.closeTerminalTab(tab, true)
}
}
}
@@ -192,15 +205,30 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
}
for (tunneling in host.tunnelings) {
SshClients.openTunneling(session, host, tunneling)
withContext(Dispatchers.Swing) {
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
try {
SshClients.openTunneling(session, host, tunneling)
withContext(Dispatchers.Swing) {
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error("Start [${tunneling.name}] port forwarding failed: {}", e.message, e)
}
withContext(Dispatchers.Swing) {
terminal.write("Start [${tunneling.name}] port forwarding failed: ${e.message}\r\n")
}
}
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == SSHSession) {
return sshSession as T?
}
return super.getData(dataKey)
}
override fun stop() {
if (mutex.tryLock()) {

View File

@@ -1,70 +0,0 @@
package app.termora
import javax.swing.event.TreeModelEvent
import javax.swing.event.TreeModelListener
import javax.swing.tree.TreeModel
import javax.swing.tree.TreePath
class SearchableHostTreeModel(
private val model: HostTreeModel,
private val filter: (host: Host) -> Boolean = { true }
) : TreeModel {
private var text = String()
override fun getRoot(): Any {
return model.root
}
override fun getChild(parent: Any?, index: Int): Any {
return getChildren(parent)[index]
}
override fun getChildCount(parent: Any?): Int {
return getChildren(parent).size
}
override fun isLeaf(node: Any?): Boolean {
return model.isLeaf(node)
}
override fun valueForPathChanged(path: TreePath?, newValue: Any?) {
return model.valueForPathChanged(path, newValue)
}
override fun getIndexOfChild(parent: Any?, child: Any?): Int {
return getChildren(parent).indexOf(child)
}
override fun addTreeModelListener(l: TreeModelListener) {
model.addTreeModelListener(l)
}
override fun removeTreeModelListener(l: TreeModelListener) {
model.removeTreeModelListener(l)
}
private fun getChildren(parent: Any?): List<Host> {
val children = model.getChildren(parent)
if (children.isEmpty()) return emptyList()
return children.filter { e ->
filter.invoke(e)
&& e.name.contains(text, true)
|| e.host.contains(text, true)
|| TreeUtils.children(model, e, true).filterIsInstance<Host>().any { it.name.contains(text, true) || it.host.contains(text, true) }
}
}
fun search(text: String) {
this.text = text
model.listeners.forEach {
it.treeStructureChanged(
TreeModelEvent(
this, TreePath(root),
null, null
)
)
}
}
}

View File

@@ -4,6 +4,7 @@ import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
@@ -22,6 +23,7 @@ import app.termora.terminal.CursorStyle
import app.termora.terminal.DataKey
import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalPanel
import app.termora.transport.SFTPAction
import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
@@ -34,7 +36,6 @@ import com.jthemedetecor.OsThemeDetector
import com.sun.jna.LastErrorException
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.*
import org.apache.commons.codec.binary.Base64
import org.apache.commons.io.IOUtils
@@ -43,6 +44,7 @@ import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.jdesktop.swingx.JXEditorPane
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.Component
@@ -67,6 +69,7 @@ class SettingsOptionsPane : OptionsPane() {
private val hostManager get() = HostManager.getInstance()
private val keymapManager get() = KeymapManager.getInstance()
private val macroManager get() = MacroManager.getInstance()
private val actionManager get() = ActionManager.getInstance()
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
private val keyManager get() = KeyManager.getInstance()
@@ -195,12 +198,7 @@ class SettingsOptionsPane : OptionsPane() {
if (it.stateChange == ItemEvent.SELECTED) {
appearance.language = languageComboBox.selectedItem as String
SwingUtilities.invokeLater {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.settings.restart.message"),
I18n.getString("termora.settings.restart.title"),
messageType = JOptionPane.INFORMATION_MESSAGE,
)
TermoraRestarter.getInstance().scheduleRestart(owner)
}
}
}
@@ -305,6 +303,7 @@ class SettingsOptionsPane : OptionsPane() {
private val cursorStyleComboBox = FlatComboBox<CursorStyle>()
private val debugComboBox = YesOrNoComboBox()
private val beepComboBox = YesOrNoComboBox()
private val cursorBlinkComboBox = YesOrNoComboBox()
private val fontComboBox = FlatComboBox<String>()
private val shellComboBox = FlatComboBox<String>()
private val maxRowsTextField = IntSpinner(0, 0)
@@ -390,6 +389,12 @@ class SettingsOptionsPane : OptionsPane() {
}
}
cursorBlinkComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.cursorBlink = cursorBlinkComboBox.selectedItem as Boolean
}
}
shellComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
@@ -478,6 +483,7 @@ class SettingsOptionsPane : OptionsPane() {
fontComboBox.selectedItem = terminalSetting.font
debugComboBox.selectedItem = terminalSetting.debug
beepComboBox.selectedItem = terminalSetting.beep
cursorBlinkComboBox.selectedItem = terminalSetting.cursorBlink
cursorStyleComboBox.selectedItem = terminalSetting.cursor
selectCopyComboBox.selectedItem = terminalSetting.selectCopy
autoCloseTabComboBox.selectedItem = terminalSetting.autoCloseTabWhenDisconnected
@@ -499,7 +505,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val beepBtn = JButton(Icons.run)
@@ -526,6 +532,8 @@ class SettingsOptionsPane : OptionsPane() {
.add(selectCopyComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows)
.add(cursorStyleComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.cursor-blink")}:").xy(1, rows)
.add(cursorBlinkComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.floating-toolbar")}:").xy(1, rows)
.add(floatingToolbarComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.auto-close-tab")}:").xy(1, rows)
@@ -1296,8 +1304,11 @@ class SettingsOptionsPane : OptionsPane() {
private inner class SFTPOption : JPanel(BorderLayout()), Option {
val editCommandField = OutlineTextField(255)
private val editCommandField = OutlineTextField(255)
private val sftpCommandField = OutlineTextField(255)
private val pinTabComboBox = YesOrNoComboBox()
private val sftp get() = database.sftp
private val sftpAction get() = actionManager.getAction(Actions.SFTP) as SFTPAction
init {
initView()
@@ -1311,6 +1322,33 @@ class SettingsOptionsPane : OptionsPane() {
sftp.editCommand = editCommandField.text
}
})
sftpCommandField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
sftp.sftpCommand = sftpCommandField.text
}
})
pinTabComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
sftp.pinTab = pinTabComboBox.selectedItem as Boolean
for (window in TermoraFrameManager.getInstance().getWindows()) {
val evt = AnActionEvent(window, StringUtils.EMPTY, EventObject(window))
if (pinTabComboBox.selectedItem == true) {
sftpAction.openOrCreateSFTPTerminalTab(evt)
}
val tabbed = evt.getData(DataProviders.TabbedPane) ?: continue
val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue
for ((index, tab) in manager.getTerminalTabs().withIndex()) {
if (tab is SFTPTerminalTab) {
tabbed.setTabClosable(index, pinTabComboBox.selectedItem != true)
break
}
}
}
}
}
}
@@ -1321,7 +1359,15 @@ class SettingsOptionsPane : OptionsPane() {
editCommandField.placeholderText = "open -a TextEdit {0}"
}
if (SystemInfo.isWindows) {
sftpCommandField.placeholderText = "sftp.exe"
} else {
sftpCommandField.placeholderText = "sftp"
}
editCommandField.text = sftp.editCommand
sftpCommandField.text = sftp.sftpCommand
pinTabComboBox.selectedItem = sftp.pinTab
}
override fun getIcon(isSelected: Boolean): Icon {
@@ -1343,8 +1389,12 @@ class SettingsOptionsPane : OptionsPane() {
)
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, 1)
builder.add(editCommandField).xy(3, 1)
builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, 1)
builder.add(pinTabComboBox).xy(3, 1)
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, 3)
builder.add(editCommandField).xy(3, 3)
builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, 5)
builder.add(sftpCommandField).xy(3, 5)
return builder.build()
@@ -1568,7 +1618,7 @@ class SettingsOptionsPane : OptionsPane() {
val key = doorman.work(passwordTextField.password)
hosts.forEach { hostManager.addHost(it, false) }
hosts.forEach { hostManager.addHost(it) }
keyPairs.forEach { keyManager.addOhKeyPair(it) }
for (e in properties) {
for ((k, v) in e.second) {

View File

@@ -2,10 +2,12 @@ package app.termora
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.channel.ClientChannelEvent
import org.apache.sshd.client.config.hosts.HostConfigEntry
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
import org.apache.sshd.client.config.hosts.KnownHostEntry
@@ -31,6 +33,7 @@ import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
import org.eclipse.jgit.transport.sshd.ProxyData
import org.slf4j.LoggerFactory
import java.awt.Window
import java.io.ByteArrayOutputStream
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.SocketAddress
@@ -38,6 +41,7 @@ import java.nio.file.Path
import java.nio.file.Paths
import java.security.PublicKey
import java.time.Duration
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
@@ -75,6 +79,34 @@ object SshClients {
}
/**
* 执行一个命令
*
* @return first: exitCode , second: response
*/
fun execChannel(
session: ClientSession,
command: String
): Pair<Int, String> {
val baos = ByteArrayOutputStream()
val channel = session.createExecChannel(command)
channel.out = baos
if (channel.open().verify(timeout).await(timeout)) {
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout)
}
IOUtils.closeQuietly(channel)
if (channel.exitStatus == null) {
return Pair(-1, baos.toString())
}
return Pair(channel.exitStatus, baos.toString())
}
/**
* 打开一个会话
*/
@@ -119,7 +151,7 @@ object SshClients {
log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
}
// 映射完毕之后修改Host和端口
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port)
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
}
}

View File

@@ -1,50 +1,67 @@
package app.termora
import app.termora.highlight.KeywordHighlightPaintListener
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector
import app.termora.terminal.Terminal
import app.termora.terminal.panel.TerminalHyperlinkPaintListener
import app.termora.terminal.panel.TerminalPanel
import kotlinx.coroutines.*
import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener
import javax.swing.SwingUtilities
import kotlin.time.Duration.Companion.milliseconds
class TerminalPanelFactory {
class TerminalPanelFactory : Disposable {
private val terminalPanels = mutableListOf<TerminalPanel>()
companion object {
private val Factory = DataKey(TerminalPanelFactory::class)
fun getInstance(scope: Scope): TerminalPanelFactory {
return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
}
fun getAllTerminalPanel(): List<TerminalPanel> {
fun getAllTerminalPanel(): Array<TerminalPanel> {
return ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }
.flatMap { it.getTerminalPanels() }
.flatMap { it.terminalPanels }.toTypedArray()
}
}
init {
// repaint
Painter.getInstance()
}
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
val terminalPanel = TerminalPanel(terminal, ptyConnector)
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
terminal.getTerminalModel().setData(Factory, this)
Disposer.register(terminalPanel, object : Disposable {
override fun dispose() {
terminalPanels.remove(terminalPanel)
if (terminal.getTerminalModel().hasData(Factory)) {
terminal.getTerminalModel().getData(Factory).removeTerminalPanel(terminalPanel)
}
}
})
terminalPanels.add(terminalPanel)
addTerminalPanel(terminalPanel)
return terminalPanel
}
fun getTerminalPanels(): List<TerminalPanel> {
return terminalPanels
fun getTerminalPanels(): Array<TerminalPanel> {
return terminalPanels.toTypedArray()
}
fun repaintAll() {
if (SwingUtilities.isEventDispatchThread()) {
terminalPanels.forEach { it.repaintImmediate() }
getTerminalPanels().forEach { it.repaintImmediate() }
} else {
SwingUtilities.invokeLater { repaintAll() }
}
@@ -62,4 +79,35 @@ class TerminalPanelFactory {
terminalPanels.remove(terminalPanel)
}
fun addTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.add(terminalPanel)
terminalPanel.terminal.getTerminalModel().setData(Factory, this)
}
private class Painter : Disposable {
companion object {
fun getInstance(): Painter {
return ApplicationScope.forApplicationScope().getOrCreate(Painter::class) { Painter() }
}
}
private val coroutineScope = CoroutineScope(Dispatchers.IO)
init {
coroutineScope.launch {
while (coroutineScope.isActive) {
delay(500.milliseconds)
SwingUtilities.invokeLater {
ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }.forEach { it.repaintAll() }
}
}
}
}
override fun dispose() {
coroutineScope.cancel()
}
}
}

View File

@@ -73,12 +73,17 @@ class TerminalTabbed(
tabbedPane.addPropertyChangeListener("selectedIndex") { evt ->
val oldIndex = evt.oldValue as Int
val newIndex = evt.newValue as Int
if (oldIndex >= 0 && tabs.size > newIndex) {
tabs[oldIndex].onLostFocus()
}
if (newIndex >= 0 && tabs.size > newIndex) {
tabs[newIndex].onGrabFocus()
}
SwingUtilities.invokeLater { tabbedPane.getComponentAt(newIndex).requestFocusInWindow() }
}
// 选择变动
@@ -174,6 +179,9 @@ class TerminalTabbed(
// 新的获取到焦点
tabs[tabbedPane.selectedIndex].onGrabFocus()
// 新的真正获取焦点
tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow()
if (disposable) {
Disposer.dispose(tab)
}
@@ -272,7 +280,7 @@ class TerminalTabbed(
}
close.isEnabled = c !is WelcomePanel
close.isEnabled = tab.canClose()
rename.isEnabled = close.isEnabled
clone.isEnabled = close.isEnabled
openInNewWindow.isEnabled = close.isEnabled
@@ -298,7 +306,7 @@ class TerminalTabbed(
}
private fun addTab(index: Int, tab: TerminalTab) {
private fun addTab(index: Int, tab: TerminalTab, selected: Boolean) {
val c = tab.getJComponent()
val title = (c.getClientProperty(titleProperty) ?: tab.getTitle()).toString()
@@ -309,13 +317,20 @@ class TerminalTabbed(
StringUtils.EMPTY,
index
)
c.putClientProperty(titleProperty, title)
// 设置标题
c.putClientProperty(titleProperty, title)
// 监听 icons 变化
tab.addPropertyChangeListener(iconListener)
tabs.add(index, tab)
tabbedPane.selectedIndex = index
if (selected) {
tabbedPane.selectedIndex = index
}
tabbedPane.setTabClosable(index, tab.canClose())
Disposer.register(this, tab)
}
@@ -341,7 +356,7 @@ class TerminalTabbed(
}
host = host.copy(
protocol = Protocol.SFTPPty,
protocol = Protocol.SFTPPty, updateDate = System.currentTimeMillis(),
options = host.options.copy(env = envs.toPropertiesString())
)
}
@@ -437,12 +452,12 @@ class TerminalTabbed(
override fun dispose() {
}
override fun addTerminalTab(tab: TerminalTab) {
addTab(tabs.size, tab)
override fun addTerminalTab(tab: TerminalTab, selected: Boolean) {
addTab(tabs.size, tab, selected)
}
override fun addTerminalTab(index: Int, tab: TerminalTab) {
addTab(index, tab)
override fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean) {
addTab(index, tab, selected)
}
override fun getSelectedTerminalTab(): TerminalTab? {

View File

@@ -1,8 +1,8 @@
package app.termora
interface TerminalTabbedManager {
fun addTerminalTab(tab: TerminalTab)
fun addTerminalTab(index: Int, tab: TerminalTab)
fun addTerminalTab(tab: TerminalTab, selected: Boolean = true)
fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean = true)
fun getSelectedTerminalTab(): TerminalTab?
fun getTerminalTabs(): List<TerminalTab>
fun setSelectedTerminalTab(tab: TerminalTab)

View File

@@ -1,7 +1,6 @@
package app.termora
import app.termora.actions.ActionManager
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders
@@ -12,7 +11,6 @@ import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import java.awt.Dimension
import java.awt.Insets
import java.awt.KeyboardFocusManager
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.*
@@ -32,7 +30,6 @@ fun assertEventDispatchThread() {
class TermoraFrame : JFrame(), DataProvider {
private val actionManager get() = ActionManager.getInstance()
private val id = UUID.randomUUID().toString()
private val windowScope = ApplicationScope.forWindowScope(this)
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
@@ -42,7 +39,7 @@ class TermoraFrame : JFrame(), DataProvider {
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
private val dataProviderSupport = DataProviderSupport()
private val welcomePanel = WelcomePanel(windowScope)
private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() }
private val sftp get() = Database.getDatabase().sftp
init {
@@ -103,6 +100,13 @@ class TermoraFrame : JFrame(), DataProvider {
minimumSize = Dimension(640, 400)
terminalTabbed.addTerminalTab(welcomePanel)
// 下一次事件循环检测是否固定 SFTP
SwingUtilities.invokeLater {
if (sftp.pinTab) {
terminalTabbed.addTerminalTab(SFTPTerminalTab(), false)
}
}
// macOS 要避开左边的控制栏
if (SystemInfo.isMacOS) {
val left = max(titleBar.leftInset.toInt(), 76)

View File

@@ -19,6 +19,8 @@ class TermoraFrameManager {
}
}
private val frames = mutableListOf<TermoraFrame>()
fun createWindow(): TermoraFrame {
val frame = TermoraFrame()
registerCloseCallback(frame)
@@ -26,16 +28,26 @@ class TermoraFrameManager {
frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
frame.setSize(1280, 800)
frame.setLocationRelativeTo(null)
frames.add(frame)
return frame
}
fun getWindows(): Array<TermoraFrame> {
return frames.toTypedArray()
}
private fun registerCloseCallback(window: TermoraFrame) {
window.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
// 删除
frames.remove(window)
// dispose windowScope
Disposer.dispose(ApplicationScope.forWindowScope(e.window))
val windowScope = ApplicationScope.forWindowScope(e.window)
Disposer.disposeChildren(windowScope, null)
Disposer.dispose(windowScope)
val windowScopes = ApplicationScope.windowScopes()
@@ -68,7 +80,9 @@ class TermoraFrameManager {
try {
Disposer.getTree().assertIsEmpty(true)
} catch (e: Exception) {
log.error(e.message)
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
exitProcess(0)

View File

@@ -0,0 +1,155 @@
package app.termora
import com.formdev.flatlaf.util.SystemInfo
import com.github.hstyi.restart4j.Restarter
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.awt.Component
import java.nio.file.Paths
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import kotlin.jvm.optionals.getOrNull
class TermoraRestarter {
companion object {
private val log = LoggerFactory.getLogger(TermoraRestarter::class.java)
fun getInstance(): TermoraRestarter {
return ApplicationScope.forApplicationScope().getOrCreate(TermoraRestarter::class) { TermoraRestarter() }
}
init {
Restarter.setProcessHandler { ProcessHandle.current().pid().toInt() }
Restarter.setExecCommandsHandler { commands ->
val pb = ProcessBuilder(commands)
if (SystemInfo.isLinux) {
// 去掉链接库变量
pb.environment().remove("LD_LIBRARY_PATH")
}
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD)
pb.redirectError(ProcessBuilder.Redirect.DISCARD)
pb.directory(Paths.get(System.getProperty("user.home")).toFile())
pb.start()
}
}
}
private val restarting = AtomicBoolean(false)
private val isSupported get() = !restarting.get() && checkIsSupported()
private val isLinuxAppImage by lazy { System.getenv("LinuxAppImage")?.toBoolean() == true }
private val startupCommand by lazy { ProcessHandle.current().info().command().getOrNull() }
private val macOSApplicationPath by lazy {
StringUtils.removeEndIgnoreCase(
Application.getAppPath(),
"/Contents/MacOS/Termora"
)
}
private fun restart(commands: List<String>) {
if (!isSupported) return
if (!restarting.compareAndSet(false, true)) return
SwingUtilities.invokeLater {
try {
doRestart(commands)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
/**
* 计划重启,如果当前进程支持重启,那么会询问用户是否重启。如果不支持重启,那么弹窗提示需要手动重启。
*/
fun scheduleRestart(owner: Component?, commands: List<String> = emptyList()) {
if (isSupported) {
if (OptionPane.showConfirmDialog(
owner,
I18n.getString("termora.settings.restart.message"),
I18n.getString("termora.settings.restart.title"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.YES_NO_OPTION,
options = arrayOf(
I18n.getString("termora.settings.restart.title"),
I18n.getString("termora.cancel")
),
initialValue = I18n.getString("termora.settings.restart.title")
) == JOptionPane.YES_OPTION
) {
restart(commands)
}
} else {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.settings.restart.message"),
I18n.getString("termora.settings.restart.title"),
messageType = JOptionPane.INFORMATION_MESSAGE,
)
}
}
private fun doRestart(commands: List<String>) {
if (commands.isEmpty()) {
if (SystemInfo.isMacOS) {
Restarter.restart(arrayOf("open", "-n", macOSApplicationPath))
} else if (SystemInfo.isWindows && startupCommand != null) {
Restarter.restart(arrayOf(startupCommand))
} else if (SystemInfo.isLinux) {
if (isLinuxAppImage) {
Restarter.restart(arrayOf(System.getenv("APPIMAGE")))
} else if (startupCommand != null) {
Restarter.restart(arrayOf(startupCommand))
}
}
} else {
Restarter.restart(commands.toTypedArray())
}
for (window in TermoraFrameManager.getInstance().getWindows()) {
window.dispose()
}
}
private fun checkIsSupported(): Boolean {
val appPath = Application.getAppPath()
if (appPath.isBlank() || Application.isUnknownVersion()) {
if (log.isWarnEnabled) {
log.warn("Restart not supported")
}
return false
}
if (SystemInfo.isWindows && startupCommand == null) {
if (log.isWarnEnabled) {
log.warn("Restart not supported , ProcessHandle#info#command is null.")
}
return false
}
if (SystemInfo.isLinux) {
if (isLinuxAppImage) {
val appImage = System.getenv("APPIMAGE") ?: StringUtils.EMPTY
return appImage.isNotBlank() && FileUtils.getFile(appImage).exists()
}
return startupCommand != null
}
if (SystemInfo.isMacOS) {
return Application.getAppPath().isNotBlank()
}
return true
}
}

View File

@@ -70,6 +70,9 @@ class UpdaterManager private constructor() {
.build()
val response = Application.httpClient.newCall(request).execute()
if (!response.isSuccessful) {
if (log.isErrorEnabled) {
log.error("Failed to fetch latest version, response was ${response.code}")
}
return LatestVersion.self
}
@@ -151,8 +154,4 @@ class UpdaterManager private constructor() {
fun ignore(version: String) {
properties.putString("ignored.version.$version", "true")
}
private fun doGetLatestVersion() {
}
}

View File

@@ -2,7 +2,6 @@ package app.termora
import app.termora.actions.*
import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.terminal.DataKey
@@ -27,11 +26,15 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
private val properties get() = Database.getDatabase().properties
private val rootPanel = JPanel(BorderLayout())
private val searchTextField = FlatTextField()
private val hostTree = HostTree()
private val hostTree = NewHostTree()
private val bannerPanel = BannerPanel()
private val toggle = FlatButton()
private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean()
private val dataProviderSupport = DataProviderSupport()
private val hostTreeModel = hostTree.model as NewHostTreeModel
private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) {
searchTextField.text.isBlank()
}
init {
initView()
@@ -126,8 +129,6 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
})
hostTree.showsRootHandles = true
Disposer.register(this, hostTree)
val scrollPane = JScrollPane(hostTree)
scrollPane.verticalScrollBar.maximumSize = Dimension(0, 0)
scrollPane.verticalScrollBar.preferredSize = Dimension(0, 0)
@@ -138,6 +139,11 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
panel.add(scrollPane, BorderLayout.CENTER)
panel.border = BorderFactory.createEmptyBorder(10, 0, 0, 0)
hostTree.model = filterableHostTreeModel
TreeUtils.loadExpansionState(
hostTree,
properties.getString("Welcome.HostTree.state", StringUtils.EMPTY)
)
return panel
}
@@ -163,37 +169,49 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
})
FindEverywhereProvider.getFindEverywhereProviders(windowScope)
.add(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider {
override fun find(pattern: String): List<FindEverywhereResult> {
return TreeUtils.children(hostTree.model, hostTree.model.root)
.filterIsInstance<Host>()
.filter { it.protocol != Protocol.Folder }
.map { HostFindEverywhereResult(it) }
FindEverywhereProvider.getFindEverywhereProviders(windowScope).add(object : FindEverywhereProvider {
override fun find(pattern: String): List<FindEverywhereResult> {
var filter = hostTreeModel.root.getAllChildren()
.map { it.host }
.filter { it.protocol != Protocol.Folder }
if (pattern.isNotBlank()) {
filter = filter.filter {
if (it.protocol == Protocol.SSH) {
it.name.contains(pattern, true) || it.host.contains(pattern, true)
} else {
it.name.contains(pattern, true)
}
}
}
override fun group(): String {
return I18n.getString("termora.find-everywhere.groups.open-new-hosts")
}
return filter.map { HostFindEverywhereResult(it) }
}
override fun order(): Int {
return Integer.MIN_VALUE + 2
}
}))
override fun group(): String {
return I18n.getString("termora.find-everywhere.groups.open-new-hosts")
}
override fun order(): Int {
return Integer.MIN_VALUE + 2
}
})
filterableHostTreeModel.addFilter {
val text = searchTextField.text
val host = it.host
text.isBlank() || host.name.contains(text, true)
|| host.host.contains(text, true)
|| host.username.contains(text, true)
}
searchTextField.document.addDocumentListener(object : DocumentAdaptor() {
private var state = StringUtils.EMPTY
override fun changedUpdate(e: DocumentEvent) {
val text = searchTextField.text
if (text.isBlank()) {
hostTree.setModel(hostTree.model)
TreeUtils.loadExpansionState(hostTree, state)
state = String()
} else {
if (state.isBlank()) state = TreeUtils.saveExpansionState(hostTree)
hostTree.setModel(hostTree.searchableModel)
hostTree.searchableModel.search(text)
TreeUtils.expandAll(hostTree)
filterableHostTreeModel.refresh()
if (text.isNotBlank()) {
hostTree.expandAll()
}
}
})
@@ -241,11 +259,13 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
}
override fun dispose() {
hostTree.setModel(null)
properties.putString("WelcomeFullContent", fullContent.toString())
properties.putString("Welcome.HostTree.state", TreeUtils.saveExpansionState(hostTree))
}
private class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
private inner class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
private val showMoreInfo get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
override fun actionPerformed(e: ActionEvent) {
ActionManager.getInstance()
.getAction(OpenHostAction.OPEN_HOST)
@@ -261,7 +281,18 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
return Icons.terminal
}
override fun toString(): String {
override fun getText(isSelected: Boolean): String {
if (showMoreInfo) {
val color = UIManager.getColor(if (isSelected) "textHighlightText" else "textInactiveText")
val moreInfo = when (host.protocol) {
Protocol.SSH -> "${host.username}@${host.host}"
Protocol.Serial -> host.options.serialComm.port
else -> StringUtils.EMPTY
}
if (moreInfo.isNotBlank()) {
return "<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;<font color=rgb(${color.red},${color.green},${color.blue})>${moreInfo}</font></html>"
}
}
return host.name
}
}

View File

@@ -29,10 +29,11 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() {
addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction())
addAction(Actions.MULTIPLE, MultipleAction())
addAction(Actions.APP_UPDATE, AppUpdateAction())
addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance())
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
addAction(Actions.SFTP, SFTPAction())
addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction())
addAction(Actions.MACRO, MacroAction())
addAction(Actions.KEY_MANAGER, KeyManagerAction())

View File

@@ -1,14 +1,28 @@
package app.termora.actions
import app.termora.*
import app.termora.Application.httpClient
import com.formdev.flatlaf.util.SystemInfo
import com.sun.jna.platform.win32.Advapi32
import com.sun.jna.platform.win32.WinError
import com.sun.jna.platform.win32.WinNT
import com.sun.jna.platform.win32.WinReg
import io.github.g00fy2.versioncompare.Version
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import okhttp3.Request
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXEditorPane
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.KeyboardFocusManager
import java.io.File
import java.net.ProxySelector
import java.net.URI
import java.util.*
import java.util.concurrent.TimeUnit
import javax.swing.BorderFactory
import javax.swing.JOptionPane
import javax.swing.JScrollPane
@@ -18,11 +32,20 @@ import kotlin.concurrent.fixedRateTimer
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
class AppUpdateAction : AnAction(
class AppUpdateAction private constructor() : AnAction(
StringUtils.EMPTY,
Icons.ideUpdate
) {
companion object {
private val log = LoggerFactory.getLogger(AppUpdateAction::class.java)
private const val PKG_FILE_KEY = "pkgFile"
fun getInstance(): AppUpdateAction {
return ApplicationScope.forApplicationScope().getOrCreate(AppUpdateAction::class) { AppUpdateAction() }
}
}
private val updaterManager get() = UpdaterManager.getInstance()
init {
@@ -63,11 +86,75 @@ class AppUpdateAction : AnAction(
return
}
withContext(Dispatchers.Swing) {
ActionManager.getInstance()
.setEnabled(Actions.APP_UPDATE, true)
try {
downloadLatestPkg(latestVersion)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
withContext(Dispatchers.Swing) { isEnabled = true }
}
private suspend fun downloadLatestPkg(latestVersion: UpdaterManager.LatestVersion) {
if (SystemInfo.isLinux) return
super.putValue(PKG_FILE_KEY, null)
val arch = if (SystemInfo.isAARCH64) "aarch64" else "x86-64"
val osName = if (SystemInfo.isWindows) "windows" else "osx"
val suffix = if (SystemInfo.isWindows) "exe" else "dmg"
val filename = "termora-${latestVersion.version}-${osName}-${arch}.${suffix}"
val asset = latestVersion.assets.find { it.name == filename } ?: return
val response = httpClient
.newBuilder()
.callTimeout(15, TimeUnit.MINUTES)
.readTimeout(15, TimeUnit.MINUTES)
.proxySelector(ProxySelector.getDefault())
.build()
.newCall(Request.Builder().url(asset.downloadUrl).build())
.execute()
if (!response.isSuccessful) {
if (log.isErrorEnabled) {
log.warn("Failed to download latest version ${latestVersion.version}, response code ${response.code}")
}
IOUtils.closeQuietly(response)
return
}
val body = response.body
val input = body?.byteStream()
val file = FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}-${filename}")
val output = file.outputStream()
val downloaded = runCatching { IOUtils.copy(input, output) }.isSuccess
IOUtils.closeQuietly(input, output, body, response)
if (!downloaded) {
if (log.isErrorEnabled) {
log.error("Failed to download latest version to $filename")
}
return
}
if (log.isInfoEnabled) {
log.info("Successfully downloaded latest version to $file")
}
withContext(Dispatchers.Swing) { setLatestPkgFile(file) }
}
private fun setLatestPkgFile(file: File) {
putValue(PKG_FILE_KEY, file)
}
private fun getLatestPkgFile(): File? {
return getValue(PKG_FILE_KEY) as? File
}
private fun showUpdateDialog() {
@@ -106,12 +193,59 @@ class AppUpdateAction : AnAction(
if (option == JOptionPane.CANCEL_OPTION) {
return
} else if (option == JOptionPane.NO_OPTION) {
ActionManager.getInstance().setEnabled(Actions.APP_UPDATE, false)
updaterManager.ignore(updaterManager.lastVersion.version)
isEnabled = false
updaterManager.ignore(lastVersion.version)
} else if (option == JOptionPane.YES_OPTION) {
ActionManager.getInstance()
.setEnabled(Actions.APP_UPDATE, false)
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${lastVersion.version}"))
updateSelf(lastVersion)
}
}
private fun updateSelf(latestVersion: UpdaterManager.LatestVersion) {
val file = getLatestPkgFile()
if (SystemInfo.isLinux || file == null) {
isEnabled = false
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${latestVersion.version}"))
return
}
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
val commands = if (SystemInfo.isMacOS) listOf("open", "-n", file.absolutePath)
// 如果安装过,那么直接静默安装和自动启动
else if (isAppInstalled()) listOf(
file.absolutePath,
"/SILENT",
"/AUTOSTART",
"/NORESTART",
"/FORCECLOSEAPPLICATIONS"
)
// 没有安装过 则打开安装向导
else listOf(file.absolutePath)
println(commands)
TermoraRestarter.getInstance().scheduleRestart(owner, commands)
}
private fun isAppInstalled(): Boolean {
val keyPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${Application.getName()}_is1"
val phkKey = WinReg.HKEYByReference()
// 尝试打开注册表键
val result = Advapi32.INSTANCE.RegOpenKeyEx(
WinReg.HKEY_LOCAL_MACHINE,
keyPath,
0,
WinNT.KEY_READ,
phkKey
)
if (result == WinError.ERROR_SUCCESS) {
// 键存在,关闭句柄
Advapi32.INSTANCE.RegCloseKey(phkKey.getValue())
return true
} else {
// 键不存在或无权限
return false
}
}
}

View File

@@ -17,6 +17,6 @@ object DataProviders {
object Welcome {
val HostTree = DataKey(app.termora.HostTree::class)
val HostTree = DataKey(app.termora.NewHostTree::class)
}
}

View File

@@ -1,9 +1,6 @@
package app.termora.actions
import app.termora.Host
import app.termora.HostDialog
import app.termora.HostManager
import app.termora.Protocol
import app.termora.*
import javax.swing.tree.TreePath
class NewHostAction : AnAction() {
@@ -20,27 +17,27 @@ class NewHostAction : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tree = evt.getData(DataProviders.Welcome.HostTree) ?: return
val model = tree.model
var lastHost = tree.lastSelectedPathComponent ?: model.root
if (lastHost !is Host) {
return
}
if (lastHost.protocol != Protocol.Folder) {
val p = model.getParent(lastHost) ?: return
lastHost = p
var lastNode = (tree.lastSelectedPathComponent ?: tree.model.root) as? HostTreeNode ?: return
if (lastNode.host.protocol != Protocol.Folder) {
lastNode = lastNode.parent ?: return
}
val lastHost = lastNode.host
val dialog = HostDialog(evt.window)
dialog.setLocationRelativeTo(evt.window)
dialog.isVisible = true
val host = (dialog.host ?: return).copy(parentId = lastHost.id)
hostManager.addHost(host)
val newNode = HostTreeNode(host)
tree.expandNode(lastHost)
val model = if (tree.model is FilterableHostTreeModel) (tree.model as FilterableHostTreeModel).getModel()
else tree.model
tree.selectionPath = TreePath(model.getPathToRoot(host))
if (model is NewHostTreeModel) {
model.insertNodeInto(newNode, lastNode, lastNode.childCount)
tree.selectionPath = TreePath(model.getPathToRoot(newNode))
}
}
}

View File

@@ -0,0 +1,30 @@
package app.termora.actions
import app.termora.HostTerminalTab
import app.termora.I18n
import app.termora.OpenHostActionEvent
import app.termora.Protocol
class SFTPCommandAction : AnAction() {
companion object {
/**
* 打开 SFTP command
*/
const val SFTP_COMMAND = "SFTPCommandAction"
}
init {
putValue(ACTION_COMMAND_KEY, SFTP_COMMAND)
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.open-sftp-command"))
}
override fun actionPerformed(evt: AnActionEvent) {
val actionManager = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val tab = terminalTabbedManager.getSelectedTerminalTab() as? HostTerminalTab ?: return
val host = tab.host
if (!(host.protocol == Protocol.SSH || host.protocol == Protocol.SFTPPty)) return
actionManager.actionPerformed(OpenHostActionEvent(evt.source, host.copy(protocol = Protocol.SFTPPty), evt))
evt.consume()
}
}

View File

@@ -8,5 +8,6 @@ interface FindEverywhereResult : ActionListener {
fun getIcon(isSelected: Boolean): Icon = Icons.empty
fun getText(isSelected: Boolean) = toString()
}

View File

@@ -94,16 +94,16 @@ class FindEverywhereXList(private val model: DefaultListModel<FindEverywhereResu
label.font = font.deriveFont(font.size - 2f)
val box = Box.createHorizontalBox()
box.add(label)
/*box.add(object : JComponent() {
override fun paintComponent(g: Graphics) {
g.color = DynamicColor.BorderColor
g.drawLine(10, height / 2, width, height / 2)
}
})*/
return box
}
val c = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
val c = super.getListCellRendererComponent(
list,
if (value is FindEverywhereResult) value.getText(isSelected) else value,
index,
isSelected,
cellHasFocus
)
if (isSelected) {
background = UIManager.getColor("List.selectionBackground")
foreground = UIManager.getColor("List.selectionForeground")

View File

@@ -76,6 +76,12 @@ class KeymapImpl(private val menuShortcutKeyMaskEx: Int) : Keymap("Keymap", null
KeyShortcut(KeyStroke.getKeyStroke(KeyEvent.VK_R, menuShortcutKeyMaskEx or InputEvent.SHIFT_DOWN_MASK))
)
// Command + Shift + P
addShortcut(
SFTPCommandAction.SFTP_COMMAND,
KeyShortcut(KeyStroke.getKeyStroke(KeyEvent.VK_P, menuShortcutKeyMaskEx or InputEvent.SHIFT_DOWN_MASK))
)
// switch map
for (i in KeyEvent.VK_1..KeyEvent.VK_9) {

View File

@@ -27,10 +27,11 @@ class KeymapTableModel : DefaultTableModel() {
TerminalZoomOutAction.ZOOM_OUT,
TerminalZoomResetAction.ZOOM_RESET,
OpenLocalTerminalAction.LOCAL_TERMINAL,
TerminalClearScreenAction.CLEAR_SCREEN,
FindEverywhereAction.FIND_EVERYWHERE,
NewWindowAction.NEW_WINDOW,
TabReconnectAction.RECONNECT_TAB,
TerminalClearScreenAction.CLEAR_SCREEN,
SFTPCommandAction.SFTP_COMMAND,
SwitchTabAction.SWITCH_TAB,
)) {
val action = actionManager.getAction(id) ?: continue

View File

@@ -211,9 +211,9 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
}
val owner = SwingUtilities.getWindowAncestor(this) ?: return
val hostTreeDialog = HostTreeDialog(owner) {
it.protocol == Protocol.SSH
}
val hostTreeDialog = NewHostTreeDialog(owner)
hostTreeDialog.setFilter { it.host.protocol == Protocol.SSH }
hostTreeDialog.setTreeName("KeyManagerPanel.SSHCopyIdTree")
hostTreeDialog.isVisible = true
val hosts = hostTreeDialog.hosts
if (hosts.isEmpty()) {

View File

@@ -42,6 +42,7 @@ class SSHCopyIdDialog(
}
private val terminalPanel by lazy {
terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate())
.apply { enableFloatingToolbar = false }
}
private val coroutineScope = CoroutineScope(Job() + Dispatchers.IO)
@@ -152,7 +153,7 @@ class SSHCopyIdDialog(
val baos = ByteArrayOutputStream()
channel.out = baos
if (channel.open().verify(timeout).await(timeout)) {
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout);
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout)
}
if (channel.exitStatus != 0) {
throw IllegalStateException("Server response: ${channel.exitStatus}")

View File

@@ -3,8 +3,11 @@ package app.termora.terminal.panel
import app.termora.*
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders
import app.termora.terminal.DataKey
import app.termora.terminal.panel.vw.NvidiaSMIVisualWindow
import app.termora.terminal.panel.vw.SystemInformationVisualWindow
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.ui.FlatRoundBorder
import org.apache.commons.lang3.StringUtils
@@ -70,6 +73,11 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
initActions()
}
override fun updateUI() {
super.updateUI()
border = FlatRoundBorder()
}
fun triggerShow() {
if (!floatingToolbarEnable || closed) {
return
@@ -98,6 +106,12 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
// Pin
add(initPinActionButton())
// 服务器信息
add(initServerInfoActionButton())
// Nvidia 显卡信息
add(initNvidiaSMIActionButton())
// 重连
add(initReconnectActionButton())
@@ -105,6 +119,62 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
add(initCloseActionButton())
}
private fun initServerInfoActionButton(): JButton {
val btn = JButton(Icons.infoOutline)
btn.toolTipText = I18n.getString("termora.visual-window.system-information")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = evt.getData(DataProviders.TerminalTab) ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
if (tab !is SSHTerminalTab) {
terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported"))
return
}
for (window in terminalPanel.getVisualWindows()) {
if (window is SystemInformationVisualWindow) {
terminalPanel.moveToFront(window)
return
}
}
val visualWindowPanel = SystemInformationVisualWindow(tab, terminalPanel)
terminalPanel.addVisualWindow(visualWindowPanel)
}
})
return btn
}
private fun initNvidiaSMIActionButton(): JButton {
val btn = JButton(Icons.nvidia)
btn.toolTipText = I18n.getString("termora.visual-window.nvidia-smi")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = evt.getData(DataProviders.TerminalTab) ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
if (tab !is SSHTerminalTab) {
terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported"))
return
}
for (window in terminalPanel.getVisualWindows()) {
if (window is NvidiaSMIVisualWindow) {
terminalPanel.moveToFront(window)
return
}
}
val visualWindowPanel = NvidiaSMIVisualWindow(tab, terminalPanel)
terminalPanel.addVisualWindow(visualWindowPanel)
}
})
return btn
}
private fun initPinActionButton(): JButton {
val btn = JButton(Icons.pin)
btn.isSelected = pinAction.isSelected

View File

@@ -0,0 +1,119 @@
package app.termora.terminal.panel
import app.termora.ApplicationScope
import app.termora.Database
import app.termora.Disposable
import app.termora.terminal.*
import kotlinx.coroutines.*
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.milliseconds
class TerminalBlink(terminal: Terminal) : Disposable {
private var cursorBlinkJob: Job? = null
private val terminalSettings get() = Database.getDatabase().terminal
private val isDisposed = AtomicBoolean(false)
private val globalBlink get() = GlobalBlink.getInstance()
private val coroutineScope get() = globalBlink.coroutineScope
/**
* 返回 true 表示可以显示某些内容 [TextStyle.blink]
*/
val blink get() = globalBlink.blink
/**
* 这个与 [blink] 不同的是它是控制光标的
*/
@Volatile
var cursorBlink = true
private set
init {
reset()
// 如果有写入,那么显示光标 N 秒
terminal.getTerminalModel().addDataListener(object : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
// 写入后,重置光标
if (key == VisualTerminal.Written) {
reset()
} else if (key == TerminalPanel.Focused) {
// 获取焦点的一瞬间则立即重置
if (data == true) {
reset()
}
}
}
})
}
private fun reset() {
if (isDisposed.get()) {
return
}
cursorBlink = true
cursorBlinkJob?.cancel()
cursorBlinkJob = coroutineScope.launch {
while (coroutineScope.isActive) {
delay(500.milliseconds)
if (isDisposed.get()) {
break
}
// 如果开启了光标闪烁才闪速
cursorBlink = if (terminalSettings.cursorBlink) {
!cursorBlink
} else {
true
}
}
}
}
override fun dispose() {
if (isDisposed.compareAndSet(false, true)) {
cursorBlinkJob?.cancel()
}
}
private class GlobalBlink : Disposable {
companion object {
fun getInstance(): GlobalBlink {
return ApplicationScope.forApplicationScope()
.getOrCreate(GlobalBlink::class) { GlobalBlink() }
}
}
val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }
/**
* 返回 true 表示可以显示某些内容 [TextStyle.blink]
*/
@Volatile
var blink = true
private set
init {
coroutineScope.launch {
while (coroutineScope.isActive) {
delay(500)
blink = !blink
}
}
}
override fun dispose() {
coroutineScope.cancel()
}
}
}

View File

@@ -17,6 +17,7 @@ import kotlin.time.Duration
class TerminalDisplay(
private val terminalPanel: TerminalPanel,
private val terminal: Terminal,
private val terminalBlink: TerminalBlink
) : JComponent() {
companion object {
@@ -132,31 +133,30 @@ class TerminalDisplay(
g.fillRect(0, 0, width, height)
}
private fun drawCursor(g: Graphics, xOffset: Int, width: Int) {
private fun drawCursor(g: Graphics, y: Int, xOffset: Int, width: Int) {
val lineHeight = getLineHeight()
val position = terminal.getCursorModel().getPosition()
val row = position.y
val style = if (inputMethodData.isNoTyping)
terminal.getTerminalModel().getData(DataKey.CursorStyle) else CursorStyle.Bar
val hasFocus = terminal.getTerminalModel().getData(TerminalPanel.Focused, false)
// background
g.color = Color(colorPalette.getColor(TerminalColor.Cursor.BACKGROUND))
if (style == CursorStyle.Block) {
if (terminalPanel.hasFocus()) {
g.fillRect(xOffset, (row - 1) * lineHeight, width, lineHeight)
if (hasFocus) {
g.fillRect(xOffset, (y - 1) * lineHeight, width, lineHeight)
} else {
g.drawRect(xOffset, (row - 1) * lineHeight, width, lineHeight)
g.drawRect(xOffset, (y - 1) * lineHeight, width, lineHeight)
}
} else if (style == CursorStyle.Underline) {
val h = ceil(lineHeight / 10.0).toInt()
g.fillRect(xOffset, row * lineHeight - h / 2, width, h)
g.fillRect(xOffset, y * lineHeight - h / 2, width, h)
} else if (style == CursorStyle.Bar) {
if (inputMethodData.isTyping) {
val w = ceil(width / 3.5).toInt()
g.fillRect(xOffset, (row - 1) * lineHeight, w, lineHeight)
g.fillRect(xOffset, (y - 1) * lineHeight, w, lineHeight)
} else {
g.drawLine(xOffset, row * lineHeight - lineHeight, xOffset, row * lineHeight)
g.drawLine(xOffset, y * lineHeight - lineHeight, xOffset, y * lineHeight)
}
}
}
@@ -219,19 +219,23 @@ class TerminalDisplay(
}
private fun drawCharacters(g: Graphics2D) {
val reverseVideo = terminal.getTerminalModel().getData(DataKey.ReverseVideo, false)
val rows = terminal.getTerminalModel().getRows()
val cols = terminal.getTerminalModel().getCols()
val buffer = terminal.getDocument().getCurrentTerminalLineBuffer()
val terminalModel = terminal.getTerminalModel()
val reverseVideo = terminalModel.getData(DataKey.ReverseVideo, false)
val rows = terminalModel.getRows()
val cols = terminalModel.getCols()
val triple = Triple(Char.Space.toString(), TextStyle.Default, 1)
val cursorPosition = terminal.getCursorModel().getPosition()
val averageCharWidth = getAverageCharWidth()
val maxVerticalScrollOffset = terminal.getScrollingModel().getMaxVerticalScrollOffset()
val verticalScrollOffset = terminal.getScrollingModel().getVerticalScrollOffset()
val selectionModel = terminal.getSelectionModel()
val cursorStyle = terminal.getTerminalModel().getData(DataKey.CursorStyle)
val showCursor = terminal.getTerminalModel().getData(DataKey.ShowCursor)
val cursorStyle = terminalModel.getData(DataKey.CursorStyle)
val showCursor = terminalModel.getData(DataKey.ShowCursor)
val markupModel = terminal.getMarkupModel()
val lineHeight = getLineHeight()
val blink = terminalBlink.blink
val cursorBlink = terminalBlink.cursorBlink
val hasFocus = terminalModel.getData(TerminalPanel.Focused, false)
for (i in 1..rows) {
@@ -242,7 +246,7 @@ class TerminalDisplay(
while (j <= cols) {
val position = Position(row + 1, j)
val caret = showCursor && j == cursorPosition.x + inputMethodData.offset
&& row + 1 == cursorPosition.y + buffer.getBufferCount()
&& i == cursorPosition.y + (maxVerticalScrollOffset - verticalScrollOffset)
val (text, style, length) = if (characters.hasNext()) characters.next() else triple
var textStyle = style
@@ -271,6 +275,13 @@ class TerminalDisplay(
background = colorPalette.getColor(TerminalColor.Basic.SELECTION_BACKGROUND)
}
// 如果启用了闪烁
if (textStyle.blink) {
if (!blink) {
continue
}
}
// 设置字体
g.font = getDisplayFont(text, textStyle)
val charWidth = min(
@@ -312,12 +323,15 @@ class TerminalDisplay(
// 渲染光标
if (caret) {
drawCursor(g, xOffset, charWidth)
// 如果是获取焦点状态,那么颜色互换
if (terminalPanel.hasFocus() && cursorStyle == CursorStyle.Block && inputMethodData.isNoTyping) {
g.color = Color(colorPalette.getColor(TerminalColor.Basic.BACKGROUND))
} else {
g.color = Color(foreground)
// 这几种情况光标才会渲染:输入中、闪烁中、没有焦点
if (inputMethodData.isTyping || cursorBlink || !hasFocus) {
drawCursor(g, i, xOffset, charWidth)
// 如果是获取焦点状态,那么颜色互换
if (hasFocus && cursorStyle == CursorStyle.Block && inputMethodData.isNoTyping) {
g.color = Color(colorPalette.getColor(TerminalColor.Basic.BACKGROUND))
} else {
g.color = Color(foreground)
}
}
}

View File

@@ -6,7 +6,10 @@ import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders
import app.termora.terminal.*
import app.termora.terminal.panel.vw.VisualWindow
import app.termora.terminal.panel.vw.VisualWindowManager
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.lang3.ArrayUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import java.awt.*
@@ -32,20 +35,33 @@ import kotlin.time.Duration.Companion.milliseconds
class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnector) :
JPanel(BorderLayout()), DataProvider, Disposable {
JPanel(BorderLayout()), DataProvider, Disposable, VisualWindowManager {
companion object {
val Debug = DataKey(Boolean::class)
val Finding = DataKey(Boolean::class)
val Focused = DataKey(Boolean::class)
val SelectCopy = DataKey(Boolean::class)
}
private val terminalBlink = TerminalBlink(terminal)
private val terminalFindPanel = TerminalFindPanel(this, terminal)
private val floatingToolbar = FloatingToolbarPanel()
private val terminalDisplay = TerminalDisplay(this, terminal)
private val terminalDisplay = TerminalDisplay(this, terminal, terminalBlink)
private val dataProviderSupport = DataProviderSupport()
private val layeredPane = TerminalLayeredPane()
private var visualWindows = emptyArray<VisualWindow>()
val scrollBar = TerminalScrollBar(this@TerminalPanel, terminalFindPanel, terminal)
var enableFloatingToolbar = true
set(value) {
field = value
if (value) {
layeredPane.add(floatingToolbar, JLayeredPane.POPUP_LAYER as Any)
} else {
layeredPane.remove(floatingToolbar)
}
}
/**
@@ -116,11 +132,11 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
scrollBar.blockIncrement = 1
background = Color.black
val layeredPane = TerminalLayeredPane()
layeredPane.add(terminalDisplay, JLayeredPane.DEFAULT_LAYER as Any)
layeredPane.add(terminalFindPanel, JLayeredPane.POPUP_LAYER as Any)
layeredPane.add(floatingToolbar, JLayeredPane.POPUP_LAYER as Any)
if (enableFloatingToolbar) {
layeredPane.add(floatingToolbar, JLayeredPane.POPUP_LAYER as Any)
}
add(layeredPane, BorderLayout.CENTER)
add(scrollBar, BorderLayout.EAST)
@@ -140,10 +156,12 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
this.addFocusListener(object : FocusAdapter() {
override fun focusLost(e: FocusEvent) {
terminal.getTerminalModel().setData(Focused, false)
repaintImmediate()
}
override fun focusGained(e: FocusEvent) {
terminal.getTerminalModel().setData(Focused, true)
repaintImmediate()
}
})
@@ -386,6 +404,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
}
override fun dispose() {
Disposer.dispose(terminalBlink)
Disposer.dispose(floatingToolbar)
}
@@ -498,6 +517,23 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
height
)
}
is VisualWindow -> {
var location = c.location
val dimension = getDimension()
if (location.x > dimension.width) {
location = Point(dimension.width - c.preferredSize.width, location.y)
}
if (location.y > dimension.height) {
location = Point(location.x, dimension.height - c.preferredSize.height)
}
c.setBounds(
location.x,
location.y,
c.width,
c.height
)
}
}
}
}
@@ -507,4 +543,41 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey)
}
override fun moveToFront(visualWindow: VisualWindow) {
if (visualWindow.isWindow()) {
visualWindow.getWindow()?.requestFocus()
return
}
layeredPane.moveToFront(visualWindow.getJComponent())
}
override fun getVisualWindows(): Array<VisualWindow> {
return visualWindows
}
override fun addVisualWindow(visualWindow: VisualWindow) {
visualWindows = ArrayUtils.add(visualWindows, visualWindow)
layeredPane.add(visualWindow.getJComponent(), JLayeredPane.DRAG_LAYER as Any)
layeredPane.moveToFront(visualWindow.getJComponent())
}
override fun removeVisualWindow(visualWindow: VisualWindow) {
rebaseVisualWindow(visualWindow)
visualWindows = ArrayUtils.removeElement(visualWindows, visualWindow)
}
override fun rebaseVisualWindow(visualWindow: VisualWindow) {
layeredPane.remove(visualWindow.getJComponent())
layeredPane.revalidate()
layeredPane.repaint()
requestFocusInWindow()
}
override fun getDimension(): Dimension {
return Dimension(
terminalDisplay.size.width + padding.left + padding.right,
terminalDisplay.size.height + padding.bottom + padding.top
)
}
}

View File

@@ -42,6 +42,8 @@ class TerminalPanelMouseSelectionAdapter(private val terminalPanel: TerminalPane
override fun mousePressed(e: MouseEvent) {
terminalPanel.requestFocusInWindow()
if (isMouseTracking) {
return
}
@@ -77,7 +79,6 @@ class TerminalPanelMouseSelectionAdapter(private val terminalPanel: TerminalPane
mousePressedPoint.y = e.y
}
terminalPanel.requestFocusInWindow()
// 如果只有 Shift 键按下,那么应该追加选中
if (selectionModel.hasSelection() && SwingUtilities.isLeftMouseButton(e) && e.modifiersEx == 1088) {

View File

@@ -0,0 +1,42 @@
package app.termora.terminal.panel.vw
import app.termora.Disposable
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import javax.swing.JPanel
import kotlin.time.Duration.Companion.milliseconds
abstract class AutoRefreshPanel : JPanel(), Disposable {
companion object {
private val log = LoggerFactory.getLogger(AutoRefreshPanel::class.java)
}
protected val coroutineScope = CoroutineScope(Dispatchers.IO)
protected abstract suspend fun refresh(isFirst: Boolean)
init {
coroutineScope.launch {
var isFirst = true
while (coroutineScope.isActive) {
try {
refresh(isFirst)
isFirst = false
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
if (isFirst) {
break
}
} finally {
delay(1000.milliseconds)
}
}
}
}
override fun dispose() {
coroutineScope.cancel()
}
}

View File

@@ -0,0 +1,386 @@
package app.termora.terminal.panel.vw
import app.termora.*
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXBusyLabel
import org.slf4j.LoggerFactory
import org.xml.sax.EntityResolver
import org.xml.sax.InputSource
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.Dimension
import java.awt.GridLayout
import java.io.StringReader
import javax.swing.*
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathFactory
class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "NVIDIA-SMI", visualWindowManager) {
companion object {
private val log = LoggerFactory.getLogger(NvidiaSMIVisualWindow::class.java)
}
private val nvidiaSMIPanel by lazy { NvidiaSMIPanel() }
private val busyLabel = JXBusyLabel()
private val errorPanel = FormBuilder.create().layout(FormLayout("pref:grow", "20dlu, pref, 5dlu, pref"))
.add(JLabel(FlatSVGIcon(Icons.warningDialog.name, 60, 60))).xy(1, 2, "center, fill")
.add(JLabel("Not supported")).xy(1, 4, "center, fill")
.build()
private val loadingPanel = FormBuilder.create().layout(FormLayout("pref:grow", "20dlu, pref"))
.add(busyLabel).xy(1, 2, "center, fill")
.build()
private val cardLayout = CardLayout()
private val rootPanel = JPanel(cardLayout)
private var isPercentage
get() = properties.getString("VisualWindow.${id}.isPercentage", "false").toBoolean()
set(value) = properties.putString("VisualWindow.${id}.isPercentage", value.toString())
private val percentageBtn by lazy { JButton(if (isPercentage) Icons.text else Icons.percentage) }
init {
initViews()
initEvents()
initVisualWindowPanel()
}
override fun toolbarButtons(): List<JButton> {
return listOf(percentageBtn)
}
private fun initViews() {
title = I18n.getString("termora.visual-window.nvidia-smi")
busyLabel.isBusy = true
rootPanel.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
rootPanel.add(errorPanel, "ErrorPanel")
rootPanel.add(loadingPanel, "LoadingPanel")
rootPanel.add(nvidiaSMIPanel, "NvidiaSMIPanel")
add(rootPanel, BorderLayout.CENTER)
cardLayout.show(rootPanel, "LoadingPanel")
}
private fun initEvents() {
percentageBtn.addActionListener {
isPercentage = !isPercentage
percentageBtn.icon = if (isPercentage) Icons.text else Icons.percentage
nvidiaSMIPanel.refreshPanel()
}
Disposer.register(this, nvidiaSMIPanel)
}
private data class GPU(
/**
* 名称 product_name
*/
val productName: String = StringUtils.EMPTY,
/**
* 序号 minor_number
*/
val minorNumber: Int = 0,
/**
* 温度 temperature.gpu_temp
*
* 单位C
*/
var temp: Double = 0.0,
var tempText: String = StringUtils.EMPTY,
/**
* 使用的功率 gpu_power_readings.power_draw
*
* 单位W
*/
var powerUsage: Double = 0.0,
var powerUsageText: String = StringUtils.EMPTY,
/**
* 功率大小 gpu_power_readings.max_power_limit
*
* 单位W
*/
var powerCap: Double = 0.0,
var powerCapText: String = StringUtils.EMPTY,
/**
* 使用的显存 fb_memory_usage.used
*
* 单位Mib
*/
var memoryUsage: Double = 0.0,
var memoryUsageText: String = StringUtils.EMPTY,
/**
* 显存大小 fb_memory_usage.total
*
* 单位Mib
*/
var memoryCap: Double = 0.0,
var memoryCapText: String = StringUtils.EMPTY,
/**
* GPU 利用率 utilization.gpu_util
*
* 单位:%
*/
var gpu: Double = 0.0
)
private class NvidiaSMI(
val driverVersion: String = StringUtils.EMPTY,
val cudaVersion: String = StringUtils.EMPTY,
val gpus: MutableList<GPU> = mutableListOf<GPU>(),
)
private class GPUPanel(val minorNumber: Int, title: String) : JPanel(BorderLayout()) {
val gpuProgressBar = SmartProgressBar()
val tempProgressBar = SmartProgressBar()
val memProgressBar = SmartProgressBar()
val powerProgressBar = SmartProgressBar()
init {
val formMargin = "4dlu"
var rows = 1
val step = 2
val p = FormBuilder.create().debug(false)
.layout(
FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
)
.border(
BorderFactory.createCompoundBorder(
BorderFactory.createTitledBorder(title),
BorderFactory.createEmptyBorder(4, 4, 4, 4),
)
)
.add("GPU: ").xy(1, rows)
.add(gpuProgressBar).xy(3, rows).apply { rows += step }
.add("Temp: ").xy(1, rows)
.add(tempProgressBar).xy(3, rows).apply { rows += step }
.add("Mem: ").xy(1, rows)
.add(memProgressBar).xy(3, rows).apply { rows += step }
.add("Power: ").xy(1, rows)
.add(powerProgressBar).xy(3, rows).apply { rows += step }
.build()
add(p, BorderLayout.CENTER)
}
}
private inner class NvidiaSMIPanel : AutoRefreshPanel() {
private val xPath by lazy { XPathFactory.newInstance().newXPath() }
private val db by lazy {
val factory = DocumentBuilderFactory.newInstance()
factory.isValidating = false
factory.isXIncludeAware = false
factory.isNamespaceAware = false
val db = factory.newDocumentBuilder()
db.setEntityResolver(object : EntityResolver {
override fun resolveEntity(
publicId: String?,
systemId: String?
): InputSource? {
return if (StringUtils.contains(systemId, ".dtd")) {
InputSource(StringReader(StringUtils.EMPTY))
} else {
null
}
}
})
db
}
private var nvidiaSMI = NvidiaSMI()
private val gpuRootPanel = JPanel()
private val driverVersionLabel = JLabel()
private val cudaVersionLabel = JLabel()
private val gpusLabel = JLabel()
init {
initViews()
initEvents()
}
private fun initViews() {
layout = BorderLayout()
add(
FormBuilder.create().debug(false)
.layout(
FormLayout(
"default:grow, pref, default:grow, 4dlu, pref, default:grow, 4dlu, pref, default:grow, default:grow",
"pref, 4dlu"
)
)
.add(Box.createHorizontalGlue()).xy(1, 1)
.add("Driver: ").xy(2, 1)
.add(driverVersionLabel).xy(3, 1)
.add("CUDA: ").xy(5, 1)
.add(cudaVersionLabel).xy(6, 1)
.add("GPUS: ").xy(8, 1)
.add(gpusLabel).xy(9, 1)
.add(Box.createHorizontalGlue()).xy(10, 1)
.build(), BorderLayout.NORTH
)
add(JScrollPane(gpuRootPanel).apply {
verticalScrollBar.maximumSize = Dimension(0, 0)
verticalScrollBar.preferredSize = Dimension(0, 0)
verticalScrollBar.minimumSize = Dimension(0, 0)
border = BorderFactory.createEmptyBorder()
}, BorderLayout.CENTER)
}
private fun initEvents() {
}
override suspend fun refresh(isFirst: Boolean) {
val session = tab.getData(SSHTerminalTab.SSHSession) ?: return
val doc = try {
val (code, text) = SshClients.execChannel(session, "nvidia-smi -x -q")
if (StringUtils.isNotBlank(text)) {
db.parse(InputSource(StringReader(text)))
} else {
throw IllegalStateException("exit code: $code")
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
null
}
if (doc == null) {
if (isFirst) {
withContext(Dispatchers.Swing) {
cardLayout.show(rootPanel, "ErrorPanel")
}
// 直接取消轮训
SwingUtilities.invokeLater { coroutineScope.cancel() }
}
return
}
nvidiaSMI = NvidiaSMI(
driverVersion = xPath.compile("/nvidia_smi_log/driver_version/text()").evaluate(doc),
cudaVersion = xPath.compile("/nvidia_smi_log/cuda_version/text()").evaluate(doc),
)
val attachedGPUs = xPath.compile("/nvidia_smi_log/attached_gpus/text()").evaluate(doc).toIntOrNull() ?: 0
for (i in 1..attachedGPUs) {
val gpu = GPU(
productName = xPath.compile("/nvidia_smi_log/gpu[${i}]/product_name/text()").evaluate(doc),
minorNumber = xPath.compile("/nvidia_smi_log/gpu[${i}]/minor_number/text()").evaluate(doc)
.toIntOrNull() ?: 0,
tempText = xPath.compile("/nvidia_smi_log/gpu[${i}]/temperature/gpu_temp/text()").evaluate(doc),
powerUsageText = xPath.compile("/nvidia_smi_log/gpu[${i}]/gpu_power_readings/power_draw/text()")
.evaluate(doc),
powerCapText = xPath.compile("/nvidia_smi_log/gpu[${i}]/gpu_power_readings/max_power_limit/text()")
.evaluate(doc),
memoryUsageText = xPath.compile("/nvidia_smi_log/gpu[${i}]/fb_memory_usage/used/text()")
.evaluate(doc),
memoryCapText = xPath.compile("/nvidia_smi_log/gpu[${i}]/fb_memory_usage/total/text()")
.evaluate(doc),
gpu = xPath.compile("/nvidia_smi_log/gpu[${i}]/utilization/gpu_util/text()")
.evaluate(doc).split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
)
nvidiaSMI.gpus.add(
gpu.copy(
temp = gpu.tempText.split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
powerUsage = gpu.powerUsageText.split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
powerCap = gpu.powerCapText.split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
memoryUsage = gpu.memoryUsageText.split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
memoryCap = gpu.memoryCapText.split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
)
)
}
withContext(Dispatchers.Swing) {
if (isFirst) {
initPanel()
cardLayout.show(rootPanel, "NvidiaSMIPanel")
}
refreshPanel()
}
}
private fun initPanel() {
gpuRootPanel.layout = GridLayout(
if (nvidiaSMI.gpus.size % 2 == 0) nvidiaSMI.gpus.size / 2 else nvidiaSMI.gpus.size / 2 + 1,
2, 4, 4
)
for (e in nvidiaSMI.gpus) {
gpuRootPanel.add(GPUPanel(e.minorNumber, "${e.minorNumber} ${e.productName}"))
}
}
fun refreshPanel() {
cudaVersionLabel.text = nvidiaSMI.cudaVersion
driverVersionLabel.text = nvidiaSMI.driverVersion
gpusLabel.text = nvidiaSMI.gpus.size.toString()
for (c in gpuRootPanel.components) {
if (c is GPUPanel) {
for (g in nvidiaSMI.gpus) {
if (c.minorNumber == g.minorNumber) {
refreshGPUPanel(g, c)
break
}
}
}
}
}
private fun refreshGPUPanel(gpu: GPU, g: GPUPanel) {
g.gpuProgressBar.value = gpu.gpu.toInt()
g.tempProgressBar.value = gpu.temp.toInt()
g.tempProgressBar.string = if (isPercentage) "${g.tempProgressBar.value}%" else gpu.tempText
g.powerProgressBar.value = (gpu.powerUsage / gpu.powerCap * 100.0).toInt()
g.powerProgressBar.string = if (isPercentage) "${g.powerProgressBar.value}%"
else "${gpu.powerUsageText}/${gpu.powerCapText}"
g.memProgressBar.value = (gpu.memoryUsage / gpu.memoryCap * 100.0).toInt()
g.memProgressBar.string = if (isPercentage) "${g.memProgressBar.value}%"
else "${gpu.memoryUsageText}/${gpu.memoryCapText}"
}
}
override fun dispose() {
busyLabel.isBusy = false
super.dispose()
}
}

View File

@@ -0,0 +1,35 @@
package app.termora.terminal.panel.vw
import app.termora.Disposer
import app.termora.SSHTerminalTab
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import org.apache.commons.lang3.StringUtils
import java.util.*
abstract class SSHVisualWindow(
protected val tab: SSHTerminalTab,
id: String,
visualWindowManager: VisualWindowManager
) : VisualWindowPanel(id, visualWindowManager) {
init {
Disposer.register(tab, this)
}
override fun toggleWindow() {
val evt = AnActionEvent(tab.getJComponent(), StringUtils.EMPTY, EventObject(this))
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
super.toggleWindow()
if (!isWindow()) {
terminalTabbedManager.setSelectedTerminalTab(tab)
}
}
override fun getWindowTitle(): String {
return tab.getTitle() + " - " + title
}
}

View File

@@ -0,0 +1,31 @@
package app.termora.terminal.panel.vw
import com.formdev.flatlaf.extras.components.FlatProgressBar
import java.awt.Dimension
import javax.swing.UIManager
class SmartProgressBar : FlatProgressBar() {
init {
preferredSize = Dimension(-1, UIManager.getInt("Table.rowHeight") - 6)
isStringPainted = true
maximum = 100
minimum = 0
}
override fun setValue(n: Int) {
super.setValue(n)
foreground = if (value < 60) {
UIManager.getColor("Component.accentColor")
} else if (value < 85) {
UIManager.getColor("Component.warning.focusedBorderColor")
} else {
UIManager.getColor("Component.error.focusedBorderColor")
}
}
override fun updateUI() {
super.updateUI()
value = value
}
}

View File

@@ -0,0 +1,403 @@
package app.termora.terminal.panel.vw
import app.termora.*
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.session.ClientSession
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel
class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "SystemInformation", visualWindowManager) {
companion object {
private val log = LoggerFactory.getLogger(SystemInformationVisualWindow::class.java)
}
private val systemInformationPanel by lazy { SystemInformationPanel() }
init {
initViews()
initEvents()
initVisualWindowPanel()
}
private fun initViews() {
title = I18n.getString("termora.visual-window.system-information")
add(systemInformationPanel, BorderLayout.CENTER)
}
private fun initEvents() {
Disposer.register(this, systemInformationPanel)
}
private inner class SystemInformationPanel : AutoRefreshPanel() {
private val cpuProgressBar = SmartProgressBar()
private val memoryProgressBar = SmartProgressBar()
private val swapProgressBar = SmartProgressBar()
private val mem = Mem()
private val cpu = CPU()
private val swap = Swap()
private val tableModel = object : DefaultTableModel() {
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
}
init {
initViews()
initEvents()
}
private fun initViews() {
layout = BorderLayout()
add(createPanel(), BorderLayout.CENTER)
}
private fun createPanel(): JComponent {
val formMargin = "4dlu"
var rows = 1
val step = 2
val p = JPanel(BorderLayout())
val n = FormBuilder.create().debug(false).layout(
FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin"
)
)
.add("CPU: ").xy(1, rows)
.add(cpuProgressBar).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.visual-window.system-information.mem")}: ").xy(1, rows)
.add(memoryProgressBar).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.visual-window.system-information.swap")}: ").xy(1, rows)
.add(swapProgressBar).xy(3, rows).apply { rows += step }
.build()
val table = JTable(tableModel)
table.tableHeader.isEnabled = false
table.showVerticalLines = true
table.showHorizontalLines = true
table.fillsViewportHeight = true
tableModel.addColumn(I18n.getString("termora.visual-window.system-information.filesystem"))
tableModel.addColumn(I18n.getString("termora.visual-window.system-information.used-total"))
val centerRenderer = DefaultTableCellRenderer()
centerRenderer.setHorizontalAlignment(JLabel.CENTER)
table.columnModel.getColumn(1).cellRenderer = centerRenderer
p.add(n, BorderLayout.NORTH)
p.add(JScrollPane(table).apply {
border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
}, BorderLayout.CENTER)
p.border = BorderFactory.createEmptyBorder(6, 6, 6, 6)
return p
}
private fun initEvents() {
}
override suspend fun refresh(isFirst: Boolean) {
val session = tab.getData(SSHTerminalTab.SSHSession) ?: return
try {
// 刷新 CPU 和 内存
refreshCPUAndMem(session)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error("refreshCPUAndMem", e)
}
}
try {
// 刷新磁盘
refreshDisk(session)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error("refreshDisk", e)
}
}
}
private suspend fun refreshCPUAndMem(session: ClientSession) {
// top
var pair = SshClients.execChannel(session, "top -bn1")
if (pair.first != 0) {
return
}
val regex = """\d+\.?\d*""".toRegex()
val lines = pair.second.split(StringUtils.LF)
for (line in lines) {
val isCPU = line.startsWith("%Cpu(s):", true)
val isMibMem = line.startsWith("MiB Mem :", true)
val isKibMem = line.startsWith("KiB Mem :", true)
val isMibSwap = line.startsWith("MiB Swap:", true)
val isKibSwap = line.startsWith("KiB Swap:", true)
val unit = if (isKibSwap || isKibMem) 'K' else 'M'
if (isCPU) {
val parts = StringUtils.removeStartIgnoreCase(line, "%Cpu(s):").split(",").map { it.trim() }
for (part in parts) {
val value = regex.find(part)?.value?.toDoubleOrNull() ?: 0.0
if (part.contains("us")) {
cpu.us = value
} else if (part.contains("sy")) {
cpu.sy = value
} else if (part.contains("ni")) {
cpu.ni = value
} else if (part.contains("id")) {
cpu.id = value
} else if (part.contains("wa")) {
cpu.wa = value
} else if (part.contains("hi")) {
cpu.hi = value
} else if (part.contains("si")) {
cpu.si = value
} else if (part.contains("st")) {
cpu.st = value
}
}
} else if (isMibMem || isKibMem) {
val parts = StringUtils.removeStartIgnoreCase(line, "${unit}iB Mem :")
.split(",")
.map { it.trim() }
for (part in parts) {
val value = regex.find(part)?.value?.toDoubleOrNull() ?: 0.0
if (part.contains("total")) {
mem.total = value
} else if (part.contains("free")) {
mem.free = value
} else if (part.contains("used")) {
mem.used = value
} else if (part.contains("buff/cache")) {
mem.buffCache = value
}
}
if (isKibMem) {
mem.total = mem.total / 1024.0
mem.free = mem.free / 1024.0
mem.used = mem.used / 1024.0
mem.buffCache = mem.buffCache / 1024.0
}
} else if (isMibSwap || isKibSwap) {
val parts = StringUtils.removeStartIgnoreCase(line, "${unit}iB Swap:")
.split(",")
.map { it.trim() }
for (part in parts) {
val value = regex.find(part)?.value?.toDoubleOrNull() ?: 0.0
if (part.contains("total")) {
swap.total = value
} else if (part.contains("free")) {
swap.free = value
} else if (part.contains("used.")) {
swap.used = value
}
}
if (isKibSwap) {
swap.total = swap.total / 1024.0
swap.free = swap.free / 1024.0
swap.used = swap.used / 1024.0
}
}
}
withContext(Dispatchers.Swing) {
cpuProgressBar.value = (100.0 - cpu.id).toInt()
memoryProgressBar.value = (mem.used / mem.total * 100.0).toInt()
memoryProgressBar.string =
"${formatBytes((mem.used * 1024 * 1024).toLong())} / ${formatBytes((mem.total * 1024 * 1024).toLong())}"
swapProgressBar.value = (swap.used / swap.total * 100.0).toInt()
swapProgressBar.string =
"${formatBytes((swap.used * 1024 * 1024).toLong())} / ${formatBytes((swap.total * 1024 * 1024).toLong())}"
}
}
private suspend fun refreshDisk(session: ClientSession) {
// df -h
var pair = SshClients.execChannel(session, "df -B1")
if (pair.first != 0) {
return
}
val disks = mutableListOf<Disk>()
val lines = pair.second.split(StringUtils.LF)
for (line in lines) {
if (!line.startsWith("/dev/")) {
continue
}
val parts = line.split("\\s+".toRegex())
if (parts.size < 6) {
continue
}
disks.add(
Disk(
filesystem = parts[0],
size = parts[1].toLong(),
used = parts[2].toLong(),
avail = parts[3].toLong(),
usePercentage = StringUtils.removeEnd(parts[4], "%").toIntOrNull() ?: 0,
mountedOn = parts[5],
)
)
}
withContext(Dispatchers.Swing) {
while (tableModel.rowCount > 0) {
tableModel.removeRow(0)
}
for (disk in disks) {
tableModel.addRow(
arrayOf(
" ${disk.filesystem}",
formatBytes(disk.used) + " / " + formatBytes(disk.size),
)
)
}
}
}
}
private data class Mem(
/**
* 总内存
*/
var total: Double = 0.0,
/**
* 空闲内存
*/
var free: Double = 0.0,
/**
* 已用内存
*/
var used: Double = 0.0,
/**
* 缓存和缓冲区占用的内存
*/
var buffCache: Double = 0.0,
)
private data class Swap(
/**
* 交换空间的总大小
*/
var total: Double = 0.0,
/**
* 已使用的交换空间
*/
var free: Double = 0.0,
/**
* 未使用的交换空间
*/
var used: Double = 0.0,
)
private data class CPU(
/**
* 用户空间 CPU 占用时间百分比。
* 该值表示 CPU 用于执行用户进程的时间比例。
* 示例:如果系统中 CPU 用于执行用户程序的时间占总 CPU 时间的 40%,则该值为 40.0。
*/
var us: Double = 0.0,
/**
* 系统空间 CPU 占用时间百分比。
* 该值表示 CPU 用于执行内核进程的时间比例。
* 示例:如果内核进程占用 CPU 时间的 20%,则该值为 20.0。
*/
var sy: Double = 0.0,
/**
* 优先级调整过的进程Nice占用的 CPU 时间百分比。
* 该值表示 CPU 用于执行“优先级较低的”进程的时间比例。
* 示例:如果优先级调整过的进程占用 CPU 时间的 10%,则该值为 10.0。
*/
var ni: Double = 0.0,
/**
* CPU 空闲时间百分比。
* 该值表示 CPU 在空闲状态下没有执行任何任务的时间比例。
* 示例:如果 CPU 95% 处于空闲状态,该值为 95.0。
*/
var id: Double = 0.0,
/**
* I/O 等待时间百分比。
* 该值表示 CPU 正在等待 I/O 操作完成的时间比例。
* 示例:如果 CPU 由于 I/O 操作等待占用 5% 的时间,则该值为 5.0。
*/
var wa: Double = 0.0,
/**
* 硬件中断处理时间百分比。
* 该值表示 CPU 用于处理中断请求的时间比例,通常由硬件触发。
* 示例:如果 CPU 处理硬件中断占用 2% 的时间,则该值为 2.0。
*/
var hi: Double = 0.0,
/**
* 软件中断处理时间百分比。
* 该值表示 CPU 用于处理由软件触发的中断的时间比例。
* 示例:如果 CPU 处理软件中断占用 3% 的时间,则该值为 3.0。
*/
var si: Double = 0.0,
/**
* 虚拟化环境中的 CPU 抢占时间百分比。
* 该值表示 CPU 在虚拟化环境中被其他虚拟机抢占的时间比例。
* 示例:如果虚拟化环境中的 CPU 抢占占用 0.5% 的时间,则该值为 0.5。
*/
var st: Double = 0.0,
)
private data class Disk(
var filesystem: String = StringUtils.EMPTY,
/**
* 总大小
*/
var size: Long = 0L,
/**
* 已经使用的空间
*/
var used: Long = 0L,
/**
* 可用空间
*/
var avail: Long = 0L,
/**
* 已经使用的百分比
*/
var usePercentage: Int = 0,
/**
* 挂载点
*/
var mountedOn: String = StringUtils.EMPTY
)
}

View File

@@ -0,0 +1,31 @@
package app.termora.terminal.panel.vw
import app.termora.Disposable
import java.awt.Window
import javax.swing.JComponent
/**
* 虚拟窗口
*/
interface VisualWindow : Disposable {
/**
* 虚拟窗口内容
*/
fun getJComponent(): JComponent
/**
* 是否是独立窗口(独立成一个 Window
*/
fun isWindow(): Boolean
/**
* 如果是独立窗口,那么可以返回
*/
fun getWindow(): Window? = null
/**
* 切换独立模式
*/
fun toggleWindow()
}

View File

@@ -0,0 +1,36 @@
package app.termora.terminal.panel.vw
import java.awt.Dimension
interface VisualWindowManager {
/**
* 将窗口移动到最前面
*/
fun moveToFront(visualWindow: VisualWindow)
/**
* 添加虚拟窗口
*/
fun addVisualWindow(visualWindow: VisualWindow)
/**
* 移除虚拟窗口
*/
fun removeVisualWindow(visualWindow: VisualWindow)
/**
* 变基,仅仅从 LayeredPane 移除,但是不从 [getVisualWindows] 中移除
*/
fun rebaseVisualWindow(visualWindow: VisualWindow)
/**
* 获取管理的所有窗口
*/
fun getVisualWindows(): Array<VisualWindow>
/**
* 获取管理器的宽高
*/
fun getDimension(): Dimension
}

View File

@@ -0,0 +1,300 @@
package app.termora.terminal.panel.vw
import app.termora.*
import com.formdev.flatlaf.extras.components.FlatToolBar
import java.awt.*
import java.awt.event.*
import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener
import javax.swing.*
import kotlin.math.max
import kotlin.math.min
open class VisualWindowPanel(protected val id: String, protected val visualWindowManager: VisualWindowManager) :
JPanel(BorderLayout()), VisualWindow {
protected val properties get() = Database.getDatabase().properties
private val titleLabel = JLabel()
private val toolbar = FlatToolBar()
private val visualWindow = this
private val resizer = VisualWindowResizer(this) { !isWindow }
private var isWindow = false
set(value) {
val oldValue = field
field = value
firePropertyChange("isWindow", oldValue, value)
}
private var dialog: VisualWindowDialog? = null
private var oldBounds = Rectangle()
private var toggleWindowBtn = JButton(Icons.openInNewWindow)
private var isAlwaysTop
get() = properties.getString("VisualWindow.${id}.dialog.isAlwaysTop", "false").toBoolean()
set(value) = properties.putString("VisualWindow.${id}.dialog.isAlwaysTop", value.toString())
private val alwaysTopBtn = JButton(Icons.moveUp)
private val closeWindowListener = object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
close()
}
}
var title: String
set(value) {
titleLabel.text = value
}
get() = titleLabel.text
protected fun initVisualWindowPanel() {
initViews()
initEvents()
initToolBar()
}
private fun initViews() {
border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
val x = properties.getString("VisualWindow.${id}.location.x", "-1").toIntOrNull() ?: -1
val y = properties.getString("VisualWindow.${id}.location.y", "-1").toIntOrNull() ?: -1
val w = properties.getString("VisualWindow.${id}.location.width", "-1").toIntOrNull() ?: -1
val h = properties.getString("VisualWindow.${id}.location.height", "-1").toIntOrNull() ?: -1
if (x >= 0 && y >= 0) {
setLocation(x, y)
} else {
setLocation(200, 200)
}
if (w > 0 && h > 0) setSize(w, h) else setSize(400, 200)
alwaysTopBtn.isSelected = isAlwaysTop
alwaysTopBtn.isVisible = false
}
protected open fun toolbarButtons(): List<JButton> {
return emptyList()
}
private fun initEvents() {
val dragListener = DragListener()
toolbar.addMouseListener(dragListener)
toolbar.addMouseMotionListener(dragListener)
// 监听全局事件
Toolkit.getDefaultToolkit().addAWTEventListener(object : AWTEventListener {
override fun eventDispatched(event: AWTEvent) {
if (event is MouseEvent) {
if (event.id == MouseEvent.MOUSE_PRESSED) {
val c = event.component ?: return
if (SwingUtilities.isDescendingFrom(c, visualWindow)) {
visualWindowManager.moveToFront(visualWindow)
}
}
}
}
}, MouseEvent.MOUSE_EVENT_MASK)
// 阻止事件穿透
addMouseListener(object : MouseAdapter() {})
toggleWindowBtn.addActionListener { toggleWindow() }
addPropertyChangeListener("isWindow", object : PropertyChangeListener {
override fun propertyChange(evt: PropertyChangeEvent) {
if (isWindow) {
border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
toggleWindowBtn.icon = Icons.openInToolWindow
} else {
border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
toggleWindowBtn.icon = Icons.openInNewWindow
}
}
})
alwaysTopBtn.addActionListener {
isAlwaysTop = !isAlwaysTop
alwaysTopBtn.isSelected = isAlwaysTop
if (isWindow()) {
dialog?.isAlwaysOnTop = isAlwaysTop
}
}
}
private fun initToolBar() {
val btns = toolbarButtons()
val count = 2 + btns.size
toolbar.add(alwaysTopBtn)
toolbar.add(Box.createHorizontalStrut(count * 26))
toolbar.add(JLabel(Icons.empty))
toolbar.add(Box.createHorizontalGlue())
toolbar.add(titleLabel)
toolbar.add(Box.createHorizontalGlue())
btns.forEach { toolbar.add(it) }
toolbar.add(toggleWindowBtn)
toolbar.add(JButton(Icons.close).apply { addActionListener { Disposer.dispose(visualWindow) } })
toolbar.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
add(toolbar, BorderLayout.NORTH)
}
override fun dispose() {
val bounds = if (isWindow) oldBounds else bounds
properties.putString("VisualWindow.${id}.location.x", bounds.x.toString())
properties.putString("VisualWindow.${id}.location.y", bounds.y.toString())
properties.putString("VisualWindow.${id}.location.width", bounds.width.toString())
properties.putString("VisualWindow.${id}.location.height", bounds.height.toString())
resizer.uninstall()
this.close()
}
final override fun getJComponent(): JComponent {
return this
}
override fun isWindow(): Boolean {
return isWindow
}
override fun getWindow(): Window? {
return dialog
}
protected open fun getWindowTitle(): String {
return id
}
override fun toggleWindow() {
if (isWindow) {
// 提前移除 dialog 的关闭事件
dialog?.removeWindowListener(closeWindowListener)
}
isWindow = !isWindow
dialog?.dispose()
dialog = null
alwaysTopBtn.isVisible = isWindow
if (isWindow) {
oldBounds = bounds
// 变基
visualWindowManager.rebaseVisualWindow(this)
val dialog = VisualWindowDialog().apply { dialog = this }
dialog.addWindowListener(closeWindowListener)
dialog.isVisible = true
} else {
bounds = oldBounds
visualWindowManager.removeVisualWindow(visualWindow)
visualWindowManager.addVisualWindow(visualWindow)
}
}
private inner class DragListener() : MouseAdapter() {
private var startPoint: Point? = null
override fun mousePressed(e: MouseEvent) {
if (isWindow) {
startPoint = null
return
}
startPoint = SwingUtilities.convertPoint(visualWindow, e.getPoint(), visualWindow.getParent())
}
override fun mouseDragged(e: MouseEvent) {
val startPoint = this.startPoint ?: return
val newPoint = SwingUtilities.convertPoint(visualWindow, e.getPoint(), visualWindow.getParent())
val dimension = visualWindowManager.getDimension()
val x = min(
visualWindow.getX() + (newPoint.x - startPoint.x),
dimension.width - visualWindow.width
)
val y = min(
visualWindow.getY() + (newPoint.y - startPoint.y),
dimension.height - visualWindow.height
)
visualWindow.setBounds(max(x, 0), max(y, 0), visualWindow.getWidth(), visualWindow.getHeight())
this.startPoint = newPoint
}
override fun mouseReleased(e: MouseEvent) {
visualWindowManager.moveToFront(visualWindow)
}
}
protected open fun close() {
if (isWindow()) {
dialog?.dispose()
dialog = null
}
visualWindowManager.removeVisualWindow(visualWindow)
}
private inner class VisualWindowDialog : DialogWrapper(null) {
init {
isModal = false
controlsVisible = false
isResizable = true
title = getWindowTitle()
isAlwaysOnTop = isAlwaysTop
initEvents()
init()
val x = properties.getString("VisualWindow.${id}.dialog.location.x", "-1").toIntOrNull() ?: -1
val y = properties.getString("VisualWindow.${id}.dialog.location.y", "-1").toIntOrNull() ?: -1
val w = properties.getString("VisualWindow.${id}.dialog.location.width", "-1").toIntOrNull() ?: -1
val h = properties.getString("VisualWindow.${id}.dialog.location.height", "-1").toIntOrNull() ?: -1
if (w > 0 && h > 0) setSize(w, h) else pack()
if (x >= 0 && y >= 0) {
setLocation(x, y)
} else {
setLocationRelativeTo(null)
}
}
private fun initEvents() {
addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
properties.putString("VisualWindow.${id}.dialog.location.x", x.toString())
properties.putString("VisualWindow.${id}.dialog.location.y", y.toString())
properties.putString("VisualWindow.${id}.dialog.location.width", width.toString())
properties.putString("VisualWindow.${id}.dialog.location.height", height.toString())
}
})
}
override fun createCenterPanel(): JComponent {
return getJComponent()
}
override fun createSouthPanel(): JComponent? {
return null
}
}
}

View File

@@ -0,0 +1,48 @@
package app.termora.terminal.panel.vw
import com.formdev.flatlaf.ui.FlatWindowResizer
import java.awt.Dimension
import java.awt.Rectangle
import javax.swing.JComponent
class VisualWindowResizer(resizeComp: JComponent, private val windowResizable: () -> Boolean = { true }) :
FlatWindowResizer(resizeComp) {
override fun isWindowResizable(): Boolean {
return windowResizable.invoke()
}
override fun getWindowBounds(): Rectangle {
return resizeComp.bounds
}
override fun setWindowBounds(r: Rectangle) {
resizeComp.bounds = r
resizeComp.revalidate()
resizeComp.repaint()
}
override fun limitToParentBounds(): Boolean {
return true
}
override fun getParentBounds(): Rectangle {
return resizeComp.getParent().bounds
}
override fun honorMinimumSizeOnResize(): Boolean {
return true
}
override fun honorMaximumSizeOnResize(): Boolean {
return true
}
override fun getWindowMinimumSize(): Dimension {
return resizeComp.minimumSize
}
override fun getWindowMaximumSize(): Dimension {
return resizeComp.maximumSize
}
}

View File

@@ -57,11 +57,13 @@ class FileSystemTabbed(
private fun initEvents() {
addBtn.addActionListener {
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this))
val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(this))
dialog.location = Point(
max(0, addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2),
addBtn.locationOnScreen.y + max(tabHeight, addBtn.height)
)
dialog.setFilter { it.host.protocol == Protocol.SSH }
dialog.setTreeName("FileSystemTabbed.Tree")
dialog.isVisible = true
for (host in dialog.hosts) {

View File

@@ -14,7 +14,7 @@ class SFTPAction : AnAction("SFTP", Icons.folder) {
val tab = openOrCreateSFTPTerminalTab(evt) ?: return
if (host != null) {
connectHost(host.copy(protocol = Protocol.SSH), tab)
connectHost(host.copy(protocol = Protocol.SSH, updateDate = System.currentTimeMillis()), tab)
}
}
@@ -23,20 +23,22 @@ class SFTPAction : AnAction("SFTP", Icons.folder) {
*
* @return null 表示当前条件下无法创建
*/
fun openOrCreateSFTPTerminalTab(evt: AnActionEvent): SFTPTerminalTab? {
fun openOrCreateSFTPTerminalTab(evt: AnActionEvent, selected: Boolean = true): SFTPTerminalTab? {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return null
val tabs = terminalTabbedManager.getTerminalTabs()
for (tab in tabs) {
if (tab is SFTPTerminalTab) {
terminalTabbedManager.setSelectedTerminalTab(tab)
if (selected) {
terminalTabbedManager.setSelectedTerminalTab(tab)
}
return tab
}
}
// 创建一个新的
val tab = SFTPTerminalTab()
terminalTabbedManager.addTerminalTab(tab)
terminalTabbedManager.addTerminalTab(tab, selected)
return tab
}

View File

@@ -1,6 +1,8 @@
package app.termora.transport
import app.termora.*
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.keyboardinteractive.TerminalUserInteraction
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon
@@ -105,7 +107,7 @@ class SftpFileSystemPanel(
private suspend fun doConnect() {
val thisHost = this.host ?: return
var host = thisHost.copy(authentication = thisHost.authentication.copy())
var host = thisHost.copy(authentication = thisHost.authentication.copy(), updateDate = System.currentTimeMillis())
closeIO()
@@ -117,13 +119,20 @@ class SftpFileSystemPanel(
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// 弹出授权框
if (host.authentication.type == AuthenticationType.No) {
val dialog = RequestAuthenticationDialog(owner)
val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication()
host = host.copy(authentication = authentication)
host = host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance()
.addHost(host.copy(authentication = authentication))
HostManager.getInstance().addHost(
host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
}
}
@@ -291,9 +300,11 @@ class SftpFileSystemPanel(
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(FlatOptionPaneInformationIcon()).xy(2, 2)
builder.add(errorInfo).xyw(1, 4, 3, "fill, center")
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.select-host")) {
override fun actionPerformed(e: ActionEvent) {
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel))
builder.add(JXHyperlink(object : AnAction(I18n.getString("termora.transport.sftp.select-host")) {
override fun actionPerformed(evt: AnActionEvent) {
val dialog = NewHostTreeDialog(evt.window)
dialog.setFilter { it.host.protocol == Protocol.SSH }
dialog.setTreeName("SftpFileSystemPanel.SelectHostTree")
dialog.allowMulti = false
dialog.setLocationRelativeTo(this@SelectHostPanel)
dialog.isVisible = true

View File

@@ -69,6 +69,7 @@ termora.settings.terminal.debug=Debug mode
termora.settings.terminal.beep=Beep
termora.settings.terminal.select-copy=Select copy
termora.settings.terminal.cursor-style=Cursor type
termora.settings.terminal.cursor-blink=Cursor blink
termora.settings.terminal.local-shell=Local shell
termora.settings.terminal.floating-toolbar=Floating Toolbar
termora.settings.terminal.auto-close-tab=Auto Close Tab
@@ -109,6 +110,7 @@ termora.settings.keymap.already-exists=The shortcut [{0}] is already in use by [
termora.settings.sftp.edit-command=Edit Command
termora.settings.sftp.fixed-tab=Fixed tab
termora.settings.restart.title=Restart
@@ -123,25 +125,31 @@ termora.find-everywhere.groups.opened-hosts=Opened hosts
termora.find-everywhere.groups.tools=Tools
termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=Local Terminal
termora.find-everywhere.double-shift-deprecated=The double-click Shift shortcut will be removed in a future version
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated}, use {0} instead
# Welcome
termora.welcome.my-hosts=My hosts
termora.welcome.contextmenu.connect=Connect
termora.welcome.contextmenu.connect-with=Connect with
termora.welcome.contextmenu.open-in-new-window=${termora.tabbed.contextmenu.open-in-new-window}
termora.welcome.contextmenu.refresh=${termora.transport.table.contextmenu.refresh}
termora.welcome.contextmenu.copy=${termora.copy}
termora.welcome.contextmenu.remove=${termora.remove}
termora.welcome.contextmenu.rename=Rename
termora.welcome.contextmenu.expand-all=Expand all
termora.welcome.contextmenu.collapse-all=Collapse all
termora.welcome.contextmenu.new=New
termora.welcome.contextmenu.import=${termora.keymgr.import}
termora.welcome.contextmenu.new.folder=${termora.folder}
termora.welcome.contextmenu.new.host=Host
termora.welcome.contextmenu.new.folder.name=New Folder
termora.welcome.contextmenu.property=Properties
termora.welcome.contextmenu.show-more-info=Show more info
termora.welcome.contextmenu.download=Download
termora.welcome.contextmenu.import.csv.download-template=Do you want to import or download the template?
termora.welcome.contextmenu.import.csv.download-template-done=Download the template successfully
termora.welcome.contextmenu.import.csv.download-template-done-open-folder=Download the template successfully, Do you want to open the folder?
termora.welcome.contextmenu.import.xshell-folder-empty=The folder does not contain any *.xsh files, Please select the correct Xshell Sessions directory
termora.welcome.contextmenu.import.finalshell-folder-empty=The folder does not contain any *_connect_config.json files, Please select the correct FinalShell directory
# New Host
termora.new-host.title=Create a new host
@@ -335,6 +343,7 @@ termora.actions.open-local-terminal=Open Local Terminal
termora.actions.open-find-everywhere=Open FindEverywhere
termora.actions.open-new-window=Open new Window
termora.actions.clear-screen=Clear Terminal Screen
termora.actions.open-sftp-command=Open SFTP Command
termora.actions.switch-tab=Switch to specific Tab [1..9]
# Terminal
@@ -343,6 +352,19 @@ termora.terminal.copied=Copied
termora.terminal.channel-disconnected=Channel has been disconnected.\u0020
termora.terminal.channel-reconnect=Type {0} to reconnect.
# Visual Window
termora.visual-window.system-information=System information
termora.visual-window.system-information.mem=Mem
termora.visual-window.system-information.swap=Swap
termora.visual-window.system-information.filesystem=Filesystem
termora.visual-window.system-information.used-total=Used / Total
termora.visual-window.nvidia-smi=NVIDIA SMI
termora.floating-toolbar.not-supported=This action is not supported
# zmodem
termora.addons.zmodem.skip=SKIP

View File

@@ -65,8 +65,6 @@ termora.find-everywhere.groups.opened-hosts=已打开的主机
termora.find-everywhere.groups.tools=工具
termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=本地终端
termora.find-everywhere.double-shift-deprecated=双击 Shift 快捷键将会在未来的版本中移除
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated},请使用 {0} 代替
termora.settings.terminal=终端
termora.settings.terminal.font=字体
@@ -76,6 +74,7 @@ termora.settings.terminal.debug=调试模式
termora.settings.terminal.beep=蜂鸣声
termora.settings.terminal.select-copy=选中复制
termora.settings.terminal.cursor-style=光标样式
termora.settings.terminal.cursor-blink=光标闪烁
termora.settings.terminal.local-shell=本地终端
termora.settings.terminal.floating-toolbar=悬浮工具栏
termora.settings.terminal.auto-close-tab=自动关闭标签
@@ -114,6 +113,7 @@ termora.settings.keymap.already-exists=快捷键 [{0}] 已经被 [{1}] 占用
termora.settings.sftp.edit-command=编辑命令
termora.settings.sftp.fixed-tab=固定标签
# Welcome
@@ -132,6 +132,13 @@ termora.welcome.contextmenu.new.folder.name=新建文件夹
termora.welcome.contextmenu.property=属性
termora.welcome.contextmenu.show-more-info=显示更多信息
termora.welcome.contextmenu.download=下载
termora.welcome.contextmenu.import.csv.download-template=您要导入还是下载模板?
termora.welcome.contextmenu.import.csv.download-template-done=下载成功
termora.welcome.contextmenu.import.csv.download-template-done-open-folder=下载成功, 是否需要打开所在文件夹?
termora.welcome.contextmenu.import.xshell-folder-empty=该文件夹不包含 *.xsh 文件,请选择正确的 Xshell 会话目录
termora.welcome.contextmenu.import.finalshell-folder-empty=该文件夹不包含 *_connect_config.json 文件,请选择正确的 FinalShell 配置目录
# New Host
termora.new-host.title=新建主机
termora.new-host.general=属性
@@ -327,7 +334,19 @@ termora.actions.open-local-terminal=打开本地终端
termora.actions.open-find-everywhere=打开全局查找
termora.actions.open-new-window=打开新窗口
termora.actions.clear-screen=清除终端屏幕
termora.actions.open-sftp-command=打开 SFTP 终端
termora.actions.switch-tab=切换到特定标签页 [1..9]
# Visual Window
termora.visual-window.system-information=系统信息
termora.visual-window.system-information.mem=内存
termora.visual-window.system-information.swap=交换
termora.visual-window.system-information.filesystem=文件系统
termora.visual-window.system-information.used-total=使用 / 大小
termora.floating-toolbar.not-supported=不允许此操作
# zmodem
termora.addons.zmodem.skip=跳过

View File

@@ -63,6 +63,7 @@ termora.settings.keymap.action=操作
termora.settings.keymap.already-exists=快捷鍵 [{0}] 已經被 [{1}] 占用
termora.settings.sftp.edit-command=編輯命令
termora.settings.sftp.fixed-tab=固定標籤
# Find everywhere
@@ -74,8 +75,6 @@ termora.find-everywhere.groups.opened-hosts=已開啟的主機
termora.find-everywhere.groups.tools=工具
termora.find-everywhere.groups.settings=${termora.setting}
termora.find-everywhere.quick-command.local-terminal=本地端
termora.find-everywhere.double-shift-deprecated=雙擊 Shift 快捷鍵將會在未來的版本中移除
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated},請使用 {0} 代替
termora.settings.terminal=終端
termora.settings.terminal.font=字體
@@ -85,6 +84,7 @@ termora.settings.terminal.debug=偵錯模式
termora.settings.terminal.beep=蜂鳴聲
termora.settings.terminal.select-copy=選取複製
termora.settings.terminal.cursor-style=遊標風格
termora.settings.terminal.cursor-blink=遊標閃爍
termora.settings.terminal.local-shell=本地端
termora.settings.terminal.floating-toolbar=懸浮工具列
termora.settings.terminal.auto-close-tab=自動關閉標籤
@@ -130,6 +130,12 @@ termora.welcome.contextmenu.new.host=主機
termora.welcome.contextmenu.new.folder.name=新建資料夾
termora.welcome.contextmenu.property=屬性
termora.welcome.contextmenu.show-more-info=顯示更多信息
termora.welcome.contextmenu.download=下載
termora.welcome.contextmenu.import.csv.download-template=您要匯入還是下載範本?
termora.welcome.contextmenu.import.csv.download-template-done=下載成功
termora.welcome.contextmenu.import.csv.download-template-done-open-folder=下載成功, 是否需要開啟所在資料夾?
termora.welcome.contextmenu.import.xshell-folder-empty=該資料夾不包含 *.xsh 文件,請選擇正確的 Xshell 會話目錄
termora.welcome.contextmenu.import.finalshell-folder-empty=該資料夾不包含 *_connect_config.json 文件,請選擇正確的 FinalShell 設定目錄
# New Host
termora.new-host.title=新主機
@@ -308,8 +314,17 @@ termora.actions.open-local-terminal=開啟本地終端
termora.actions.open-find-everywhere=開啟全域搜尋
termora.actions.open-new-window=開啟新視窗
termora.actions.clear-screen=清除終端機螢幕
termora.actions.open-sftp-command=打開 SFTP 終端
termora.actions.switch-tab=切換到特定分頁 [1..9]
# Visual Window
termora.visual-window.system-information=系統訊息
termora.visual-window.system-information.mem=內存
termora.visual-window.system-information.swap=交換
termora.visual-window.system-information.filesystem=檔案系統
termora.visual-window.system-information.used-total=使用 / 大小
termora.floating-toolbar.not-supported=不允許此操作
# zmodem
termora.addons.zmodem.skip=跳過

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 fill-rule="evenodd" clip-rule="evenodd" d="M8.5 5V2.02054C11.4149 2.26101 13.739 4.5851 13.9795 7.5H11C10.7239 7.5 10.5 7.72386 10.5 8C10.5 8.27614 10.7239 8.5 11 8.5H13.9795C13.739 11.4149 11.4149 13.739 8.5 13.9795V11C8.5 10.7239 8.27614 10.5 8 10.5C7.72386 10.5 7.5 10.7239 7.5 11V13.9795C4.5851 13.739 2.26101 11.4149 2.02054 8.5H5C5.27614 8.5 5.5 8.27614 5.5 8C5.5 7.72386 5.27614 7.5 5 7.5H2.02054C2.26101 4.5851 4.5851 2.26101 7.5 2.02054V5C7.5 5.27614 7.72386 5.5 8 5.5C8.27614 5.5 8.5 5.27614 8.5 5ZM1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 866 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 fill-rule="evenodd" clip-rule="evenodd" d="M8.5 5V2.02054C11.4149 2.26101 13.739 4.5851 13.9795 7.5H11C10.7239 7.5 10.5 7.72386 10.5 8C10.5 8.27614 10.7239 8.5 11 8.5H13.9795C13.739 11.4149 11.4149 13.739 8.5 13.9795V11C8.5 10.7239 8.27614 10.5 8 10.5C7.72386 10.5 7.5 10.7239 7.5 11V13.9795C4.5851 13.739 2.26101 11.4149 2.02054 8.5H5C5.27614 8.5 5.5 8.27614 5.5 8C5.5 7.72386 5.27614 7.5 5 7.5H2.02054C2.26101 4.5851 4.5851 2.26101 7.5 2.02054V5C7.5 5.27614 7.72386 5.5 8 5.5C8.27614 5.5 8.5 5.27614 8.5 5ZM1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 866 B

View File

@@ -0,0 +1,5 @@
<svg t="1740039296619" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516"
width="16" height="16">
<path d="M381.781333 375.381333v-61.013333a285.866667 285.866667 0 0 1 18.090667-0.768c167.338667-5.290667 277.034667 143.957333 277.034667 143.957333s-118.357333 164.309333-245.333334 164.309334a156.586667 156.586667 0 0 1-49.408-7.893334v-185.429333c65.194667 7.893333 78.378667 36.565333 117.205334 101.76l87.04-73.130667s-63.658667-83.285333-170.666667-83.285333a256.682667 256.682667 0 0 0-33.962667 1.493333m0-202.026666v91.221333l18.090667-1.152c232.533333-7.893333 384.426667 190.72 384.426667 190.72s-174.08 211.797333-355.413334 211.797333a275.626667 275.626667 0 0 1-46.72-4.138666v56.533333c12.8 1.493333 26.026667 2.645333 38.826667 2.645333 168.832 0 290.986667-86.314667 409.301333-188.074666 19.584 15.829333 99.84 53.888 116.48 70.485333-112.341333 94.208-374.272 169.984-522.794666 169.984-14.293333 0-27.861333-0.768-41.429334-2.261333v79.530666H1024V173.354667z m0 440.576v48.256c-156.032-27.904-199.381333-190.293333-199.381333-190.293334s75.008-82.944 199.381333-96.512v52.778667H381.44c-65.194667-7.936-116.48 53.12-116.48 53.12s29.013333 102.912 116.864 132.693333M104.789333 465.066667s92.330667-136.405333 277.333334-150.741334V264.576C177.194667 281.173333 0 454.528 0 454.528s100.266667 290.218667 381.781333 316.586667v-52.778667c-206.506667-25.6-276.992-253.269333-276.992-253.269333z"
p-id="9517" fill="#6C707E"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,5 @@
<svg t="1740039296619" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516"
width="16" height="16">
<path d="M381.781333 375.381333v-61.013333a285.866667 285.866667 0 0 1 18.090667-0.768c167.338667-5.290667 277.034667 143.957333 277.034667 143.957333s-118.357333 164.309333-245.333334 164.309334a156.586667 156.586667 0 0 1-49.408-7.893334v-185.429333c65.194667 7.893333 78.378667 36.565333 117.205334 101.76l87.04-73.130667s-63.658667-83.285333-170.666667-83.285333a256.682667 256.682667 0 0 0-33.962667 1.493333m0-202.026666v91.221333l18.090667-1.152c232.533333-7.893333 384.426667 190.72 384.426667 190.72s-174.08 211.797333-355.413334 211.797333a275.626667 275.626667 0 0 1-46.72-4.138666v56.533333c12.8 1.493333 26.026667 2.645333 38.826667 2.645333 168.832 0 290.986667-86.314667 409.301333-188.074666 19.584 15.829333 99.84 53.888 116.48 70.485333-112.341333 94.208-374.272 169.984-522.794666 169.984-14.293333 0-27.861333-0.768-41.429334-2.261333v79.530666H1024V173.354667z m0 440.576v48.256c-156.032-27.904-199.381333-190.293333-199.381333-190.293334s75.008-82.944 199.381333-96.512v52.778667H381.44c-65.194667-7.936-116.48 53.12-116.48 53.12s29.013333 102.912 116.864 132.693333M104.789333 465.066667s92.330667-136.405333 277.333334-150.741334V264.576C177.194667 281.173333 0 454.528 0 454.528s100.266667 290.218667 381.781333 316.586667v-52.778667c-206.506667-25.6-276.992-253.269333-276.992-253.269333z"
p-id="9517" fill="#CED0D6"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 14C2.89543 14 2 13.1046 2 12L2 4C2 2.89543 2.89543 2 4 2L6.5 2C6.77614 2 7 2.22386 7 2.5C7 2.77614 6.77614 3 6.5 3L4 3C3.44772 3 3 3.44772 3 4L3 12C3 12.5523 3.44772 13 4 13H12C12.5523 13 13 12.5523 13 12V9.5C13 9.22386 13.2239 9 13.5 9C13.7761 9 14 9.22386 14 9.5V12C14 13.1046 13.1046 14 12 14H4Z" fill="#6C707E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 2V6.5C14 6.77614 13.7761 7 13.5 7C13.2239 7 13 6.77614 13 6.5V3.70711L8.85355 7.85355C8.65829 8.04882 8.34171 8.04882 8.14645 7.85355C7.95118 7.65829 7.95118 7.34171 8.14645 7.14645L12.2929 3L9.5 3C9.22386 3 9 2.77614 9 2.5C9 2.22386 9.22386 2 9.5 2L14 2Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 799 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 14C2.89543 14 2 13.1046 2 12L2 4C2 2.89543 2.89543 2 4 2L6.5 2C6.77614 2 7 2.22386 7 2.5C7 2.77614 6.77614 3 6.5 3L4 3C3.44772 3 3 3.44772 3 4L3 12C3 12.5523 3.44772 13 4 13H12C12.5523 13 13 12.5523 13 12V9.5C13 9.22386 13.2239 9 13.5 9C13.7761 9 14 9.22386 14 9.5V12C14 13.1046 13.1046 14 12 14H4Z" fill="#CED0D6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 2V6.5C14 6.77614 13.7761 7 13.5 7C13.2239 7 13 6.77614 13 6.5V3.70711L8.85355 7.85355C8.65829 8.04882 8.34171 8.04882 8.14645 7.85355C7.95118 7.65829 7.95118 7.34171 8.14645 7.14645L12.2929 3L9.5 3C9.22386 3 9 2.77614 9 2.5C9 2.22386 9.22386 2 9.5 2L14 2Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 799 B

View File

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

After

Width:  |  Height:  |  Size: 508 B

View File

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

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,5 @@
<svg t="1740037914635" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5141"
width="16" height="16">
<path d="M855.7 210.8l-42.4-42.4c-3.1-3.1-8.2-3.1-11.3 0L168.3 801.9c-3.1 3.1-3.1 8.2 0 11.3l42.4 42.4c3.1 3.1 8.2 3.1 11.3 0L855.6 222c3.2-3 3.2-8.1 0.1-11.2zM304 448c79.4 0 144-64.6 144-144s-64.6-144-144-144-144 64.6-144 144 64.6 144 144 144z m0-216c39.7 0 72 32.3 72 72s-32.3 72-72 72-72-32.3-72-72 32.3-72 72-72zM720 576c-79.4 0-144 64.6-144 144s64.6 144 144 144 144-64.6 144-144-64.6-144-144-144z m0 216c-39.7 0-72-32.3-72-72s32.3-72 72-72 72 32.3 72 72-32.3 72-72 72z"
p-id="5142" fill="#6C707E"></path>
</svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@@ -0,0 +1,5 @@
<svg t="1740037914635" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5141"
width="16" height="16">
<path d="M855.7 210.8l-42.4-42.4c-3.1-3.1-8.2-3.1-11.3 0L168.3 801.9c-3.1 3.1-3.1 8.2 0 11.3l42.4 42.4c3.1 3.1 8.2 3.1 11.3 0L855.6 222c3.2-3 3.2-8.1 0.1-11.2zM304 448c79.4 0 144-64.6 144-144s-64.6-144-144-144-144 64.6-144 144 64.6 144 144 144z m0-216c39.7 0 72 32.3 72 72s-32.3 72-72 72-72-32.3-72-72 32.3-72 72-72zM720 576c-79.4 0-144 64.6-144 144s64.6 144 144 144 144-64.6 144-144-64.6-144-144-144z m0 216c-39.7 0-72-32.3-72-72s32.3-72 72-72 72 32.3 72 72-32.3 72-72 72z"
p-id="5142" fill="#CED0D6"></path>
</svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@@ -0,0 +1,5 @@
<svg t="1740038430861" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7036"
width="16" height="16">
<path d="M853.333333 138.666667H170.666667c-17.066667 0-32 14.933333-32 32v128c0 17.066667 14.933333 32 32 32s32-14.933333 32-32V202.666667h277.333333v618.666666H384c-17.066667 0-32 14.933333-32 32s14.933333 32 32 32h256c17.066667 0 32-14.933333 32-32s-14.933333-32-32-32h-96v-618.666666h277.333333V298.666667c0 17.066667 14.933333 32 32 32s32-14.933333 32-32V170.666667c0-17.066667-14.933333-32-32-32z"
fill="#6C707E" p-id="7037"></path>
</svg>

After

Width:  |  Height:  |  Size: 609 B

View File

@@ -0,0 +1,5 @@
<svg t="1740038430861" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7036"
width="16" height="16">
<path d="M853.333333 138.666667H170.666667c-17.066667 0-32 14.933333-32 32v128c0 17.066667 14.933333 32 32 32s32-14.933333 32-32V202.666667h277.333333v618.666666H384c-17.066667 0-32 14.933333-32 32s14.933333 32 32 32h256c17.066667 0 32-14.933333 32-32s-14.933333-32-32-32h-96v-618.666666h277.333333V298.666667c0 17.066667 14.933333 32 32 32s32-14.933333 32-32V170.666667c0-17.066667-14.933333-32-32-32z"
fill="#CED0D6" p-id="7037"></path>
</svg>

After

Width:  |  Height:  |  Size: 609 B

View File

@@ -0,0 +1,85 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppPublisher "TermoraDev"
#define MyAppURL "https://github.com/TermoraDev/termora"
#define MyAppSupportURL "https://github.com/TermoraDev/termora/issues"
#define MyAppUpdatesURL "https://github.com/TermoraDev/termora/releases"
#define MyAppExeName "Termora.exe"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={#MyAppId}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppSupportURL}
AppUpdatesURL={#MyAppUpdatesURL}
DefaultDirName={autopf}\{#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run
; on anything but x64 and Windows 11 on Arm.
ArchitecturesAllowed=x64compatible
; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the
; install be done in "64-bit mode" on x64 or Windows 11 on Arm,
; meaning it should use the native 64-bit Program Files directory and
; the 64-bit view of the registry.
ArchitecturesInstallIn64BitMode=x64compatible
DisableProgramGroupPage=yes
; Uncomment the following line to run in non administrative install mode (install for current user only).
;PrivilegesRequired=lowest
OutputDir={#MyOutputDir}
OutputBaseFilename={#MyAppName}-{#MyAppVersion}
SolidCompression=yes
WizardStyle=classic
;WizardStyle=modern
SetupIconFile={#MySetupIconFile}
;WizardSmallImageFile=
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "{#MySourceDir}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MySourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall; Check: ShouldPromptStart
Filename: "{app}\{#MyAppExeName}"; Flags: nowait runhidden; Check: ShouldAutoStart
[Code]
function CmdLineParamExists(const Value: string): Boolean;
var
I: Integer;
begin
Result := False;
for I := 1 to ParamCount do
if CompareText(ParamStr(I), Value) = 0 then
begin
Result := True;
Exit;
end;
end;
function ShouldAutoStart: Boolean;
begin
// 静默模式下且包含 /AUTOSTART 参数时自动启动
Result := WizardSilent and CmdLineParamExists('/AUTOSTART');
end;
function ShouldPromptStart: Boolean;
begin
Result := not WizardSilent;
end;

View File

@@ -1,6 +1,6 @@
FROM linuxserver/openssh-server
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update && apk add wget gcc g++ git make zsh htop inetutils-telnet && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \
&& apk update && apk add wget gcc g++ git make zsh htop stress-ng inetutils-telnet && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \
&& tar -xf lrzsz-0.12.20.tar.gz && cd lrzsz-0.12.20 && ./configure && make && make install \
&& ln -s /usr/local/bin/lrz /usr/local/bin/rz && ln -s /usr/local/bin/lsz /usr/local/bin/sz