mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 02:12:58 +08:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e690bafed | ||
|
|
28b511e179 | ||
|
|
f010a13abd | ||
|
|
4d80ffafdd | ||
|
|
9aecd4d54b | ||
|
|
65091823eb | ||
|
|
d17218bfbd | ||
|
|
724c5d2632 | ||
|
|
6806c26028 | ||
|
|
dcd89174c9 | ||
|
|
9a8707b8cb | ||
|
|
28f1d05f06 | ||
|
|
54b044584e | ||
|
|
ed39449a20 | ||
|
|
2ff3f3a352 | ||
|
|
91e2e964a5 | ||
|
|
ca6cc68fed | ||
|
|
0962de7735 | ||
|
|
062b957fdb | ||
|
|
4efe4e5663 | ||
|
|
25eb6966c4 | ||
|
|
7843460020 | ||
|
|
1cbc6ba4a9 | ||
|
|
a43407bee8 | ||
|
|
05c4ec9af2 | ||
|
|
9236064293 | ||
|
|
e1955a371e | ||
|
|
58b56c4221 | ||
|
|
1e461e529f | ||
|
|
38ada1207c | ||
|
|
8bd1b34f46 | ||
|
|
4a513360e6 | ||
|
|
22da5c1c37 | ||
|
|
483582a8d1 | ||
|
|
f037cbfac0 | ||
|
|
343d11482d | ||
|
|
7ef81a0116 | ||
|
|
5df62d5d3e | ||
|
|
7db650d69f | ||
|
|
8d80d38d63 | ||
|
|
48f05d4cff | ||
|
|
9a1cf387c0 | ||
|
|
8b7efefbdb | ||
|
|
75f21db325 | ||
|
|
b094c9d4ff | ||
|
|
0da3c95759 | ||
|
|
fa79473ece | ||
|
|
86ccb5e0cc | ||
|
|
f385f4b277 | ||
|
|
3d0ef2a331 | ||
|
|
96999205a8 | ||
|
|
ee7f3871eb | ||
|
|
df2e9b0743 | ||
|
|
7964950149 | ||
|
|
e2d77fe881 | ||
|
|
f5783c8587 | ||
|
|
346044b1ba | ||
|
|
aa6ec8dd43 | ||
|
|
e0e6a85a81 | ||
|
|
56ba107c87 |
4
.github/workflows/linux-x86-64.yml
vendored
4
.github/workflows/linux-x86-64.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
# download jdk
|
||||
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-linux-x64-b509.30.tar.gz
|
||||
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b825.69.tar.gz
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.5'
|
||||
java-version: '21.0.6'
|
||||
architecture: x64
|
||||
|
||||
# dist
|
||||
|
||||
33
.github/workflows/osx-aarch64.yml
vendored
33
.github/workflows/osx-aarch64.yml
vendored
@@ -10,9 +10,31 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the Apple certificate
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
|
||||
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# create variables
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
|
||||
# import certificate from secrets
|
||||
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||
|
||||
# create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# import certificate to keychain
|
||||
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
# download jdk
|
||||
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-osx-aarch64-b509.30.tar.gz
|
||||
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
@@ -20,12 +42,15 @@ jobs:
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.5'
|
||||
java-version: '21.0.6'
|
||||
architecture: aarch64
|
||||
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
- name: Dist
|
||||
env:
|
||||
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' }}
|
||||
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
|
||||
run: |
|
||||
./gradlew dist --no-daemon
|
||||
|
||||
- name: Upload artifact
|
||||
|
||||
33
.github/workflows/osx-x86-64.yml
vendored
33
.github/workflows/osx-x86-64.yml
vendored
@@ -10,8 +10,31 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the Apple certificate
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
|
||||
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# create variables
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
|
||||
# import certificate from secrets
|
||||
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||
|
||||
# create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# import certificate to keychain
|
||||
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
# download jdk
|
||||
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-osx-x64-b509.30.tar.gz
|
||||
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
@@ -19,12 +42,16 @@ jobs:
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.5'
|
||||
java-version: '21.0.6'
|
||||
architecture: x64
|
||||
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
- name: Dist
|
||||
env:
|
||||
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' }}
|
||||
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
|
||||
run: |
|
||||
./gradlew dist --no-daemon
|
||||
|
||||
- name: Upload artifact
|
||||
|
||||
13
.github/workflows/winget.yml
vendored
Normal file
13
.github/workflows/winget.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
release:
|
||||
types: [ released ]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@main
|
||||
with:
|
||||
identifier: TermoraDev.Termora
|
||||
installers-regex: 'x86-64\.msi$' # Only x86-64.msi files
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
@@ -15,10 +15,12 @@
|
||||
## Features
|
||||
|
||||
- SSH and local terminal support
|
||||
- Serial port protocol support
|
||||
- [SFTP](./docs/sftp.png?raw=1) file transfer support
|
||||
- Compatible with Windows, macOS, and Linux
|
||||
- Zmodem protocol support
|
||||
- SSH port forwarding
|
||||
- SSH port forwarding & Jump hosts
|
||||
- Terminal log
|
||||
- Configuration synchronization via [Gist](https://gist.github.com)
|
||||
- Macro support (record and replay scripts)
|
||||
- Keyword highlighting
|
||||
@@ -32,6 +34,7 @@
|
||||
|
||||
- [Latest release](https://github.com/TermoraDev/termora/releases/latest)
|
||||
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora`
|
||||
- [WinGet](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/TermoraDev/Termora): `winget install termora`
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
## 功能特性
|
||||
|
||||
- 支持 SSH 和本地终端
|
||||
- 支持串口协议
|
||||
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) 文件传输
|
||||
- 支持 Windows、macOS、Linux 平台
|
||||
- 支持 Zmodem 协议
|
||||
- 支持 SSH 端口转发
|
||||
- 支持 SSH 端口转发和跳板机
|
||||
- 终端日志记录
|
||||
- 支持配置同步到 [Gist](https://gist.github.com)
|
||||
- 支持宏(录制脚本并回放)
|
||||
- 支持关键词高亮
|
||||
@@ -28,6 +30,7 @@
|
||||
|
||||
- [Latest release](https://github.com/TermoraDev/termora/releases/latest)
|
||||
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora`
|
||||
- [WinGet](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/TermoraDev/Termora): `winget install termora`
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -241,3 +241,7 @@ https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
|
||||
json-20231013
|
||||
Public Domain.
|
||||
https://github.com/stleary/JSON-java/blob/master/LICENSE
|
||||
|
||||
jSerialComm 2.11.0
|
||||
Apache License 2.0
|
||||
https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0
|
||||
@@ -1,5 +1,6 @@
|
||||
import org.gradle.internal.jvm.Jvm
|
||||
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
|
||||
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
|
||||
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
|
||||
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils
|
||||
import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
|
||||
@@ -14,10 +15,10 @@ plugins {
|
||||
|
||||
|
||||
group = "app.termora"
|
||||
version = "1.0.3"
|
||||
version = "1.0.7"
|
||||
|
||||
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
|
||||
val arch: Architecture = DefaultNativePlatform.getCurrentArchitecture()
|
||||
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
|
||||
|
||||
// macOS 签名信息
|
||||
val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY
|
||||
@@ -37,7 +38,7 @@ repositories {
|
||||
|
||||
dependencies {
|
||||
// 由于签名和公证,macOS 不携带 natives
|
||||
val useNoNativesFlatLaf = os.isMacOsX && macOSNotary && System.getenv("ENABLE_BUILD").toBoolean()
|
||||
val useNoNativesFlatLaf = os.isMacOsX && System.getenv("ENABLE_BUILD").toBoolean()
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation(libs.hutool)
|
||||
@@ -104,6 +105,7 @@ dependencies {
|
||||
implementation(libs.bip39)
|
||||
implementation(libs.colorpicker)
|
||||
implementation(libs.mixpanel)
|
||||
implementation(libs.jSerialComm)
|
||||
}
|
||||
|
||||
application {
|
||||
@@ -148,6 +150,8 @@ tasks.register<Copy>("copy-dependencies") {
|
||||
val jna = libs.jna.asProvider().get()
|
||||
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)
|
||||
@@ -172,6 +176,21 @@ tasks.register<Copy>("copy-dependencies") {
|
||||
// @formatter:on
|
||||
// 删除所有二进制类库
|
||||
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
|
||||
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "OSX/${archName}/*", "-d", targetDir.absolutePath) }
|
||||
// @formatter:on
|
||||
// 删除所有二进制类库
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Android/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "FreeBSD/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Linux/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OpenBSD/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,15 +251,18 @@ tasks.register<Exec>("jpackage") {
|
||||
"-Dapp-version=${project.version}",
|
||||
)
|
||||
|
||||
if (os.isMacOsX) {
|
||||
options.add("-Dsun.java2d.metal=true")
|
||||
|
||||
if (os.isMacOsX) {
|
||||
options.add("-Dapple.awt.application.appearance=system")
|
||||
options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
|
||||
} else {
|
||||
}
|
||||
|
||||
if (os.isLinux) {
|
||||
options.add("-Dsun.java2d.opengl=true")
|
||||
}
|
||||
|
||||
val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage", "--verbose")
|
||||
val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage")
|
||||
arguments.addAll(listOf("--runtime-image", "${buildDir}/jlink"))
|
||||
arguments.addAll(listOf("--name", project.name.uppercaseFirstChar()))
|
||||
arguments.addAll(listOf("--app-version", "${project.version}"))
|
||||
@@ -383,6 +405,14 @@ tasks.register("dist") {
|
||||
"--wait",
|
||||
)
|
||||
}
|
||||
|
||||
// 绑定公证信息
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/xcrun",
|
||||
"stapler", "staple", macOSFinalFilePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,6 +437,18 @@ tasks.register("check-license") {
|
||||
thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license
|
||||
thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first())
|
||||
}
|
||||
|
||||
for (file in configurations.runtimeClasspath.get()) {
|
||||
val name = file.nameWithoutExtension
|
||||
if (!thirdParty.containsKey(name)) {
|
||||
if (logger.isWarnEnabled) {
|
||||
logger.warn("$name does not exist in third-party")
|
||||
}
|
||||
if (!thirdPartyNames.contains(name)) {
|
||||
throw GradleException("$name No license found")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ rhino = "1.7.15"
|
||||
delight-rhino-sandbox = "0.0.17"
|
||||
testcontainers = "1.20.4"
|
||||
mixpanel = "1.5.3"
|
||||
jSerialComm="2.11.0"
|
||||
|
||||
[libraries]
|
||||
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
@@ -97,6 +98,7 @@ rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
|
||||
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
|
||||
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
|
||||
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
|
||||
jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
|
||||
@@ -111,11 +111,18 @@ object Application {
|
||||
return "Termora"
|
||||
}
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
fun browse(uri: URI, async: Boolean = true) {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
// https://github.com/TermoraDev/termora/issues/178
|
||||
if (SystemInfo.isWindows && uri.scheme == "file") {
|
||||
if (async) {
|
||||
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
|
||||
} else {
|
||||
tryBrowse(uri)
|
||||
}
|
||||
} else if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
Desktop.getDesktop().browse(uri)
|
||||
} else if (async) {
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
|
||||
} else {
|
||||
tryBrowse(uri)
|
||||
|
||||
@@ -10,9 +10,6 @@ import com.jthemedetecor.OsThemeDetector
|
||||
import com.mixpanel.mixpanelapi.ClientDelivery
|
||||
import com.mixpanel.mixpanelapi.MessageBuilder
|
||||
import com.mixpanel.mixpanelapi.MixpanelAPI
|
||||
import com.sun.jna.platform.WindowUtils
|
||||
import com.sun.jna.platform.win32.User32
|
||||
import com.sun.jna.ptr.IntByReference
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -20,25 +17,27 @@ import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.LocaleUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.math.NumberUtils
|
||||
import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.tinylog.configuration.Configuration
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.FileLock
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class ApplicationRunner {
|
||||
private lateinit var singletonChannel: FileChannel
|
||||
private lateinit var singletonLock: FileLock
|
||||
private val log by lazy {
|
||||
if (!::singletonLock.isInitialized) {
|
||||
throw UnsupportedOperationException("Singleton lock is not initialized")
|
||||
}
|
||||
LoggerFactory.getLogger("Main")
|
||||
LoggerFactory.getLogger(ApplicationRunner::class.java)
|
||||
}
|
||||
|
||||
fun run() {
|
||||
@@ -224,36 +223,14 @@ class ApplicationRunner {
|
||||
|
||||
|
||||
private fun checkSingleton() {
|
||||
val file = File(Application.getBaseDataDir(), "lock")
|
||||
val pidFile = File(Application.getBaseDataDir(), "pid")
|
||||
|
||||
|
||||
val raf = RandomAccessFile(file, "rw")
|
||||
val lock = raf.channel.tryLock()
|
||||
|
||||
if (lock != null) {
|
||||
pidFile.writeText(ProcessHandle.current().pid().toString())
|
||||
pidFile.deleteOnExit()
|
||||
file.deleteOnExit()
|
||||
} else {
|
||||
if (SystemInfo.isWindows && pidFile.exists()) {
|
||||
val pid = NumberUtils.toLong(pidFile.readText())
|
||||
for (window in WindowUtils.getAllWindows(false)) {
|
||||
if (pid > 0) {
|
||||
val processId = IntByReference()
|
||||
User32.INSTANCE.GetWindowThreadProcessId(window.hwnd, processId)
|
||||
if (processId.value.toLong() != pid) {
|
||||
continue
|
||||
}
|
||||
} else if (window.title != Application.getName() || window.filePath.endsWith("explorer.exe")) {
|
||||
continue
|
||||
}
|
||||
User32.INSTANCE.ShowWindow(window.hwnd, User32.SW_SHOWNOACTIVATE)
|
||||
User32.INSTANCE.SetForegroundWindow(window.hwnd)
|
||||
break
|
||||
}
|
||||
}
|
||||
singletonChannel = FileChannel.open(
|
||||
Paths.get(Application.getBaseDataDir().absolutePath, "lock"),
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.WRITE,
|
||||
)
|
||||
|
||||
val lock = singletonChannel.tryLock()
|
||||
if (lock == null) {
|
||||
System.err.println("Program is already running")
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@ class ChannelShellPtyConnector(
|
||||
output.flush()
|
||||
}
|
||||
|
||||
override fun write(buffer: String) {
|
||||
write(buffer.toByteArray(charset))
|
||||
}
|
||||
|
||||
override fun resize(rows: Int, cols: Int) {
|
||||
channel.sendWindowChange(cols, rows)
|
||||
}
|
||||
@@ -38,4 +34,8 @@ class ChannelShellPtyConnector(
|
||||
override fun close() {
|
||||
channel.close(true)
|
||||
}
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return charset
|
||||
}
|
||||
}
|
||||
@@ -454,6 +454,11 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
*/
|
||||
var debug by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 蜂鸣声
|
||||
*/
|
||||
var beep by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 选中复制
|
||||
*/
|
||||
@@ -463,6 +468,11 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
* 光标样式
|
||||
*/
|
||||
var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
|
||||
|
||||
/**
|
||||
* 终端断开连接时自动关闭Tab
|
||||
*/
|
||||
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,16 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
||||
}
|
||||
|
||||
jumpHostsOption.filter = { it.id != host.id }
|
||||
|
||||
val serialComm = host.options.serialComm
|
||||
if (serialComm.port.isNotBlank()) {
|
||||
serialCommOption.serialPortComboBox.selectedItem = serialComm.port
|
||||
}
|
||||
serialCommOption.baudRateComboBox.selectedItem = serialComm.baudRate
|
||||
serialCommOption.dataBitsComboBox.selectedItem = serialComm.dataBits
|
||||
serialCommOption.parityComboBox.selectedItem = serialComm.parity
|
||||
serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits
|
||||
serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl
|
||||
}
|
||||
|
||||
override fun getHost(): Host {
|
||||
|
||||
@@ -13,6 +13,7 @@ enum class Protocol {
|
||||
Folder,
|
||||
SSH,
|
||||
Local,
|
||||
Serial
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +40,53 @@ data class Authentication(
|
||||
}
|
||||
}
|
||||
|
||||
enum class SerialCommParity {
|
||||
None,
|
||||
Even,
|
||||
Odd,
|
||||
Mark,
|
||||
Space
|
||||
}
|
||||
|
||||
enum class SerialCommFlowControl {
|
||||
None,
|
||||
RTS_CTS,
|
||||
XON_XOFF,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SerialComm(
|
||||
/**
|
||||
* 串口
|
||||
*/
|
||||
val port: String = StringUtils.EMPTY,
|
||||
|
||||
/**
|
||||
* 波特率
|
||||
*/
|
||||
val baudRate: Int = 9600,
|
||||
|
||||
/**
|
||||
* 数据位:5、6、7、8
|
||||
*/
|
||||
val dataBits: Int = 8,
|
||||
|
||||
/**
|
||||
* 停止位: 1、1.5、2
|
||||
*/
|
||||
val stopBits: String = "1",
|
||||
|
||||
/**
|
||||
* 校验位
|
||||
*/
|
||||
val parity: SerialCommParity = SerialCommParity.None,
|
||||
|
||||
/**
|
||||
* 流控
|
||||
*/
|
||||
val flowControl: SerialCommFlowControl = SerialCommFlowControl.None,
|
||||
)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class Options(
|
||||
@@ -61,7 +109,12 @@ data class Options(
|
||||
/**
|
||||
* SSH 心跳间隔
|
||||
*/
|
||||
val heartbeatInterval: Int = 30
|
||||
val heartbeatInterval: Int = 30,
|
||||
|
||||
/**
|
||||
* 串口配置
|
||||
*/
|
||||
val serialComm: SerialComm = SerialComm(),
|
||||
) {
|
||||
companion object {
|
||||
val Default = Options()
|
||||
|
||||
@@ -2,6 +2,9 @@ package app.termora
|
||||
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
@@ -47,38 +50,70 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
|
||||
}
|
||||
|
||||
putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...")
|
||||
SwingUtilities.invokeLater {
|
||||
isEnabled = false
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
testConnection(pane.getHost())
|
||||
withContext(Dispatchers.Swing) {
|
||||
putValue(NAME, I18n.getString("termora.new-host.test-connection"))
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun testConnection(host: Host) {
|
||||
if (host.protocol != Protocol.SSH) {
|
||||
OptionPane.showMessageDialog(this, I18n.getString("termora.new-host.test-connection-successful"))
|
||||
private suspend fun testConnection(host: Host) {
|
||||
val owner = this
|
||||
if (host.protocol == Protocol.Local) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(owner, I18n.getString("termora.new-host.test-connection-successful"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (host.protocol == Protocol.SSH) {
|
||||
testSSH(host)
|
||||
} else if (host.protocol == Protocol.Serial) {
|
||||
testSerial(host)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, ExceptionUtils.getMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.new-host.test-connection-successful")
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun testSSH(host: Host) {
|
||||
var client: SshClient? = null
|
||||
var session: ClientSession? = null
|
||||
try {
|
||||
client = SshClients.openClient(host)
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
session = SshClients.openSession(host, client)
|
||||
OptionPane.showMessageDialog(this, I18n.getString("termora.new-host.test-connection-successful"))
|
||||
} catch (e: Exception) {
|
||||
OptionPane.showMessageDialog(
|
||||
this, ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
} finally {
|
||||
session?.close()
|
||||
client?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testSerial(host: Host) {
|
||||
Serials.openPort(host).closePort()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
|
||||
@@ -2,11 +2,17 @@ package app.termora
|
||||
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.KeyManagerDialog
|
||||
import com.fazecast.jSerialComm.SerialPort
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatComboBox
|
||||
import com.formdev.flatlaf.ui.FlatTextBorder
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.*
|
||||
@@ -22,6 +28,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
protected val proxyOption = ProxyOption()
|
||||
protected val terminalOption = TerminalOption()
|
||||
protected val jumpHostsOption = JumpHostsOption()
|
||||
protected val serialCommOption = SerialCommOption()
|
||||
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
|
||||
|
||||
init {
|
||||
@@ -30,6 +37,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
addOption(tunnelingOption)
|
||||
addOption(jumpHostsOption)
|
||||
addOption(terminalOption)
|
||||
addOption(serialCommOption)
|
||||
|
||||
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
|
||||
}
|
||||
@@ -43,6 +51,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
var authentication = Authentication.No
|
||||
var proxy = Proxy.No
|
||||
|
||||
|
||||
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
|
||||
authentication = authentication.copy(
|
||||
type = AuthenticationType.Password,
|
||||
@@ -66,12 +75,23 @@ open class HostOptionsPane : OptionsPane() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val serialComm = SerialComm(
|
||||
port = serialCommOption.serialPortComboBox.selectedItem?.toString() ?: StringUtils.EMPTY,
|
||||
baudRate = serialCommOption.baudRateComboBox.selectedItem?.toString()?.toIntOrNull() ?: 9600,
|
||||
dataBits = serialCommOption.dataBitsComboBox.selectedItem as Int? ?: 8,
|
||||
stopBits = serialCommOption.stopBitsComboBox.selectedItem as String? ?: "1",
|
||||
parity = serialCommOption.parityComboBox.selectedItem as SerialCommParity,
|
||||
flowControl = serialCommOption.flowControlComboBox.selectedItem as SerialCommFlowControl
|
||||
)
|
||||
|
||||
val options = Options.Default.copy(
|
||||
encoding = terminalOption.charsetComboBox.selectedItem as String,
|
||||
env = terminalOption.environmentTextArea.text,
|
||||
startupCommand = terminalOption.startupCommandTextField.text,
|
||||
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
|
||||
jumpHosts = jumpHostsOption.jumpHosts.map { it.id }
|
||||
jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
|
||||
serialComm = serialComm
|
||||
)
|
||||
|
||||
return Host(
|
||||
@@ -103,6 +123,12 @@ open class HostOptionsPane : OptionsPane() {
|
||||
if (validateField(generalOption.usernameTextField)) {
|
||||
return false
|
||||
}
|
||||
} else if (host.protocol == Protocol.Serial) {
|
||||
if (validateField(serialCommOption.serialPortComboBox)
|
||||
|| validateField(serialCommOption.baudRateComboBox)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
@@ -152,7 +178,8 @@ open class HostOptionsPane : OptionsPane() {
|
||||
* 返回 true 表示有错误
|
||||
*/
|
||||
private fun validateField(comboBox: JComboBox<*>): Boolean {
|
||||
if (comboBox.isEnabled && comboBox.selectedItem == null) {
|
||||
val selectedItem = comboBox.selectedItem
|
||||
if (comboBox.isEnabled && (selectedItem == null || (selectedItem is String && selectedItem.isBlank()))) {
|
||||
selectOptionJComponent(comboBox)
|
||||
comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
|
||||
comboBox.requestFocusInWindow()
|
||||
@@ -259,6 +286,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
|
||||
protocolTypeComboBox.addItem(Protocol.SSH)
|
||||
protocolTypeComboBox.addItem(Protocol.Local)
|
||||
protocolTypeComboBox.addItem(Protocol.Serial)
|
||||
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.No)
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.Password)
|
||||
@@ -328,7 +356,9 @@ open class HostOptionsPane : OptionsPane() {
|
||||
passwordTextField.isEnabled = true
|
||||
chooseKeyBtn.isEnabled = true
|
||||
|
||||
if (protocolTypeComboBox.selectedItem == Protocol.Local) {
|
||||
if (protocolTypeComboBox.selectedItem == Protocol.Local
|
||||
|| protocolTypeComboBox.selectedItem == Protocol.Serial
|
||||
) {
|
||||
hostTextField.isEnabled = false
|
||||
portTextField.isEnabled = false
|
||||
usernameTextField.isEnabled = false
|
||||
@@ -901,6 +931,127 @@ open class HostOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class SerialCommOption : JPanel(BorderLayout()), Option {
|
||||
val serialPortComboBox = OutlineComboBox<String>()
|
||||
val baudRateComboBox = OutlineComboBox<Int>()
|
||||
val dataBitsComboBox = OutlineComboBox<Int>()
|
||||
val parityComboBox = OutlineComboBox<SerialCommParity>()
|
||||
val stopBitsComboBox = OutlineComboBox<String>()
|
||||
val flowControlComboBox = OutlineComboBox<SerialCommFlowControl>()
|
||||
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
|
||||
serialPortComboBox.isEditable = true
|
||||
|
||||
baudRateComboBox.isEditable = true
|
||||
baudRateComboBox.addItem(9600)
|
||||
baudRateComboBox.addItem(19200)
|
||||
baudRateComboBox.addItem(38400)
|
||||
baudRateComboBox.addItem(57600)
|
||||
baudRateComboBox.addItem(115200)
|
||||
|
||||
dataBitsComboBox.addItem(5)
|
||||
dataBitsComboBox.addItem(6)
|
||||
dataBitsComboBox.addItem(7)
|
||||
dataBitsComboBox.addItem(8)
|
||||
dataBitsComboBox.selectedItem = 8
|
||||
|
||||
parityComboBox.addItem(SerialCommParity.None)
|
||||
parityComboBox.addItem(SerialCommParity.Even)
|
||||
parityComboBox.addItem(SerialCommParity.Odd)
|
||||
parityComboBox.addItem(SerialCommParity.Mark)
|
||||
parityComboBox.addItem(SerialCommParity.Space)
|
||||
|
||||
stopBitsComboBox.addItem("1")
|
||||
stopBitsComboBox.addItem("1.5")
|
||||
stopBitsComboBox.addItem("2")
|
||||
stopBitsComboBox.selectedItem = "1"
|
||||
|
||||
flowControlComboBox.addItem(SerialCommFlowControl.None)
|
||||
flowControlComboBox.addItem(SerialCommFlowControl.RTS_CTS)
|
||||
flowControlComboBox.addItem(SerialCommFlowControl.XON_XOFF)
|
||||
|
||||
flowControlComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
val text = value?.toString() ?: StringUtils.EMPTY
|
||||
return super.getListCellRendererComponent(
|
||||
list,
|
||||
text.replace('_', '/'),
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
add(getCenterComponent(), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentShown(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
for (commPort in SerialPort.getCommPorts()) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
serialPortComboBox.addItem(commPort.systemPortName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
return Icons.plugin
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
return I18n.getString("termora.new-host.serial")
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, $formMargin",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val panel = FormBuilder.create().layout(layout)
|
||||
.add("${I18n.getString("termora.new-host.serial.port")}:").xy(1, rows)
|
||||
.add(serialPortComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.baud-rate")}:").xy(1, rows)
|
||||
.add(baudRateComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.data-bits")}:").xy(1, rows)
|
||||
.add(dataBitsComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.parity")}:").xy(1, rows)
|
||||
.add(parityComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.stop-bits")}:").xy(1, rows)
|
||||
.add(stopBitsComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.flow-control")}:").xy(1, rows)
|
||||
.add(flowControlComboBox).xy(3, rows).apply { rows += step }
|
||||
.build()
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class JumpHostsOption : JPanel(BorderLayout()), Option {
|
||||
val jumpHosts = mutableListOf<Host>()
|
||||
@@ -1006,7 +1157,8 @@ open class HostOptionsPane : OptionsPane() {
|
||||
val rows = table.selectedRows.sortedDescending()
|
||||
if (rows.isEmpty()) return
|
||||
for (row in rows) {
|
||||
model.removeRow(row)
|
||||
jumpHosts.removeAt(row)
|
||||
model.fireTableRowsDeleted(row, row)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,6 +69,8 @@ class HostTree : JTree(), Disposable {
|
||||
icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
|
||||
} else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) {
|
||||
icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal
|
||||
} else if (host.protocol == Protocol.Serial) {
|
||||
icon = if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -318,6 +320,7 @@ class HostTree : JTree(), Disposable {
|
||||
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host"))
|
||||
|
||||
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open"))
|
||||
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"))
|
||||
@@ -330,15 +333,8 @@ class HostTree : JTree(), Disposable {
|
||||
popupMenu.addSeparator()
|
||||
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
|
||||
|
||||
open.addActionListener { evt ->
|
||||
getSelectionNodes()
|
||||
.filter { it.protocol != Protocol.Folder }
|
||||
.forEach {
|
||||
ActionManager.getInstance()
|
||||
.getAction(OpenHostAction.OPEN_HOST)
|
||||
?.actionPerformed(OpenHostActionEvent(evt.source, it, evt))
|
||||
}
|
||||
}
|
||||
open.addActionListener { openHosts(it, false) }
|
||||
openInNewWindow.addActionListener { openHosts(it, true) }
|
||||
|
||||
rename.addActionListener {
|
||||
startEditingAtPath(TreePath(model.getPathToRoot(lastHost)))
|
||||
@@ -454,6 +450,17 @@ class HostTree : JTree(), Disposable {
|
||||
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)) }
|
||||
}
|
||||
|
||||
fun expandNode(node: Host, including: Boolean = false) {
|
||||
expandPath(TreePath(model.getPathToRoot(node)))
|
||||
|
||||
@@ -3,6 +3,7 @@ package app.termora
|
||||
object Icons {
|
||||
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
|
||||
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
|
||||
val plugin by lazy { DynamicIcon("icons/plugin.svg", "icons/plugin_dark.svg") }
|
||||
val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") }
|
||||
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
|
||||
val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }
|
||||
@@ -67,6 +68,7 @@ object Icons {
|
||||
val network by lazy { DynamicIcon("icons/network.svg", "icons/network_dark.svg") }
|
||||
val server by lazy { DynamicIcon("icons/server.svg", "icons/server_dark.svg") }
|
||||
val runAnything by lazy { DynamicIcon("icons/runAnything.svg", "icons/runAnything_dark.svg") }
|
||||
val run by lazy { DynamicIcon("icons/run.svg", "icons/run_dark.svg") }
|
||||
val uiForm by lazy { DynamicIcon("icons/uiForm.svg", "icons/uiForm_dark.svg") }
|
||||
val cloud by lazy { DynamicIcon("icons/cloud.svg", "icons/cloud_dark.svg") }
|
||||
val externalLink by lazy { DynamicIcon("icons/externalLink.svg", "icons/externalLink_dark.svg") }
|
||||
|
||||
@@ -4,7 +4,8 @@ import app.termora.terminal.PtyConnector
|
||||
import org.apache.commons.io.Charsets
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class LocalTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
|
||||
class LocalTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
PtyHostTerminalTab(windowScope, host) {
|
||||
|
||||
override suspend fun openPtyConnector(): PtyConnector {
|
||||
val winSize = terminalPanel.winSize()
|
||||
|
||||
@@ -41,4 +41,9 @@ private fun setupNativeLibraries() {
|
||||
if (pty4j.exists()) {
|
||||
System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath)
|
||||
}
|
||||
|
||||
val jSerialComm = FileUtils.getFile(dylib, "jSerialComm")
|
||||
if (jSerialComm.exists()) {
|
||||
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,264 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.*
|
||||
import java.awt.image.BufferedImage
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
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)
|
||||
|
||||
init {
|
||||
initEvents()
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
styleMap = mapOf(
|
||||
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
|
||||
"hoverColor" to UIManager.getColor("TabbedPane.background"),
|
||||
)
|
||||
super.updateUI()
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addMouseListener(dragMouseAdaptor)
|
||||
addMouseMotionListener(dragMouseAdaptor)
|
||||
}
|
||||
|
||||
override fun processMouseEvent(e: MouseEvent) {
|
||||
// Shift + Click ===> close tab
|
||||
if (e.id == MouseEvent.MOUSE_CLICKED && SwingUtilities.isLeftMouseButton(e) && isShiftPressedOnly(e.modifiersEx)) {
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
tabCloseCallback?.accept(this, index)
|
||||
return
|
||||
}
|
||||
} else if (e.id == MouseEvent.MOUSE_PRESSED && isShiftPressedOnly(e.modifiersEx)) {
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
super.processMouseEvent(e)
|
||||
}
|
||||
|
||||
private fun isShiftPressedOnly(modifiersEx: Int): Boolean {
|
||||
return (modifiersEx and InputEvent.ALT_DOWN_MASK) == 0
|
||||
&& (modifiersEx and InputEvent.ALT_GRAPH_DOWN_MASK) == 0
|
||||
&& (modifiersEx and InputEvent.CTRL_DOWN_MASK) == 0
|
||||
&& (modifiersEx and InputEvent.SHIFT_DOWN_MASK) != 0
|
||||
}
|
||||
|
||||
override fun setSelectedIndex(index: Int) {
|
||||
val oldIndex = selectedIndex
|
||||
super.setSelectedIndex(index)
|
||||
firePropertyChange("selectedIndex", oldIndex, index)
|
||||
}
|
||||
|
||||
|
||||
private inner class DragMouseAdaptor : MouseAdapter(), KeyEventDispatcher {
|
||||
private var mousePressedPoint = Point()
|
||||
private var tabIndex = 0 - 1
|
||||
private var cancelled = false
|
||||
private var window: Window? = null
|
||||
private var terminalTab: TerminalTab? = null
|
||||
private var isDragging = false
|
||||
private var lastVisitTabIndex = -1
|
||||
private var releasedPoint = Point()
|
||||
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index < 0 || !isTabClosable(index)) {
|
||||
return
|
||||
}
|
||||
tabIndex = index
|
||||
mousePressedPoint = e.point
|
||||
}
|
||||
|
||||
override fun mouseDragged(e: MouseEvent) {
|
||||
// 如果正在拖拽中,那么修改 Window 的位置
|
||||
if (isDragging) {
|
||||
window?.location = e.locationOnScreen
|
||||
lastVisitTabIndex = indexAtLocation(e.x, e.y)
|
||||
} else if (tabIndex >= 0) { // 这里之所以判断是确保在 mousePressed 时已经确定了 Tab
|
||||
// 有的时候会太灵敏,这里容错一下
|
||||
val diff = 5
|
||||
if (abs(mousePressedPoint.y - e.y) >= diff || abs(mousePressedPoint.x - e.x) >= diff) {
|
||||
startDrag(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDrag(e: MouseEvent) {
|
||||
if (isDragging) return
|
||||
val terminalTabbedManager = terminalTabbedManager ?: return
|
||||
val window = JDialog(owner).also { this.window = it }
|
||||
window.isUndecorated = true
|
||||
val image = createTabImage(tabIndex)
|
||||
window.size = Dimension(image.width, image.height)
|
||||
window.add(JLabel(ImageIcon(image)))
|
||||
window.location = e.locationOnScreen
|
||||
window.addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||
.removeKeyEventDispatcher(this@DragMouseAdaptor)
|
||||
}
|
||||
|
||||
override fun windowOpened(e: WindowEvent) {
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||
.addKeyEventDispatcher(this@DragMouseAdaptor)
|
||||
}
|
||||
})
|
||||
|
||||
// 暂时关闭 Tab
|
||||
terminalTabbedManager.closeTerminalTab(terminalTabbedManager.getTerminalTabs()[tabIndex].also {
|
||||
terminalTab = it
|
||||
}, false)
|
||||
|
||||
window.isVisible = true
|
||||
|
||||
isDragging = true
|
||||
cancelled = false
|
||||
}
|
||||
|
||||
private fun stopDrag() {
|
||||
if (!isDragging) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是取消,那么不需要移动到其它窗口
|
||||
val c = if (cancelled) owner else getTopMostWindowUnderMouse()
|
||||
|
||||
// 如果等于 null 表示在空地方释放,那么单独一个窗口
|
||||
if (c == null) {
|
||||
val window = TermoraFrameManager.getInstance().createWindow()
|
||||
dragToAnotherWindow(window)
|
||||
window.location = releasedPoint
|
||||
window.isVisible = true
|
||||
} else if (c != owner && c is TermoraFrame) { // 如果在某个窗口内释放,那么就移动到某个窗口内
|
||||
dragToAnotherWindow(c)
|
||||
} else {
|
||||
val tab = this.terminalTab
|
||||
val terminalTabbedManager = terminalTabbedManager
|
||||
if (tab != null && terminalTabbedManager != null) {
|
||||
moveTab(
|
||||
terminalTabbedManager,
|
||||
tab,
|
||||
lastVisitTabIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// reset
|
||||
window?.dispose()
|
||||
isDragging = false
|
||||
tabIndex = -1
|
||||
cancelled = false
|
||||
lastVisitTabIndex = -1
|
||||
}
|
||||
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
releasedPoint = e.point
|
||||
stopDrag()
|
||||
}
|
||||
|
||||
private fun createTabImage(index: Int): BufferedImage {
|
||||
val tabBounds = getBoundsAt(index)
|
||||
val image = BufferedImage(tabBounds.width, tabBounds.height, BufferedImage.TYPE_INT_ARGB)
|
||||
val g2 = image.createGraphics()
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
|
||||
g2.translate(-tabBounds.x, -tabBounds.y)
|
||||
paint(g2)
|
||||
g2.dispose()
|
||||
return image
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
|
||||
if (e.keyCode == KeyEvent.VK_ESCAPE) {
|
||||
cancelled = true
|
||||
stopDrag()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getTopMostWindowUnderMouse(): Window? {
|
||||
val mouseLocation = MouseInfo.getPointerInfo().location
|
||||
val owner = owner
|
||||
if (owner.isVisible && owner.bounds.contains(mouseLocation)) {
|
||||
return owner
|
||||
}
|
||||
|
||||
val windows = Window.getWindows()
|
||||
// 倒序遍历,最上层的窗口优先匹配
|
||||
for (i in windows.indices.reversed()) {
|
||||
val window = windows[i]
|
||||
if (window !is TermoraFrame) {
|
||||
continue
|
||||
}
|
||||
if (window.isVisible && window.bounds.contains(mouseLocation)) {
|
||||
val topComponent = SwingUtilities.getDeepestComponentAt(
|
||||
window,
|
||||
mouseLocation.x - window.x, mouseLocation.y - window.y
|
||||
)
|
||||
if (topComponent != null) {
|
||||
return SwingUtilities.getWindowAncestor(topComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
private fun dragToAnotherWindow(frame: TermoraFrame) {
|
||||
val tab = this.terminalTab ?: return
|
||||
val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return
|
||||
val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return
|
||||
val location = Point(MouseInfo.getPointerInfo().location)
|
||||
SwingUtilities.convertPointFromScreen(location, tabbedPane)
|
||||
val index = tabbedPane.indexAtLocation(location.x, location.y)
|
||||
|
||||
moveTab(
|
||||
tabbedManager,
|
||||
tab,
|
||||
index
|
||||
)
|
||||
|
||||
if (frame.hasFocus()) {
|
||||
return
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
frame.requestFocus()
|
||||
tabbedPane.selectedComponent?.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveTab(terminalTabbedManager: TerminalTabbedManager, tab: TerminalTab, lastVisitTabIndex: Int) {
|
||||
// 如果是手动取消
|
||||
if (cancelled) {
|
||||
terminalTabbedManager.addTerminalTab(tabIndex, tab)
|
||||
} else if (lastVisitTabIndex > 0) {
|
||||
terminalTabbedManager.addTerminalTab(lastVisitTabIndex, tab)
|
||||
} else if (lastVisitTabIndex == 0) {
|
||||
terminalTabbedManager.addTerminalTab(1, tab)
|
||||
} else {
|
||||
terminalTabbedManager.addTerminalTab(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -38,6 +38,8 @@ class PtyConnectorFactory : Disposable {
|
||||
val locale = Locale.getDefault()
|
||||
if (StringUtils.isNoneBlank(locale.language, locale.country)) {
|
||||
envs["LANG"] = "${locale.language}_${locale.country}.${Charset.defaultCharset().name()}"
|
||||
} else {
|
||||
envs["LANG"] = "en_US.UTF-8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@ abstract class PtyHostTerminalTab(
|
||||
private var readerJob: Job? = null
|
||||
private val ptyConnectorDelegate = PtyConnectorDelegate()
|
||||
|
||||
protected val terminalPanel =
|
||||
TerminalPanelFactory.getInstance(windowScope).createTerminalPanel(terminal, ptyConnectorDelegate)
|
||||
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
|
||||
protected val terminalPanel = terminalPanelFactory.createTerminalPanel(terminal, ptyConnectorDelegate)
|
||||
.apply { Disposer.register(this@PtyHostTerminalTab, this) }
|
||||
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance(windowScope)
|
||||
|
||||
init {
|
||||
@@ -53,8 +54,12 @@ abstract class PtyHostTerminalTab(
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
delay(250.milliseconds)
|
||||
withContext(Dispatchers.Swing) {
|
||||
ptyConnector.write(host.options.startupCommand)
|
||||
ptyConnector.write(terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER)))
|
||||
val charset = ptyConnector.getCharset()
|
||||
ptyConnector.write(host.options.startupCommand.toByteArray(charset))
|
||||
ptyConnector.write(
|
||||
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
|
||||
.toByteArray(charset)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +121,7 @@ abstract class PtyHostTerminalTab(
|
||||
|
||||
override fun dispose() {
|
||||
stop()
|
||||
terminalPanel
|
||||
super.dispose()
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.actions.TabReconnectAction
|
||||
import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
@@ -27,11 +29,13 @@ import org.apache.sshd.common.session.SessionListener.Event
|
||||
import org.apache.sshd.common.util.net.SshdSocketAddress
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.EventObject
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
|
||||
class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
|
||||
class SSHTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
PtyHostTerminalTab(windowScope, host) {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
|
||||
}
|
||||
@@ -41,6 +45,9 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
|
||||
private var sshClient: SshClient? = null
|
||||
private var sshSession: ClientSession? = null
|
||||
private var sshChannelShell: ChannelShell? = null
|
||||
private val terminalTabbedManager
|
||||
get() = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
|
||||
.getData(DataProviders.TerminalTabbedManager)
|
||||
|
||||
init {
|
||||
terminalPanel.dropFiles = false
|
||||
@@ -119,13 +126,25 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
|
||||
override fun channelClosed(channel: Channel, reason: Throwable?) {
|
||||
coroutineScope.launch(Dispatchers.Swing) {
|
||||
terminal.write("\r\n\r\n${ControlCharacters.ESC}[31m")
|
||||
terminal.write("Channel has been disconnected.")
|
||||
terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
|
||||
if (reconnectShortcut is KeyShortcut) {
|
||||
terminal.write(" Type $reconnectShortcut to reconnect.")
|
||||
terminal.write(
|
||||
I18n.getString(
|
||||
"termora.terminal.channel-reconnect",
|
||||
reconnectShortcut.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
terminal.write("\r\n")
|
||||
terminal.write("${ControlCharacters.ESC}[0m")
|
||||
terminalModel.setData(DataKey.ShowCursor, false)
|
||||
if (Database.getDatabase().terminal.autoCloseTabWhenDisconnected) {
|
||||
terminalTabbedManager?.let { manager ->
|
||||
SwingUtilities.invokeLater {
|
||||
manager.closeTerminalTab(this@SSHTerminalTab, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
61
src/main/kotlin/app/termora/SerialPortPtyConnector.kt
Normal file
61
src/main/kotlin/app/termora/SerialPortPtyConnector.kt
Normal file
@@ -0,0 +1,61 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.terminal.PtyConnector
|
||||
import com.fazecast.jSerialComm.SerialPort
|
||||
import com.fazecast.jSerialComm.SerialPortDataListener
|
||||
import com.fazecast.jSerialComm.SerialPortEvent
|
||||
import java.nio.charset.Charset
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SerialPortPtyConnector(
|
||||
private val serialPort: SerialPort,
|
||||
private val charset: Charset = Charsets.UTF_8
|
||||
) : PtyConnector, SerialPortDataListener {
|
||||
|
||||
private val queue = LinkedBlockingQueue<Char>()
|
||||
|
||||
init {
|
||||
serialPort.addDataListener(this)
|
||||
}
|
||||
|
||||
override fun read(buffer: CharArray): Int {
|
||||
buffer[0] = queue.poll(1, TimeUnit.SECONDS) ?: return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
override fun write(buffer: ByteArray, offset: Int, len: Int) {
|
||||
serialPort.writeBytes(buffer, len, offset)
|
||||
}
|
||||
|
||||
override fun resize(rows: Int, cols: Int) {
|
||||
|
||||
}
|
||||
|
||||
override fun waitFor(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
queue.clear()
|
||||
serialPort.closePort()
|
||||
}
|
||||
|
||||
override fun getListeningEvents(): Int {
|
||||
return SerialPort.LISTENING_EVENT_DATA_RECEIVED
|
||||
}
|
||||
|
||||
override fun serialEvent(event: SerialPortEvent) {
|
||||
if (event.eventType == SerialPort.LISTENING_EVENT_DATA_RECEIVED) {
|
||||
val data = event.receivedData
|
||||
if (data.isEmpty()) return
|
||||
for (c in String(data, charset).toCharArray()) {
|
||||
queue.add(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return charset
|
||||
}
|
||||
}
|
||||
21
src/main/kotlin/app/termora/SerialTerminalTab.kt
Normal file
21
src/main/kotlin/app/termora/SerialTerminalTab.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.terminal.PtyConnector
|
||||
import org.apache.commons.io.Charsets
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.swing.Icon
|
||||
|
||||
class SerialTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
PtyHostTerminalTab(windowScope, host) {
|
||||
override suspend fun openPtyConnector(): PtyConnector {
|
||||
val serialPort = Serials.openPort(host)
|
||||
return SerialPortPtyConnector(
|
||||
serialPort,
|
||||
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getIcon(): Icon {
|
||||
return Icons.plugin
|
||||
}
|
||||
}
|
||||
38
src/main/kotlin/app/termora/Serials.kt
Normal file
38
src/main/kotlin/app/termora/Serials.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
package app.termora
|
||||
|
||||
import com.fazecast.jSerialComm.SerialPort
|
||||
|
||||
object Serials {
|
||||
fun openPort(host: Host): SerialPort {
|
||||
val serialComm = host.options.serialComm
|
||||
val serialPort = SerialPort.getCommPort(serialComm.port)
|
||||
serialPort.setBaudRate(serialComm.baudRate)
|
||||
serialPort.setNumDataBits(serialComm.dataBits)
|
||||
|
||||
when (serialComm.parity) {
|
||||
SerialCommParity.None -> serialPort.setParity(SerialPort.NO_PARITY)
|
||||
SerialCommParity.Mark -> serialPort.setParity(SerialPort.MARK_PARITY)
|
||||
SerialCommParity.Even -> serialPort.setParity(SerialPort.EVEN_PARITY)
|
||||
SerialCommParity.Odd -> serialPort.setParity(SerialPort.ODD_PARITY)
|
||||
SerialCommParity.Space -> serialPort.setParity(SerialPort.SPACE_PARITY)
|
||||
}
|
||||
|
||||
when (serialComm.stopBits) {
|
||||
"1" -> serialPort.setNumStopBits(SerialPort.ONE_STOP_BIT)
|
||||
"1.5" -> serialPort.setNumStopBits(SerialPort.ONE_POINT_FIVE_STOP_BITS)
|
||||
"2" -> serialPort.setNumStopBits(SerialPort.TWO_STOP_BITS)
|
||||
}
|
||||
|
||||
when (serialComm.flowControl) {
|
||||
SerialCommFlowControl.None -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED)
|
||||
SerialCommFlowControl.RTS_CTS -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_RTS_ENABLED or SerialPort.FLOW_CONTROL_CTS_ENABLED)
|
||||
SerialCommFlowControl.XON_XOFF -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_XONXOFF_IN_ENABLED or SerialPort.FLOW_CONTROL_XONXOFF_OUT_ENABLED)
|
||||
}
|
||||
|
||||
if (!serialPort.openPort()) {
|
||||
throw IllegalStateException("Open serial port [${serialComm.port}] failed")
|
||||
}
|
||||
|
||||
return serialPort
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,14 @@ import app.termora.AES.encodeBase64String
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.highlight.KeywordHighlight
|
||||
import app.termora.highlight.KeywordHighlightManager
|
||||
import app.termora.keymap.Keymap
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.keymap.KeymapPanel
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import app.termora.macro.Macro
|
||||
import app.termora.macro.MacroManager
|
||||
import app.termora.native.FileChooser
|
||||
import app.termora.sync.SyncConfig
|
||||
@@ -17,8 +22,10 @@ import app.termora.terminal.CursorStyle
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import cash.z.ecc.android.bip39.Mnemonics
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||
import com.formdev.flatlaf.extras.components.*
|
||||
import com.formdev.flatlaf.util.FontUtils
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
@@ -27,17 +34,17 @@ import com.sun.jna.LastErrorException
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.*
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
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.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.event.ItemEvent
|
||||
import java.io.File
|
||||
@@ -54,6 +61,11 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
class SettingsOptionsPane : OptionsPane() {
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this@SettingsOptionsPane)
|
||||
private val database get() = Database.getDatabase()
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
private val keymapManager get() = KeymapManager.getInstance()
|
||||
private val macroManager get() = MacroManager.getInstance()
|
||||
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
|
||||
private val keyManager get() = KeyManager.getInstance()
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SettingsOptionsPane::class.java)
|
||||
@@ -288,12 +300,14 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private inner class TerminalOption : JPanel(BorderLayout()), Option {
|
||||
private val cursorStyleComboBox = FlatComboBox<CursorStyle>()
|
||||
private val debugComboBox = YesOrNoComboBox()
|
||||
private val beepComboBox = YesOrNoComboBox()
|
||||
private val fontComboBox = FlatComboBox<String>()
|
||||
private val shellComboBox = FlatComboBox<String>()
|
||||
private val maxRowsTextField = IntSpinner(0, 0)
|
||||
private val fontSizeTextField = IntSpinner(0, 9, 99)
|
||||
private val terminalSetting get() = Database.getDatabase().terminal
|
||||
private val selectCopyComboBox = YesOrNoComboBox()
|
||||
private val autoCloseTabComboBox = YesOrNoComboBox()
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -309,6 +323,13 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
autoCloseTabComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.autoCloseTabWhenDisconnected = autoCloseTabComboBox.selectedItem as Boolean
|
||||
}
|
||||
}
|
||||
autoCloseTabComboBox.toolTipText = I18n.getString("termora.settings.terminal.auto-close-tab-description")
|
||||
|
||||
selectCopyComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean
|
||||
@@ -345,6 +366,13 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
|
||||
|
||||
beepComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.beep = beepComboBox.selectedItem as Boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
shellComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.localShell = shellComboBox.selectedItem as String
|
||||
@@ -379,6 +407,28 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
fontComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
if (value is String) {
|
||||
return super.getListCellRendererComponent(
|
||||
list,
|
||||
"<html><font face='$value'>$value</font></html>",
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
}
|
||||
return super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
|
||||
}
|
||||
}
|
||||
fontComboBox.maximumSize = fontComboBox.preferredSize
|
||||
|
||||
cursorStyleComboBox.addItem(CursorStyle.Block)
|
||||
cursorStyleComboBox.addItem(CursorStyle.Bar)
|
||||
cursorStyleComboBox.addItem(CursorStyle.Underline)
|
||||
@@ -391,13 +441,40 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
shellComboBox.selectedItem = terminalSetting.localShell
|
||||
|
||||
fontComboBox.addItem("JetBrains Mono")
|
||||
fontComboBox.addItem("Source Code Pro")
|
||||
val fonts = linkedSetOf(
|
||||
"JetBrains Mono",
|
||||
"Source Code Pro",
|
||||
"Monospaced",
|
||||
"Andale Mono",
|
||||
"Ayuthaya",
|
||||
"Courier New",
|
||||
"Droid Sans Mono",
|
||||
"Fira Code",
|
||||
"PCMyungjo",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Osaka",
|
||||
"PT Mono",
|
||||
"SimSong",
|
||||
)
|
||||
|
||||
for (font in FontUtils.getAllFonts()) {
|
||||
if (fonts.contains(font.family)) {
|
||||
continue
|
||||
}
|
||||
fonts.remove(font.family)
|
||||
}
|
||||
|
||||
for (font in fonts) {
|
||||
fontComboBox.addItem(font)
|
||||
}
|
||||
|
||||
fontComboBox.selectedItem = terminalSetting.font
|
||||
debugComboBox.selectedItem = terminalSetting.debug
|
||||
beepComboBox.selectedItem = terminalSetting.beep
|
||||
cursorStyleComboBox.selectedItem = terminalSetting.cursor
|
||||
selectCopyComboBox.selectedItem = terminalSetting.selectCopy
|
||||
autoCloseTabComboBox.selectedItem = terminalSetting.autoCloseTabWhenDisconnected
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
@@ -415,9 +492,14 @@ 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"
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val beepBtn = JButton(Icons.run)
|
||||
beepBtn.isFocusable = false
|
||||
beepBtn.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON)
|
||||
beepBtn.addActionListener { Toolkit.getDefaultToolkit().beep() }
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val panel = FormBuilder.create().layout(layout)
|
||||
@@ -430,10 +512,15 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
.add(maxRowsTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows)
|
||||
.add(debugComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.beep")}:").xy(1, rows)
|
||||
.add(beepComboBox).xy(3, rows)
|
||||
.add(beepBtn).xy(5, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.select-copy")}:").xy(1, rows)
|
||||
.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.auto-close-tab")}:").xy(1, rows)
|
||||
.add(autoCloseTabComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.local-shell")}:").xy(1, rows)
|
||||
.add(shellComboBox).xyw(3, rows, 5)
|
||||
.build()
|
||||
@@ -451,6 +538,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
val domainTextField = OutlineTextField(255)
|
||||
val uploadConfigButton = JButton(I18n.getString("termora.settings.sync.push"), Icons.upload)
|
||||
val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export)
|
||||
val importConfigButton = JButton(I18n.getString("termora.settings.sync.import"), Icons.import)
|
||||
val downloadConfigButton = JButton(I18n.getString("termora.settings.sync.pull"), Icons.download)
|
||||
val lastSyncTimeLabel = JLabel()
|
||||
val sync get() = database.sync
|
||||
@@ -492,12 +580,6 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeComboBox.selectedItem == SyncType.Gitee) {
|
||||
gistTextField.trailingComponent = null
|
||||
} else {
|
||||
gistTextField.trailingComponent = visitGistBtn
|
||||
}
|
||||
|
||||
removeAll()
|
||||
add(getCenterComponent(), BorderLayout.CENTER)
|
||||
revalidate()
|
||||
@@ -562,6 +644,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
|
||||
exportConfigButton.addActionListener { export() }
|
||||
importConfigButton.addActionListener { import() }
|
||||
|
||||
keysCheckBox.addActionListener { refreshButtons() }
|
||||
hostsCheckBox.addActionListener { refreshButtons() }
|
||||
@@ -578,6 +661,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|| keywordHighlightsCheckBox.isSelected
|
||||
uploadConfigButton.isEnabled = downloadConfigButton.isEnabled
|
||||
exportConfigButton.isEnabled = downloadConfigButton.isEnabled
|
||||
importConfigButton.isEnabled = downloadConfigButton.isEnabled
|
||||
}
|
||||
|
||||
private fun export() {
|
||||
@@ -593,6 +677,109 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun import() {
|
||||
val fileChooser = FileChooser()
|
||||
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
|
||||
fileChooser.osxAllowedFileTypes = listOf("json")
|
||||
fileChooser.win32Filters.add(Pair("JSON files", listOf("json")))
|
||||
fileChooser.showOpenDialog(owner).thenAccept { files ->
|
||||
if (files.isNotEmpty()) {
|
||||
SwingUtilities.invokeLater { importFromFile(files.first()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun importFromFile(file: File) {
|
||||
if (!file.exists()) {
|
||||
return
|
||||
}
|
||||
|
||||
val ranges = getSyncConfig().ranges
|
||||
if (ranges.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 最大 100MB
|
||||
if (file.length() >= 1024 * 1024 * 100) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, I18n.getString("termora.settings.sync.import.file-too-large"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val text = file.readText()
|
||||
val jsonResult = ohMyJson.runCatching { decodeFromString<JsonObject>(text) }
|
||||
if (jsonResult.isFailure) {
|
||||
val e = jsonResult.exceptionOrNull() ?: return
|
||||
OptionPane.showMessageDialog(
|
||||
owner, ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val json = jsonResult.getOrNull() ?: return
|
||||
if (ranges.contains(SyncRange.Hosts)) {
|
||||
val hosts = json["hosts"]
|
||||
if (hosts is JsonArray) {
|
||||
ohMyJson.runCatching { decodeFromJsonElement<List<Host>>(hosts.jsonArray) }.onSuccess {
|
||||
for (host in it) {
|
||||
hostManager.addHost(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.contains(SyncRange.KeyPairs)) {
|
||||
val keyPairs = json["keyPairs"]
|
||||
if (keyPairs is JsonArray) {
|
||||
ohMyJson.runCatching { decodeFromJsonElement<List<OhKeyPair>>(keyPairs.jsonArray) }.onSuccess {
|
||||
for (keyPair in it) {
|
||||
keyManager.addOhKeyPair(keyPair)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.contains(SyncRange.KeywordHighlights)) {
|
||||
val keywordHighlights = json["keywordHighlights"]
|
||||
if (keywordHighlights is JsonArray) {
|
||||
ohMyJson.runCatching { decodeFromJsonElement<List<KeywordHighlight>>(keywordHighlights.jsonArray) }
|
||||
.onSuccess {
|
||||
for (keyPair in it) {
|
||||
keywordHighlightManager.addKeywordHighlight(keyPair)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.contains(SyncRange.Macros)) {
|
||||
val macros = json["macros"]
|
||||
if (macros is JsonArray) {
|
||||
ohMyJson.runCatching { decodeFromJsonElement<List<Macro>>(macros.jsonArray) }.onSuccess {
|
||||
for (macro in it) {
|
||||
macroManager.addMacro(macro)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.contains(SyncRange.Keymap)) {
|
||||
val keymaps = json["keymaps"]
|
||||
if (keymaps is JsonArray) {
|
||||
for (keymap in keymaps.jsonArray.mapNotNull { Keymap.fromJSON(it.jsonObject) }) {
|
||||
keymapManager.addKeymap(keymap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionPane.showMessageDialog(
|
||||
owner, I18n.getString("termora.settings.sync.import.successful"),
|
||||
messageType = JOptionPane.INFORMATION_MESSAGE
|
||||
)
|
||||
}
|
||||
|
||||
private fun exportText(file: File) {
|
||||
val syncConfig = getSyncConfig()
|
||||
val text = ohMyJson.encodeToString(buildJsonObject {
|
||||
@@ -603,21 +790,29 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
put("os", SystemUtils.OS_NAME)
|
||||
put("exportDateHuman", DateFormatUtils.ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.format(Date(now)))
|
||||
if (syncConfig.ranges.contains(SyncRange.Hosts)) {
|
||||
put("hosts", ohMyJson.encodeToJsonElement(HostManager.getInstance().hosts()))
|
||||
put("hosts", ohMyJson.encodeToJsonElement(hostManager.hosts()))
|
||||
}
|
||||
if (syncConfig.ranges.contains(SyncRange.KeyPairs)) {
|
||||
put("keyPairs", ohMyJson.encodeToJsonElement(KeyManager.getInstance().getOhKeyPairs()))
|
||||
put("keyPairs", ohMyJson.encodeToJsonElement(keyManager.getOhKeyPairs()))
|
||||
}
|
||||
if (syncConfig.ranges.contains(SyncRange.KeywordHighlights)) {
|
||||
put(
|
||||
"keywordHighlights",
|
||||
ohMyJson.encodeToJsonElement(KeywordHighlightManager.getInstance().getKeywordHighlights())
|
||||
ohMyJson.encodeToJsonElement(keywordHighlightManager.getKeywordHighlights())
|
||||
)
|
||||
}
|
||||
if (syncConfig.ranges.contains(SyncRange.Macros)) {
|
||||
put(
|
||||
"macros",
|
||||
ohMyJson.encodeToJsonElement(MacroManager.getInstance().getMacros())
|
||||
ohMyJson.encodeToJsonElement(macroManager.getMacros())
|
||||
)
|
||||
}
|
||||
if (syncConfig.ranges.contains(SyncRange.Keymap)) {
|
||||
val keymaps = keymapManager.getKeymaps().filter { !it.isReadonly }
|
||||
.map { it.toJSONObject() }
|
||||
put(
|
||||
"keymaps",
|
||||
ohMyJson.encodeToJsonElement(keymaps)
|
||||
)
|
||||
}
|
||||
put("settings", buildJsonObject {
|
||||
@@ -662,6 +857,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
private suspend fun pushOrPull(push: Boolean) {
|
||||
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab) {
|
||||
@@ -717,6 +913,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
exportConfigButton.isEnabled = false
|
||||
importConfigButton.isEnabled = false
|
||||
downloadConfigButton.isEnabled = false
|
||||
uploadConfigButton.isEnabled = false
|
||||
typeComboBox.isEnabled = false
|
||||
@@ -752,6 +949,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
withContext(Dispatchers.Swing) {
|
||||
downloadConfigButton.isEnabled = true
|
||||
exportConfigButton.isEnabled = true
|
||||
importConfigButton.isEnabled = true
|
||||
uploadConfigButton.isEnabled = true
|
||||
keysCheckBox.isEnabled = true
|
||||
hostsCheckBox.isEnabled = true
|
||||
@@ -813,6 +1011,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
typeComboBox.addItem(SyncType.GitHub)
|
||||
typeComboBox.addItem(SyncType.GitLab)
|
||||
typeComboBox.addItem(SyncType.Gitee)
|
||||
typeComboBox.addItem(SyncType.WebDAV)
|
||||
|
||||
hostsCheckBox.isFocusable = false
|
||||
keysCheckBox.isFocusable = false
|
||||
@@ -831,7 +1030,31 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
tokenTextField.text = sync.token
|
||||
domainTextField.trailingComponent = JButton(Icons.externalLink).apply {
|
||||
addActionListener {
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab) {
|
||||
Application.browse(URI.create("https://docs.gitlab.com/ee/api/snippets.html"))
|
||||
|
||||
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
|
||||
val url = domainTextField.text
|
||||
if (url.isNullOrBlank()) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.sync.webdav.help")
|
||||
)
|
||||
} else {
|
||||
val uri = URI.create(url)
|
||||
val sb = StringBuilder()
|
||||
sb.append(uri.scheme).append("://")
|
||||
if (tokenTextField.password.isNotEmpty() && gistTextField.text.isNotBlank()) {
|
||||
sb.append(String(tokenTextField.password)).append(":").append(gistTextField.text)
|
||||
sb.append('@')
|
||||
}
|
||||
sb.append(uri.authority).append(uri.path)
|
||||
if (!uri.query.isNullOrBlank()) {
|
||||
sb.append('?').append(uri.query)
|
||||
}
|
||||
Application.browse(URI.create(sb.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,12 +1064,15 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null
|
||||
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab) {
|
||||
if (domainTextField.text.isBlank()) {
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab) {
|
||||
domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api")
|
||||
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
|
||||
domainTextField.text = sync.domain
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val lastSyncTime = sync.lastSyncTime
|
||||
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${
|
||||
if (lastSyncTime > 0) DateFormatUtils.format(
|
||||
@@ -892,29 +1118,50 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val builder = FormBuilder.create().layout(layout).debug(false);
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
val box = Box.createHorizontalBox()
|
||||
box.add(typeComboBox)
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab) {
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab || typeComboBox.selectedItem == SyncType.WebDAV) {
|
||||
box.add(Box.createHorizontalStrut(4))
|
||||
box.add(domainTextField)
|
||||
}
|
||||
builder.add("${I18n.getString("termora.settings.sync.type")}:").xy(1, rows)
|
||||
.add(box).xy(3, rows).apply { rows += step }
|
||||
|
||||
builder.add("${I18n.getString("termora.settings.sync.token")}:").xy(1, rows)
|
||||
.add(tokenTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.sync.gist")}:").xy(1, rows)
|
||||
.add(gistTextField).xy(3, rows).apply { rows += step }
|
||||
val isWebDAV = typeComboBox.selectedItem == SyncType.WebDAV
|
||||
|
||||
val tokenText = if (isWebDAV) {
|
||||
I18n.getString("termora.new-host.general.username")
|
||||
} else {
|
||||
I18n.getString("termora.settings.sync.token")
|
||||
}
|
||||
|
||||
val gistText = if (isWebDAV) {
|
||||
I18n.getString("termora.new-host.general.password")
|
||||
} else {
|
||||
I18n.getString("termora.settings.sync.gist")
|
||||
}
|
||||
|
||||
if (typeComboBox.selectedItem == SyncType.Gitee || isWebDAV) {
|
||||
gistTextField.trailingComponent = null
|
||||
} else {
|
||||
gistTextField.trailingComponent = visitGistBtn
|
||||
}
|
||||
|
||||
builder.add("${tokenText}:").xy(1, rows)
|
||||
.add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${gistText}:").xy(1, rows)
|
||||
.add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows)
|
||||
.add(rangeBox).xy(3, rows).apply { rows += step }
|
||||
// Sync buttons
|
||||
.add(
|
||||
FormBuilder.create()
|
||||
.layout(FormLayout("left:pref, $formMargin, left:pref, $formMargin, left:pref", "pref"))
|
||||
.layout(FormLayout("pref, 2dlu, pref, 2dlu, pref, 2dlu, pref", "pref"))
|
||||
.add(uploadConfigButton).xy(1, 1)
|
||||
.add(downloadConfigButton).xy(3, 1)
|
||||
.add(exportConfigButton).xy(5, 1)
|
||||
.add(importConfigButton).xy(7, 1)
|
||||
.build()
|
||||
).xy(3, rows, "center, fill").apply { rows += step }
|
||||
.add(lastSyncTimeLabel).xy(3, rows, "center, fill").apply { rows += step }
|
||||
@@ -1009,8 +1256,6 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private val tip = FlatLabel()
|
||||
private val safeBtn = FlatButton()
|
||||
private val doorman get() = Doorman.getInstance()
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
private val keyManager get() = KeyManager.getInstance()
|
||||
|
||||
init {
|
||||
initView()
|
||||
|
||||
@@ -6,10 +6,13 @@ import org.apache.sshd.client.ClientBuilder
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.channel.ChannelShell
|
||||
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
|
||||
import org.apache.sshd.client.kex.DHGClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.common.SshException
|
||||
import org.apache.sshd.common.channel.PtyChannelConfiguration
|
||||
import org.apache.sshd.common.global.KeepAliveHandler
|
||||
import org.apache.sshd.common.kex.BuiltinDHFactories
|
||||
import org.apache.sshd.common.keyprovider.KeyIdentityProvider
|
||||
import org.apache.sshd.common.util.net.SshdSocketAddress
|
||||
import org.apache.sshd.core.CoreModuleProperties
|
||||
import org.apache.sshd.server.forward.AcceptAllForwardingFilter
|
||||
@@ -133,6 +136,18 @@ object SshClients {
|
||||
builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE))
|
||||
.factory { JGitSshClient() }
|
||||
|
||||
val keyExchangeFactories = ClientBuilder.setUpDefaultKeyExchanges(true).toMutableList()
|
||||
|
||||
// https://github.com/TermoraDev/termora/issues/123
|
||||
keyExchangeFactories.addAll(
|
||||
listOf(
|
||||
DHGClient.newFactory(BuiltinDHFactories.dhg1),
|
||||
DHGClient.newFactory(BuiltinDHFactories.dhg14),
|
||||
DHGClient.newFactory(BuiltinDHFactories.dhgex),
|
||||
)
|
||||
)
|
||||
builder.keyExchangeFactories(keyExchangeFactories)
|
||||
|
||||
if (host.tunnelings.isEmpty() && host.options.jumpHosts.isEmpty()) {
|
||||
builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE)
|
||||
} else {
|
||||
@@ -142,8 +157,15 @@ object SshClients {
|
||||
builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY)
|
||||
|
||||
val sshClient = builder.build() as JGitSshClient
|
||||
|
||||
// https://github.com/TermoraDev/termora/issues/180
|
||||
// JGit 会尝试读取本地的私钥或缓存的私钥
|
||||
sshClient.keyIdentityProvider = KeyIdentityProvider { mutableListOf() }
|
||||
|
||||
val heartbeatInterval = max(host.options.heartbeatInterval, 3)
|
||||
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong()))
|
||||
CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true)
|
||||
|
||||
sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) }
|
||||
|
||||
if (host.proxy.type != ProxyType.No) {
|
||||
|
||||
@@ -21,6 +21,11 @@ class TerminalFactory private constructor() : Disposable {
|
||||
|
||||
// terminal logger listener
|
||||
terminal.getTerminalModel().addDataListener(TerminalLoggerDataListener(terminal))
|
||||
terminal.addTerminalListener(object : TerminalListener {
|
||||
override fun onClose(terminal: Terminal) {
|
||||
terminals.remove(terminal)
|
||||
}
|
||||
})
|
||||
|
||||
terminals.add(terminal)
|
||||
return terminal
|
||||
@@ -51,6 +56,11 @@ class TerminalFactory private constructor() : Disposable {
|
||||
return colorPalette
|
||||
}
|
||||
|
||||
override fun bell() {
|
||||
if (config.beep) {
|
||||
super.bell()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(key: DataKey<T>): T {
|
||||
|
||||
@@ -23,6 +23,11 @@ class TerminalPanelFactory {
|
||||
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
|
||||
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
|
||||
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
|
||||
Disposer.register(terminalPanel, object : Disposable {
|
||||
override fun dispose() {
|
||||
terminalPanels.remove(terminalPanel)
|
||||
}
|
||||
})
|
||||
terminalPanels.add(terminalPanel)
|
||||
return terminalPanel
|
||||
}
|
||||
@@ -47,4 +52,8 @@ class TerminalPanelFactory {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTerminalPanel(terminalPanel: TerminalPanel) {
|
||||
terminalPanels.remove(terminalPanel)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,14 +10,19 @@ import app.termora.transport.TransportPanel
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.AWTEventListener
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import javax.swing.*
|
||||
import java.util.*
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.math.min
|
||||
|
||||
class TerminalTabbed(
|
||||
@@ -30,7 +35,7 @@ class TerminalTabbed(
|
||||
private val toolbar = termoraToolBar.getJToolBar()
|
||||
private val actionManager = ActionManager.getInstance()
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
|
||||
private val titleProperty = UUID.randomUUID().toSimpleString()
|
||||
private val iconListener = PropertyChangeListener { e ->
|
||||
val source = e.source
|
||||
if (e.propertyName == "icon" && source is TerminalTab) {
|
||||
@@ -52,9 +57,6 @@ class TerminalTabbed(
|
||||
tabbedPane.isTabsClosable = true
|
||||
tabbedPane.tabType = FlatTabbedPane.TabType.card
|
||||
|
||||
tabbedPane.styleMap = mapOf(
|
||||
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground")
|
||||
)
|
||||
tabbedPane.trailingComponent = toolbar
|
||||
|
||||
add(tabbedPane, BorderLayout.CENTER)
|
||||
@@ -190,16 +192,16 @@ class TerminalTabbed(
|
||||
// 修改名称
|
||||
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
|
||||
rename.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
if (index > 0) {
|
||||
if (tabIndex > 0) {
|
||||
val dialog = InputDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
title = rename.text,
|
||||
text = tabbedPane.getTitleAt(index),
|
||||
text = tabbedPane.getTitleAt(tabIndex),
|
||||
)
|
||||
val text = dialog.getText()
|
||||
if (!text.isNullOrBlank()) {
|
||||
tabbedPane.setTitleAt(index, text)
|
||||
tabbedPane.setTitleAt(tabIndex, text)
|
||||
c.putClientProperty(titleProperty, text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,9 +278,8 @@ class TerminalTabbed(
|
||||
popupMenu.addSeparator()
|
||||
val reconnect = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.reconnect"))
|
||||
reconnect.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
if (index > 0) {
|
||||
tabs[index].reconnect()
|
||||
if (tabIndex > 0) {
|
||||
tabs[tabIndex].reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,18 +290,24 @@ class TerminalTabbed(
|
||||
}
|
||||
|
||||
|
||||
fun addTab(tab: TerminalTab) {
|
||||
tabbedPane.addTab(
|
||||
tab.getTitle(),
|
||||
private fun addTab(index: Int, tab: TerminalTab) {
|
||||
val c = tab.getJComponent()
|
||||
val title = (c.getClientProperty(titleProperty) ?: tab.getTitle()).toString()
|
||||
|
||||
tabbedPane.insertTab(
|
||||
title,
|
||||
tab.getIcon(),
|
||||
tab.getJComponent()
|
||||
c,
|
||||
StringUtils.EMPTY,
|
||||
index
|
||||
)
|
||||
c.putClientProperty(titleProperty, title)
|
||||
|
||||
// 监听 icons 变化
|
||||
tab.addPropertyChangeListener(iconListener)
|
||||
|
||||
tabs.add(tab)
|
||||
tabbedPane.selectedIndex = tabbedPane.tabCount - 1
|
||||
tabs.add(index, tab)
|
||||
tabbedPane.selectedIndex = index
|
||||
Disposer.register(this, tab)
|
||||
}
|
||||
|
||||
@@ -393,7 +400,11 @@ class TerminalTabbed(
|
||||
}
|
||||
|
||||
override fun addTerminalTab(tab: TerminalTab) {
|
||||
addTab(tab)
|
||||
addTab(tabs.size, tab)
|
||||
}
|
||||
|
||||
override fun addTerminalTab(index: Int, tab: TerminalTab) {
|
||||
addTab(index, tab)
|
||||
}
|
||||
|
||||
override fun getSelectedTerminalTab(): TerminalTab? {
|
||||
@@ -418,10 +429,10 @@ class TerminalTabbed(
|
||||
}
|
||||
}
|
||||
|
||||
override fun closeTerminalTab(tab: TerminalTab) {
|
||||
override fun closeTerminalTab(tab: TerminalTab, disposable: Boolean) {
|
||||
for (i in 0 until tabs.size) {
|
||||
if (tabs[i] == tab) {
|
||||
removeTabAt(i, true)
|
||||
removeTabAt(i, disposable)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ package app.termora
|
||||
|
||||
interface TerminalTabbedManager {
|
||||
fun addTerminalTab(tab: TerminalTab)
|
||||
fun addTerminalTab(index: Int, tab: TerminalTab)
|
||||
fun getSelectedTerminalTab(): TerminalTab?
|
||||
fun getTerminalTabs(): List<TerminalTab>
|
||||
fun setSelectedTerminalTab(tab: TerminalTab)
|
||||
fun closeTerminalTab(tab: TerminalTab)
|
||||
fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
}
|
||||
|
||||
minimumSize = Dimension(640, 400)
|
||||
terminalTabbed.addTab(welcomePanel)
|
||||
terminalTabbed.addTerminalTab(welcomePanel)
|
||||
|
||||
// macOS 要避开左边的控制栏
|
||||
if (SystemInfo.isMacOS) {
|
||||
@@ -116,6 +116,7 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
Disposer.register(windowScope, terminalTabbed)
|
||||
add(terminalTabbed)
|
||||
|
||||
dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane)
|
||||
dataProviderSupport.addData(DataProviders.TermoraFrame, this)
|
||||
dataProviderSupport.addData(DataProviders.WindowScope, windowScope)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import javax.swing.WindowConstants.DISPOSE_ON_CLOSE
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class TermoraFrameManager {
|
||||
@@ -22,7 +23,7 @@ class TermoraFrameManager {
|
||||
val frame = TermoraFrame()
|
||||
registerCloseCallback(frame)
|
||||
frame.title = if (SystemInfo.isLinux) null else Application.getName()
|
||||
frame.defaultCloseOperation = DISPOSE_ON_CLOSE
|
||||
frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
|
||||
frame.setSize(1280, 800)
|
||||
frame.setLocationRelativeTo(null)
|
||||
return frame
|
||||
@@ -43,6 +44,21 @@ class TermoraFrameManager {
|
||||
this@TermoraFrameManager.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
override fun windowClosing(e: WindowEvent) {
|
||||
if (ApplicationScope.windowScopes().size == 1) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
window,
|
||||
I18n.getString("termora.quit-confirm", Application.getName()),
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
window.dispose()
|
||||
}
|
||||
} else {
|
||||
window.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ class OutlinePasswordField(
|
||||
styleMap = mapOf(
|
||||
"showRevealButton" to true
|
||||
)
|
||||
|
||||
putClientProperty("JPasswordField.cutCopyAllowed", true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import app.termora.Application.ohMyJson
|
||||
import kotlinx.serialization.json.*
|
||||
import okhttp3.Request
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.commonmark.node.BulletList
|
||||
import org.commonmark.node.Heading
|
||||
import org.commonmark.node.Paragraph
|
||||
@@ -97,7 +98,14 @@ class UpdaterManager private constructor() {
|
||||
}
|
||||
|
||||
val parser = Parser.builder().build()
|
||||
val document = parser.parse("# ${name.trim()}\n${body.trim()}")
|
||||
val document = parser.parse(
|
||||
"# 🎉 ${name.trim()} (${
|
||||
DateFormatUtils.format(
|
||||
publishedDate,
|
||||
"yyyy-MM-dd"
|
||||
)
|
||||
}) \n${body.trim()}"
|
||||
)
|
||||
val renderer = HtmlRenderer.builder()
|
||||
.attributeProviderFactory {
|
||||
AttributeProvider { node, _, attributes ->
|
||||
@@ -106,7 +114,7 @@ class UpdaterManager private constructor() {
|
||||
attributes["style"] = "margin: 5px 0;"
|
||||
} else if (node is BulletList) {
|
||||
attributes["style"] = "margin: 0 20px;"
|
||||
}else if(node is Paragraph){
|
||||
} else if (node is Paragraph) {
|
||||
attributes["style"] = "margin: 0;"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ object DataProviders {
|
||||
val Terminal = DataKey(app.termora.terminal.Terminal::class)
|
||||
val PtyConnector = DataKey(app.termora.terminal.PtyConnector::class)
|
||||
|
||||
val TabbedPane = DataKey(app.termora.MyTabbedPane::class)
|
||||
val TerminalTabbed = DataKey(app.termora.TerminalTabbed::class)
|
||||
val TerminalTab = DataKey(app.termora.TerminalTab::class)
|
||||
val TerminalTabbedManager = DataKey(app.termora.TerminalTabbedManager::class)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.LocalTerminalTab
|
||||
import app.termora.OpenHostActionEvent
|
||||
import app.termora.Protocol
|
||||
import app.termora.SSHTerminalTab
|
||||
import app.termora.*
|
||||
|
||||
class OpenHostAction : AnAction() {
|
||||
companion object {
|
||||
@@ -18,9 +15,11 @@ class OpenHostAction : AnAction() {
|
||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
|
||||
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
|
||||
|
||||
val tab = if (evt.host.protocol == Protocol.SSH)
|
||||
SSHTerminalTab(windowScope, evt.host)
|
||||
else LocalTerminalTab(windowScope, evt.host)
|
||||
val tab = when (evt.host.protocol) {
|
||||
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
|
||||
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
|
||||
else -> LocalTerminalTab(windowScope, evt.host)
|
||||
}
|
||||
|
||||
terminalTabbedManager.addTerminalTab(tab)
|
||||
tab.start()
|
||||
|
||||
@@ -28,6 +28,7 @@ class TerminalUserInteraction(
|
||||
prompt[i],
|
||||
true
|
||||
)
|
||||
dialog.setLocationRelativeTo(owner)
|
||||
dialog.title = instruction ?: name ?: StringUtils.EMPTY
|
||||
passwords[i] = dialog.getText()
|
||||
if (passwords[i].isBlank()) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package app.termora.keymap
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.Database
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.Disposable
|
||||
import app.termora.*
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.findeverywhere.FindEverywhereAction
|
||||
@@ -12,34 +9,34 @@ import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.KeyEventDispatcher
|
||||
import java.awt.KeyEventPostProcessor
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JDialog
|
||||
import javax.swing.KeyStroke
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class KeymapManager private constructor() : Disposable {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(KeymapManager::class.java)
|
||||
|
||||
const val PROCESS_GLOBAL_KEYMAP = "PROCESS_GLOBAL_KEYMAP"
|
||||
|
||||
fun getInstance(): KeymapManager {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(KeymapManager::class) { KeymapManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val myKeyEventPostProcessor = MyKeyEventPostProcessor()
|
||||
private val keymapKeyEventDispatcher = KeymapKeyEventDispatcher()
|
||||
private val myKeyEventDispatcher = MyKeyEventDispatcher()
|
||||
private val database get() = Database.getDatabase()
|
||||
private val properties get() = database.properties
|
||||
private val keymaps = linkedMapOf<String, Keymap>()
|
||||
private val activeKeymap get() = database.properties.getString("Keymap.Active")
|
||||
private val activeKeymap get() = properties.getString("Keymap.Active")
|
||||
private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() }
|
||||
|
||||
init {
|
||||
keyboardFocusManager.addKeyEventPostProcessor(myKeyEventPostProcessor)
|
||||
keyboardFocusManager.addKeyEventDispatcher(keymapKeyEventDispatcher)
|
||||
keyboardFocusManager.addKeyEventDispatcher(myKeyEventDispatcher)
|
||||
|
||||
try {
|
||||
@@ -97,12 +94,26 @@ class KeymapManager private constructor() : Disposable {
|
||||
database.removeKeymap(name)
|
||||
}
|
||||
|
||||
private inner class MyKeyEventPostProcessor : KeyEventPostProcessor {
|
||||
override fun postProcessKeyEvent(e: KeyEvent): Boolean {
|
||||
// 只处理 PRESSED 和 带有 modifiers 键的事件
|
||||
if (!e.isConsumed && e.id == KeyEvent.KEY_PRESSED && e.modifiersEx != 0) {
|
||||
private inner class KeymapKeyEventDispatcher : KeyEventDispatcher {
|
||||
|
||||
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
|
||||
if (e.isConsumed || e.id != KeyEvent.KEY_PRESSED || e.modifiersEx == 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
val keyStroke = KeyStroke.getKeyStrokeForEvent(e)
|
||||
val component = e.source
|
||||
|
||||
if (component is JComponent) {
|
||||
// 如果这个键已经被组件注册了,那么忽略
|
||||
if (component.getConditionForKeyStroke(keyStroke) != JComponent.UNDEFINED_CONDITION) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val shortcuts = getActiveKeymap()
|
||||
val actionIds = shortcuts.getActionIds(KeyShortcut(KeyStroke.getKeyStrokeForEvent(e)))
|
||||
val actionIds = shortcuts.getActionIds(KeyShortcut(keyStroke))
|
||||
if (actionIds.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
@@ -128,27 +139,45 @@ class KeymapManager private constructor() : Disposable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Deprecated(message = "Deprecated")
|
||||
private inner class MyKeyEventDispatcher : KeyEventDispatcher {
|
||||
// double shift
|
||||
private var lastTime = -1L
|
||||
private val findEverywhereAction
|
||||
get() = ActionManager.getInstance().getAction(FindEverywhereAction.FIND_EVERYWHERE)
|
||||
private val deprecatedKey by lazy { "${Application.getVersion()}.FindEverywhereActionDeprecated" }
|
||||
|
||||
|
||||
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
|
||||
if (e.keyCode == KeyEvent.VK_SHIFT && e.id == KeyEvent.KEY_PRESSED) {
|
||||
val owner = AnActionEvent(e.source, StringUtils.EMPTY, e).getData(DataProviders.TermoraFrame)
|
||||
?: return false
|
||||
val evt = AnActionEvent(e.source, StringUtils.EMPTY, e)
|
||||
val owner = evt.getData(DataProviders.TermoraFrame) ?: return false
|
||||
if (keyboardFocusManager.focusedWindow == owner) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - 250 < lastTime) {
|
||||
app.termora.actions.ActionManager.getInstance()
|
||||
.getAction(FindEverywhereAction.FIND_EVERYWHERE)
|
||||
?.actionPerformed(AnActionEvent(e.source, StringUtils.EMPTY, e))
|
||||
if (!properties.getString(deprecatedKey, "false").toBoolean()) {
|
||||
properties.putString(deprecatedKey, "true")
|
||||
val shortcut = getActiveKeymap().getShortcut(FindEverywhereAction.FIND_EVERYWHERE)
|
||||
.firstOrNull()
|
||||
if (shortcut == null) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.find-everywhere.double-shift-deprecated")
|
||||
)
|
||||
} else {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.find-everywhere.double-shift-deprecated-instead", shortcut)
|
||||
)
|
||||
}
|
||||
}
|
||||
SwingUtilities.invokeLater { findEverywhereAction?.actionPerformed(evt) }
|
||||
}
|
||||
lastTime = now
|
||||
}
|
||||
@@ -163,7 +192,7 @@ class KeymapManager private constructor() : Disposable {
|
||||
|
||||
|
||||
override fun dispose() {
|
||||
keyboardFocusManager.removeKeyEventPostProcessor(myKeyEventPostProcessor)
|
||||
keyboardFocusManager.removeKeyEventDispatcher(keymapKeyEventDispatcher)
|
||||
keyboardFocusManager.removeKeyEventDispatcher(myKeyEventDispatcher)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class KeyManager private constructor() {
|
||||
if (keyPair == OhKeyPair.empty) {
|
||||
return
|
||||
}
|
||||
keyPairs.remove(keyPair)
|
||||
keyPairs.add(keyPair)
|
||||
database.addKeyPair(keyPair)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package app.termora.keymgr
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.AES.decodeBase64
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.native.FileChooser
|
||||
import com.formdev.flatlaf.extras.components.FlatComboBox
|
||||
import com.formdev.flatlaf.extras.components.FlatTable
|
||||
@@ -48,6 +51,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
private val exportBtn = JButton(I18n.getString("termora.keymgr.export"))
|
||||
private val editBtn = JButton(I18n.getString("termora.keymgr.edit"))
|
||||
private val deleteBtn = JButton(I18n.getString("termora.remove"))
|
||||
private val sshCopyIdBtn = JButton("ssh-copy-id")
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -59,6 +63,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
|
||||
exportBtn.isEnabled = false
|
||||
editBtn.isEnabled = false
|
||||
sshCopyIdBtn.isEnabled = false
|
||||
deleteBtn.isEnabled = false
|
||||
|
||||
keyPairTableModel.addColumn(I18n.getString("termora.keymgr.table.name"))
|
||||
@@ -75,7 +80,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
val formMargin = "4dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin"
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
@@ -91,6 +96,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
.add(importBtn).xy(1, rows).apply { rows += step }
|
||||
.add(exportBtn).xy(1, rows).apply { rows += step }
|
||||
.add(deleteBtn).xy(1, rows).apply { rows += step }
|
||||
.add(sshCopyIdBtn).xy(1, rows).apply { rows += step }
|
||||
.build(), BorderLayout.EAST)
|
||||
border = BorderFactory.createEmptyBorder(if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0, 12, 12, 12)
|
||||
|
||||
@@ -175,13 +181,48 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
}
|
||||
})
|
||||
|
||||
sshCopyIdBtn.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
sshCopyId(evt)
|
||||
}
|
||||
})
|
||||
|
||||
keyPairTable.selectionModel.addListSelectionListener {
|
||||
exportBtn.isEnabled = keyPairTable.selectedRowCount > 0
|
||||
editBtn.isEnabled = exportBtn.isEnabled
|
||||
deleteBtn.isEnabled = exportBtn.isEnabled
|
||||
sshCopyIdBtn.isEnabled = exportBtn.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
private fun sshCopyId(evt: AnActionEvent) {
|
||||
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
|
||||
val keyPairs = keyPairTable.selectedRows.map { keyPairTableModel.getOhKeyPair(it) }
|
||||
val publicKeys = mutableListOf<Pair<String, String>>()
|
||||
for (keyPair in keyPairs) {
|
||||
val publicKey = OhKeyPairKeyPairProvider.generateKeyPair(keyPair).public
|
||||
val baos = ByteArrayOutputStream()
|
||||
OpenSSHKeyPairResourceWriter.INSTANCE.writePublicKey(publicKey, keyPair.name, baos)
|
||||
publicKeys.add(Pair(keyPair.name, baos.toString(Charsets.UTF_8)))
|
||||
}
|
||||
|
||||
if (publicKeys.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val owner = SwingUtilities.getWindowAncestor(this) ?: return
|
||||
val hostTreeDialog = HostTreeDialog(owner) {
|
||||
it.protocol == Protocol.SSH
|
||||
}
|
||||
hostTreeDialog.isVisible = true
|
||||
val hosts = hostTreeDialog.hosts
|
||||
if (hosts.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
SSHCopyIdDialog(owner, windowScope, hosts, publicKeys).start()
|
||||
}
|
||||
|
||||
private fun exportKeyPairs(file: File, keyPairs: List<OhKeyPair>) {
|
||||
file.outputStream().use { fis ->
|
||||
val names = mutableMapOf<String, Int>()
|
||||
|
||||
197
src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt
Normal file
197
src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt
Normal file
@@ -0,0 +1,197 @@
|
||||
package app.termora.keymgr
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import app.termora.terminal.ControlCharacters
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.PtyConnectorDelegate
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.channel.ClientChannelEvent
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import javax.swing.AbstractAction
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.UIManager
|
||||
|
||||
class SSHCopyIdDialog(
|
||||
owner: Window,
|
||||
private val windowScope: WindowScope,
|
||||
private val hosts: List<Host>,
|
||||
// key: name , value: public key
|
||||
private val publicKeys: List<Pair<String, String>>,
|
||||
) : DialogWrapper(owner) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SSHCopyIdDialog::class.java)
|
||||
}
|
||||
|
||||
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
|
||||
private val terminal by lazy {
|
||||
TerminalFactory.getInstance(windowScope).createTerminal().apply {
|
||||
getTerminalModel().setData(DataKey.ShowCursor, false)
|
||||
getTerminalModel().setData(DataKey.AutoNewline, true)
|
||||
}
|
||||
}
|
||||
private val terminalPanel by lazy {
|
||||
terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate())
|
||||
}
|
||||
private val coroutineScope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 100, UIManager.getInt("Dialog.height") - 100)
|
||||
isModal = true
|
||||
title = "SSH Copy ID"
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
Disposer.register(disposable, object : Disposable {
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
terminal.close()
|
||||
Disposer.dispose(terminalPanel)
|
||||
}
|
||||
})
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
return terminalPanel
|
||||
}
|
||||
|
||||
fun start() {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
doStart()
|
||||
} catch (e: Exception) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
|
||||
override fun createActions(): List<AbstractAction> {
|
||||
return listOf(CancelAction())
|
||||
}
|
||||
|
||||
private fun magenta(text: Any): String {
|
||||
return "${ControlCharacters.ESC}[35m${text}${ControlCharacters.ESC}[0m"
|
||||
}
|
||||
|
||||
private fun cyan(text: Any): String {
|
||||
return "${ControlCharacters.ESC}[36m${text}${ControlCharacters.ESC}[0m"
|
||||
}
|
||||
|
||||
private fun red(text: Any): String {
|
||||
return "${ControlCharacters.ESC}[31m${text}${ControlCharacters.ESC}[0m"
|
||||
}
|
||||
|
||||
private fun green(text: Any): String {
|
||||
return "${ControlCharacters.ESC}[32m${text}${ControlCharacters.ESC}[0m"
|
||||
}
|
||||
|
||||
private suspend fun doStart() {
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write(
|
||||
I18n.getString(
|
||||
"termora.keymgr.ssh-copy-id.number",
|
||||
magenta(hosts.size),
|
||||
magenta(publicKeys.size)
|
||||
)
|
||||
)
|
||||
terminal.getDocument().newline()
|
||||
terminal.getDocument().newline()
|
||||
}
|
||||
|
||||
var myClient: SshClient? = null
|
||||
var mySession: ClientSession? = null
|
||||
val timeout = Duration.ofMinutes(1)
|
||||
|
||||
// 获取公钥名称最长的
|
||||
val publicKeyNameLength = publicKeys.maxOfOrNull { it.first.length } ?: 0
|
||||
|
||||
for (index in hosts.indices) {
|
||||
if (!coroutineScope.isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
val host = hosts[index]
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("[${cyan(index + 1)}/${cyan(hosts.size)}] ${host.name}")
|
||||
terminal.getDocument().newline()
|
||||
}
|
||||
|
||||
for ((j, e) in publicKeys.withIndex()) {
|
||||
if (!coroutineScope.isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
val publicKeyName = e.first.padEnd(publicKeyNameLength, ' ')
|
||||
val publicKey = e.second
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
// @formatter:off
|
||||
terminal.write("\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${I18n.getString("termora.transport.sftp.connecting")}")
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
try {
|
||||
val client = SshClients.openClient(host).apply { myClient = this }
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
val session = SshClients.openSession(host, client).apply { mySession = this }
|
||||
val channel =
|
||||
session.createExecChannel("mkdir -p ~/.ssh && grep -qxF \"$publicKey\" ~/.ssh/authorized_keys || echo \"$publicKey\" >> ~/.ssh/authorized_keys")
|
||||
val baos = ByteArrayOutputStream()
|
||||
channel.out = baos
|
||||
if (channel.open().verify(timeout).await(timeout)) {
|
||||
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout);
|
||||
}
|
||||
if (channel.exitStatus != 0) {
|
||||
throw IllegalStateException("Server response: ${channel.exitStatus}")
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.getDocument().eraseInLine(2)
|
||||
// @formatter:off
|
||||
terminal.write("\r\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${green(I18n.getString("termora.keymgr.ssh-copy-id.successful"))}")
|
||||
// @formatter:on
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.getDocument().eraseInLine(2)
|
||||
// @formatter:off
|
||||
terminal.write("\r\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${red("${I18n.getString("termora.keymgr.ssh-copy-id.failed")}: ${e.message}")}")
|
||||
// @formatter:on
|
||||
}
|
||||
} finally {
|
||||
IOUtils.closeQuietly(mySession)
|
||||
IOUtils.closeQuietly(myClient)
|
||||
}
|
||||
|
||||
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.getDocument().newline()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.getDocument().newline()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write(I18n.getString("termora.keymgr.ssh-copy-id.end"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,10 @@ class FileChooser {
|
||||
var allowsOtherFileTypes = true
|
||||
var canCreateDirectories = true
|
||||
var win32Filters = mutableListOf<Pair<String, List<String>>>()
|
||||
|
||||
/**
|
||||
* e.g. listOf("json")
|
||||
*/
|
||||
var osxAllowedFileTypes = emptyList<String>()
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
package app.termora.sync
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.AES.CBC.aesCBCDecrypt
|
||||
import app.termora.AES.CBC.aesCBCEncrypt
|
||||
import app.termora.AES.decodeBase64
|
||||
import app.termora.AES.encodeBase64String
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.highlight.KeywordHighlight
|
||||
import app.termora.highlight.KeywordHighlightManager
|
||||
import app.termora.keymap.Keymap
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import app.termora.macro.Macro
|
||||
import app.termora.macro.MacroManager
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import app.termora.ResponseException
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
abstract class GitSyncer : Syncer {
|
||||
abstract class GitSyncer : SafetySyncer() {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(GitSyncer::class.java)
|
||||
}
|
||||
|
||||
protected val description = "${Application.getName()} config"
|
||||
protected val httpClient get() = Application.httpClient
|
||||
protected val hostManager get() = HostManager.getInstance()
|
||||
protected val keyManager get() = KeyManager.getInstance()
|
||||
protected val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
|
||||
protected val macroManager get() = MacroManager.getInstance()
|
||||
protected val keymapManager get() = KeymapManager.getInstance()
|
||||
|
||||
override fun pull(config: SyncConfig): GistResponse {
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
@@ -92,174 +69,6 @@ abstract class GitSyncer : Syncer {
|
||||
return gistResponse
|
||||
}
|
||||
|
||||
private fun decodeHosts(text: String, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
|
||||
val hosts = hostManager.hosts().associateBy { it.id }
|
||||
|
||||
for (encryptedHost in encryptedHosts) {
|
||||
val oldHost = hosts[encryptedHost.id]
|
||||
|
||||
// 如果一样,则无需配置
|
||||
if (oldHost != null) {
|
||||
if (oldHost.updateDate == encryptedHost.updateDate) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(encryptedHost.id)
|
||||
val host = Host(
|
||||
id = encryptedHost.id,
|
||||
name = encryptedHost.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
protocol = Protocol.valueOf(
|
||||
encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
|
||||
),
|
||||
host = encryptedHost.host.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
port = encryptedHost.port.decodeBase64().aesCBCDecrypt(key, iv)
|
||||
.decodeToString().toIntOrNull() ?: 0,
|
||||
username = encryptedHost.username.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
remark = encryptedHost.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
authentication = ohMyJson.decodeFromString(
|
||||
encryptedHost.authentication.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
|
||||
),
|
||||
proxy = ohMyJson.decodeFromString(
|
||||
encryptedHost.proxy.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
|
||||
),
|
||||
options = ohMyJson.decodeFromString(
|
||||
encryptedHost.options.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
|
||||
),
|
||||
tunnelings = ohMyJson.decodeFromString(
|
||||
encryptedHost.tunnelings.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
|
||||
),
|
||||
sort = encryptedHost.sort,
|
||||
parentId = encryptedHost.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
ownerId = encryptedHost.ownerId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
createDate = encryptedHost.createDate,
|
||||
updateDate = encryptedHost.updateDate,
|
||||
deleted = encryptedHost.deleted
|
||||
)
|
||||
SwingUtilities.invokeLater { hostManager.addHost(host) }
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Decode host: ${encryptedHost.id} failed. error: {}", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode hosts: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeKeys(text: String, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
|
||||
|
||||
for (encryptedKey in encryptedKeys) {
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(encryptedKey.id)
|
||||
val keyPair = OhKeyPair(
|
||||
id = encryptedKey.id,
|
||||
publicKey = encryptedKey.publicKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
|
||||
privateKey = encryptedKey.privateKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
|
||||
type = encryptedKey.type.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
name = encryptedKey.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
remark = encryptedKey.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
length = encryptedKey.length,
|
||||
sort = encryptedKey.sort
|
||||
)
|
||||
SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair) }
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Decode key: ${encryptedKey.id} failed. error: {}", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode keys: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeKeywordHighlights(text: String, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
|
||||
|
||||
for (e in encryptedKeywordHighlights) {
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(e.id)
|
||||
keywordHighlightManager.addKeywordHighlight(
|
||||
e.copy(
|
||||
keyword = e.keyword.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
description = e.description.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
)
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Decode KeywordHighlight: ${e.id} failed. error: {}", ex.message, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode KeywordHighlight: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeMacros(text: String, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
|
||||
|
||||
for (e in encryptedMacros) {
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(e.id)
|
||||
macroManager.addMacro(
|
||||
e.copy(
|
||||
name = e.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
macro = e.macro.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
)
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Decode Macro: ${e.id} failed. error: {}", ex.message, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode Macros: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeKeymaps(text: String, config: SyncConfig) {
|
||||
|
||||
for (keymap in ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }) {
|
||||
keymapManager.addKeymap(keymap)
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode Keymaps: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getKey(config: SyncConfig): ByteArray {
|
||||
return ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
|
||||
}
|
||||
|
||||
private fun getIv(id: String): ByteArray {
|
||||
return ArrayUtils.subarray(id.padEnd(16, '0').toByteArray(), 0, 16)
|
||||
}
|
||||
|
||||
override fun push(config: SyncConfig): GistResponse {
|
||||
val gistFiles = mutableListOf<GistFile>()
|
||||
@@ -268,62 +77,16 @@ abstract class GitSyncer : Syncer {
|
||||
|
||||
// Hosts
|
||||
if (config.ranges.contains(SyncRange.Hosts)) {
|
||||
val encryptedHosts = mutableListOf<EncryptedHost>()
|
||||
for (host in hostManager.hosts()) {
|
||||
// aes iv
|
||||
val iv = ArrayUtils.subarray(host.id.padEnd(16, '0').toByteArray(), 0, 16)
|
||||
val encryptedHost = EncryptedHost()
|
||||
encryptedHost.id = host.id
|
||||
encryptedHost.name = host.name.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.protocol = host.protocol.name.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.host = host.host.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.port = "${host.port}".aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.username = host.username.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.remark = host.remark.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.authentication = ohMyJson.encodeToString(host.authentication)
|
||||
.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.proxy = ohMyJson.encodeToString(host.proxy).aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.options =
|
||||
ohMyJson.encodeToString(host.options).aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.tunnelings =
|
||||
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.sort = host.sort
|
||||
encryptedHost.deleted = host.deleted
|
||||
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.createDate = host.createDate
|
||||
encryptedHost.updateDate = host.updateDate
|
||||
encryptedHosts.add(encryptedHost)
|
||||
}
|
||||
|
||||
val hostsContent = ohMyJson.encodeToString(encryptedHosts)
|
||||
val hostsContent = encodeHosts(key)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push encryptedHosts: {}", hostsContent)
|
||||
}
|
||||
gistFiles.add(GistFile("Hosts", hostsContent))
|
||||
|
||||
}
|
||||
|
||||
// KeyPairs
|
||||
if (config.ranges.contains(SyncRange.KeyPairs)) {
|
||||
val encryptedKeys = mutableListOf<OhKeyPair>()
|
||||
for (keyPair in keyManager.getOhKeyPairs()) {
|
||||
// aes iv
|
||||
val iv = ArrayUtils.subarray(keyPair.id.padEnd(16, '0').toByteArray(), 0, 16)
|
||||
val encryptedKeyPair = OhKeyPair(
|
||||
id = keyPair.id,
|
||||
publicKey = keyPair.publicKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
privateKey = keyPair.privateKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
type = keyPair.type.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
name = keyPair.name.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
remark = keyPair.remark.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
length = keyPair.length,
|
||||
sort = keyPair.sort
|
||||
)
|
||||
encryptedKeys.add(encryptedKeyPair)
|
||||
}
|
||||
val keysContent = ohMyJson.encodeToString(encryptedKeys)
|
||||
val keysContent = encodeKeys(key)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push encryptedKeys: {}", keysContent)
|
||||
}
|
||||
@@ -332,17 +95,7 @@ abstract class GitSyncer : Syncer {
|
||||
|
||||
// Highlights
|
||||
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
|
||||
val keywordHighlights = mutableListOf<KeywordHighlight>()
|
||||
for (keywordHighlight in keywordHighlightManager.getKeywordHighlights()) {
|
||||
// aes iv
|
||||
val iv = getIv(keywordHighlight.id)
|
||||
val encryptedKeyPair = keywordHighlight.copy(
|
||||
keyword = keywordHighlight.keyword.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
description = keywordHighlight.description.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
)
|
||||
keywordHighlights.add(encryptedKeyPair)
|
||||
}
|
||||
val keywordHighlightsContent = ohMyJson.encodeToString(keywordHighlights)
|
||||
val keywordHighlightsContent = encodeKeywordHighlights(key)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
|
||||
}
|
||||
@@ -351,17 +104,7 @@ abstract class GitSyncer : Syncer {
|
||||
|
||||
// Macros
|
||||
if (config.ranges.contains(SyncRange.Macros)) {
|
||||
val macros = mutableListOf<Macro>()
|
||||
for (macro in macroManager.getMacros()) {
|
||||
val iv = getIv(macro.id)
|
||||
macros.add(
|
||||
macro.copy(
|
||||
name = macro.name.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
macro = macro.macro.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
)
|
||||
)
|
||||
}
|
||||
val macrosContent = ohMyJson.encodeToString(macros)
|
||||
val macrosContent = encodeMacros(key)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push macros: {}", macrosContent)
|
||||
}
|
||||
@@ -370,23 +113,12 @@ abstract class GitSyncer : Syncer {
|
||||
|
||||
// Keymap
|
||||
if (config.ranges.contains(SyncRange.Keymap)) {
|
||||
val keymaps = mutableListOf<JsonObject>()
|
||||
for (keymap in keymapManager.getKeymaps()) {
|
||||
// 只读的是内置的
|
||||
if (keymap.isReadonly) {
|
||||
continue
|
||||
}
|
||||
keymaps.add(keymap.toJSONObject())
|
||||
}
|
||||
|
||||
if (keymaps.isNotEmpty()) {
|
||||
val keymapsContent = ohMyJson.encodeToString(keymaps)
|
||||
val keymapsContent = encodeKeymaps()
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push keymaps: {}", keymapsContent)
|
||||
}
|
||||
gistFiles.add(GistFile("Keymaps", keymapsContent))
|
||||
}
|
||||
}
|
||||
|
||||
if (gistFiles.isEmpty()) {
|
||||
throw IllegalArgumentException("No gist files found")
|
||||
|
||||
299
src/main/kotlin/app/termora/sync/SafetySyncer.kt
Normal file
299
src/main/kotlin/app/termora/sync/SafetySyncer.kt
Normal file
@@ -0,0 +1,299 @@
|
||||
package app.termora.sync
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.AES.CBC.aesCBCDecrypt
|
||||
import app.termora.AES.CBC.aesCBCEncrypt
|
||||
import app.termora.AES.decodeBase64
|
||||
import app.termora.AES.encodeBase64String
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.highlight.KeywordHighlight
|
||||
import app.termora.highlight.KeywordHighlightManager
|
||||
import app.termora.keymap.Keymap
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import app.termora.macro.Macro
|
||||
import app.termora.macro.MacroManager
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
abstract class SafetySyncer : Syncer {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SafetySyncer::class.java)
|
||||
}
|
||||
|
||||
protected val description = "${Application.getName()} config"
|
||||
protected val httpClient get() = Application.httpClient
|
||||
protected val hostManager get() = HostManager.getInstance()
|
||||
protected val keyManager get() = KeyManager.getInstance()
|
||||
protected val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
|
||||
protected val macroManager get() = MacroManager.getInstance()
|
||||
protected val keymapManager get() = KeymapManager.getInstance()
|
||||
|
||||
protected fun decodeHosts(text: String, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
|
||||
val hosts = hostManager.hosts().associateBy { it.id }
|
||||
|
||||
for (encryptedHost in encryptedHosts) {
|
||||
val oldHost = hosts[encryptedHost.id]
|
||||
|
||||
// 如果一样,则无需配置
|
||||
if (oldHost != null) {
|
||||
if (oldHost.updateDate == encryptedHost.updateDate) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(encryptedHost.id)
|
||||
val host = Host(
|
||||
id = encryptedHost.id,
|
||||
name = encryptedHost.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
protocol = Protocol.valueOf(
|
||||
encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
|
||||
),
|
||||
host = encryptedHost.host.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
port = encryptedHost.port.decodeBase64().aesCBCDecrypt(key, iv)
|
||||
.decodeToString().toIntOrNull() ?: 0,
|
||||
username = encryptedHost.username.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
remark = encryptedHost.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
authentication = ohMyJson.decodeFromString(
|
||||
encryptedHost.authentication.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
|
||||
),
|
||||
proxy = ohMyJson.decodeFromString(
|
||||
encryptedHost.proxy.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
|
||||
),
|
||||
options = ohMyJson.decodeFromString(
|
||||
encryptedHost.options.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
|
||||
),
|
||||
tunnelings = ohMyJson.decodeFromString(
|
||||
encryptedHost.tunnelings.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
|
||||
),
|
||||
sort = encryptedHost.sort,
|
||||
parentId = encryptedHost.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
ownerId = encryptedHost.ownerId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
createDate = encryptedHost.createDate,
|
||||
updateDate = encryptedHost.updateDate,
|
||||
deleted = encryptedHost.deleted
|
||||
)
|
||||
SwingUtilities.invokeLater { hostManager.addHost(host) }
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Decode host: ${encryptedHost.id} failed. error: {}", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode hosts: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun encodeHosts(key: ByteArray): String {
|
||||
val encryptedHosts = mutableListOf<EncryptedHost>()
|
||||
for (host in hostManager.hosts()) {
|
||||
// aes iv
|
||||
val iv = ArrayUtils.subarray(host.id.padEnd(16, '0').toByteArray(), 0, 16)
|
||||
val encryptedHost = EncryptedHost()
|
||||
encryptedHost.id = host.id
|
||||
encryptedHost.name = host.name.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.protocol = host.protocol.name.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.host = host.host.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.port = "${host.port}".aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.username = host.username.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.remark = host.remark.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.authentication = ohMyJson.encodeToString(host.authentication)
|
||||
.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.proxy = ohMyJson.encodeToString(host.proxy).aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.options =
|
||||
ohMyJson.encodeToString(host.options).aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.tunnelings =
|
||||
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.sort = host.sort
|
||||
encryptedHost.deleted = host.deleted
|
||||
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.createDate = host.createDate
|
||||
encryptedHost.updateDate = host.updateDate
|
||||
encryptedHosts.add(encryptedHost)
|
||||
}
|
||||
|
||||
return ohMyJson.encodeToString(encryptedHosts)
|
||||
|
||||
}
|
||||
|
||||
protected fun decodeKeys(text: String, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
|
||||
|
||||
for (encryptedKey in encryptedKeys) {
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(encryptedKey.id)
|
||||
val keyPair = OhKeyPair(
|
||||
id = encryptedKey.id,
|
||||
publicKey = encryptedKey.publicKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
|
||||
privateKey = encryptedKey.privateKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
|
||||
type = encryptedKey.type.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
name = encryptedKey.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
remark = encryptedKey.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
length = encryptedKey.length,
|
||||
sort = encryptedKey.sort
|
||||
)
|
||||
SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair) }
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Decode key: ${encryptedKey.id} failed. error: {}", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode keys: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun encodeKeys(key: ByteArray): String {
|
||||
val encryptedKeys = mutableListOf<OhKeyPair>()
|
||||
for (keyPair in keyManager.getOhKeyPairs()) {
|
||||
// aes iv
|
||||
val iv = ArrayUtils.subarray(keyPair.id.padEnd(16, '0').toByteArray(), 0, 16)
|
||||
val encryptedKeyPair = OhKeyPair(
|
||||
id = keyPair.id,
|
||||
publicKey = keyPair.publicKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
privateKey = keyPair.privateKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
type = keyPair.type.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
name = keyPair.name.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
remark = keyPair.remark.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
length = keyPair.length,
|
||||
sort = keyPair.sort
|
||||
)
|
||||
encryptedKeys.add(encryptedKeyPair)
|
||||
}
|
||||
return ohMyJson.encodeToString(encryptedKeys)
|
||||
}
|
||||
|
||||
protected fun decodeKeywordHighlights(text: String, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
|
||||
|
||||
for (e in encryptedKeywordHighlights) {
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(e.id)
|
||||
keywordHighlightManager.addKeywordHighlight(
|
||||
e.copy(
|
||||
keyword = e.keyword.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
description = e.description.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
)
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Decode KeywordHighlight: ${e.id} failed. error: {}", ex.message, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode KeywordHighlight: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun encodeKeywordHighlights(key: ByteArray): String {
|
||||
val keywordHighlights = mutableListOf<KeywordHighlight>()
|
||||
for (keywordHighlight in keywordHighlightManager.getKeywordHighlights()) {
|
||||
// aes iv
|
||||
val iv = getIv(keywordHighlight.id)
|
||||
val encryptedKeyPair = keywordHighlight.copy(
|
||||
keyword = keywordHighlight.keyword.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
description = keywordHighlight.description.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
)
|
||||
keywordHighlights.add(encryptedKeyPair)
|
||||
}
|
||||
return ohMyJson.encodeToString(keywordHighlights)
|
||||
}
|
||||
|
||||
protected fun decodeMacros(text: String, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
|
||||
|
||||
for (e in encryptedMacros) {
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(e.id)
|
||||
macroManager.addMacro(
|
||||
e.copy(
|
||||
name = e.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
macro = e.macro.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
)
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Decode Macro: ${e.id} failed. error: {}", ex.message, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode Macros: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun encodeMacros(key: ByteArray): String {
|
||||
val macros = mutableListOf<Macro>()
|
||||
for (macro in macroManager.getMacros()) {
|
||||
val iv = getIv(macro.id)
|
||||
macros.add(
|
||||
macro.copy(
|
||||
name = macro.name.aesCBCEncrypt(key, iv).encodeBase64String(),
|
||||
macro = macro.macro.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
)
|
||||
)
|
||||
}
|
||||
return ohMyJson.encodeToString(macros)
|
||||
}
|
||||
|
||||
protected fun decodeKeymaps(text: String, config: SyncConfig) {
|
||||
|
||||
for (keymap in ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }) {
|
||||
keymapManager.addKeymap(keymap)
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode Keymaps: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun encodeKeymaps(): String {
|
||||
val keymaps = mutableListOf<JsonObject>()
|
||||
for (keymap in keymapManager.getKeymaps()) {
|
||||
// 只读的是内置的
|
||||
if (keymap.isReadonly) {
|
||||
continue
|
||||
}
|
||||
keymaps.add(keymap.toJSONObject())
|
||||
}
|
||||
|
||||
return ohMyJson.encodeToString(keymaps)
|
||||
}
|
||||
|
||||
protected open fun getKey(config: SyncConfig): ByteArray {
|
||||
return ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
|
||||
}
|
||||
|
||||
protected fun getIv(id: String): ByteArray {
|
||||
return ArrayUtils.subarray(id.padEnd(16, '0').toByteArray(), 0, 16)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ enum class SyncType {
|
||||
GitLab,
|
||||
GitHub,
|
||||
Gitee,
|
||||
WebDAV,
|
||||
}
|
||||
|
||||
enum class SyncRange {
|
||||
|
||||
@@ -15,6 +15,7 @@ class SyncerProvider private constructor() {
|
||||
SyncType.GitHub -> GitHubSyncer.getInstance()
|
||||
SyncType.Gitee -> GiteeSyncer.getInstance()
|
||||
SyncType.GitLab -> GitLabSyncer.getInstance()
|
||||
SyncType.WebDAV -> WebDAVSyncer.getInstance()
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/main/kotlin/app/termora/sync/WebDAVSyncer.kt
Normal file
152
src/main/kotlin/app/termora/sync/WebDAVSyncer.kt
Normal file
@@ -0,0 +1,152 @@
|
||||
package app.termora.sync
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.PBKDF2
|
||||
import app.termora.ResponseException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class WebDAVSyncer private constructor() : SafetySyncer() {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(WebDAVSyncer::class.java)
|
||||
|
||||
fun getInstance(): WebDAVSyncer {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(WebDAVSyncer::class) { WebDAVSyncer() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun pull(config: SyncConfig): GistResponse {
|
||||
val response = httpClient.newCall(newRequestBuilder(config).get().build()).execute()
|
||||
if (!response.isSuccessful) {
|
||||
throw ResponseException(response.code, response)
|
||||
}
|
||||
|
||||
val text = response.use { resp -> resp.body?.use { it.string() } }
|
||||
?: throw ResponseException(response.code, response)
|
||||
|
||||
val json = ohMyJson.decodeFromString<JsonObject>(text)
|
||||
|
||||
// decode hosts
|
||||
json["Hosts"]?.jsonPrimitive?.content?.let {
|
||||
decodeHosts(it, config)
|
||||
}
|
||||
|
||||
// decode KeyPairs
|
||||
json["KeyPairs"]?.jsonPrimitive?.content?.let {
|
||||
decodeKeys(it, config)
|
||||
}
|
||||
|
||||
// decode Highlights
|
||||
json["KeywordHighlights"]?.jsonPrimitive?.content?.let {
|
||||
decodeKeywordHighlights(it, config)
|
||||
}
|
||||
|
||||
// decode Macros
|
||||
json["Macros"]?.jsonPrimitive?.content?.let {
|
||||
decodeMacros(it, config)
|
||||
}
|
||||
|
||||
// decode Keymaps
|
||||
json["Keymaps"]?.jsonPrimitive?.content?.let {
|
||||
decodeKeymaps(it, config)
|
||||
}
|
||||
|
||||
return GistResponse(config, emptyList())
|
||||
}
|
||||
|
||||
override fun push(config: SyncConfig): GistResponse {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val json = buildJsonObject {
|
||||
// Hosts
|
||||
if (config.ranges.contains(SyncRange.Hosts)) {
|
||||
val hostsContent = encodeHosts(key)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push encryptedHosts: {}", hostsContent)
|
||||
}
|
||||
put("Hosts", hostsContent)
|
||||
}
|
||||
|
||||
// KeyPairs
|
||||
if (config.ranges.contains(SyncRange.KeyPairs)) {
|
||||
val keysContent = encodeKeys(key)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push encryptedKeys: {}", keysContent)
|
||||
}
|
||||
put("KeyPairs", keysContent)
|
||||
}
|
||||
|
||||
// Highlights
|
||||
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
|
||||
val keywordHighlightsContent = encodeKeywordHighlights(key)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
|
||||
}
|
||||
put("KeywordHighlights", keywordHighlightsContent)
|
||||
}
|
||||
|
||||
// Macros
|
||||
if (config.ranges.contains(SyncRange.Macros)) {
|
||||
val macrosContent = encodeMacros(key)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push macros: {}", macrosContent)
|
||||
}
|
||||
put("Macros", macrosContent)
|
||||
}
|
||||
|
||||
// Keymap
|
||||
if (config.ranges.contains(SyncRange.Keymap)) {
|
||||
val keymapsContent = encodeKeymaps()
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push keymaps: {}", keymapsContent)
|
||||
}
|
||||
put("Keymaps", keymapsContent)
|
||||
}
|
||||
}
|
||||
|
||||
val response = httpClient.newCall(
|
||||
newRequestBuilder(config).put(
|
||||
ohMyJson.encodeToString(json)
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
).build()
|
||||
).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw ResponseException(response.code, response)
|
||||
}
|
||||
|
||||
return GistResponse(
|
||||
config = config,
|
||||
gists = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun getWebDavFileUrl(config: SyncConfig): String {
|
||||
return config.options["domain"] ?: throw IllegalStateException("domain is not defined")
|
||||
}
|
||||
|
||||
override fun getKey(config: SyncConfig): ByteArray {
|
||||
return PBKDF2.generateSecret(
|
||||
config.gistId.toCharArray(),
|
||||
config.token.toByteArray(),
|
||||
10000, 128
|
||||
)
|
||||
}
|
||||
|
||||
private fun newRequestBuilder(config: SyncConfig): Request.Builder {
|
||||
return Request.Builder()
|
||||
.header("Authorization", Credentials.basic(config.gistId, config.token, Charsets.UTF_8))
|
||||
.url(getWebDavFileUrl(config))
|
||||
}
|
||||
}
|
||||
@@ -485,9 +485,11 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
|
||||
val m = args.first()
|
||||
if (m == '6') {
|
||||
val position = terminal.getCursorModel().getPosition()
|
||||
ptyConnector.write("${ControlCharacters.ESC}[${position.y};${position.x}R")
|
||||
val bytes = "${ControlCharacters.ESC}[${position.y};${position.x}R".toByteArray(ptyConnector.getCharset())
|
||||
ptyConnector.write(bytes)
|
||||
} else if (m == '5') {
|
||||
ptyConnector.write("${ControlCharacters.ESC}[0n")
|
||||
val bytes = "${ControlCharacters.ESC}[0n".toByteArray(ptyConnector.getCharset())
|
||||
ptyConnector.write(bytes)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -689,6 +691,13 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
|
||||
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
|
||||
1049 -> {
|
||||
|
||||
// Save cursor
|
||||
if (enable) {
|
||||
CursorStoreStores.store(terminal)
|
||||
} else {
|
||||
CursorStoreStores.restore(terminal)
|
||||
}
|
||||
|
||||
// 如果是关闭 清屏
|
||||
if (!enable) {
|
||||
terminal.getDocument().eraseInDisplay(2)
|
||||
@@ -922,7 +931,7 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
|
||||
|
||||
else -> {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("xterm-256 foreground color, code: $code")
|
||||
log.warn("xterm-256 background color, code: $code")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
src/main/kotlin/app/termora/terminal/CursorStoreStores.kt
Normal file
66
src/main/kotlin/app/termora/terminal/CursorStoreStores.kt
Normal file
@@ -0,0 +1,66 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
|
||||
object CursorStoreStores {
|
||||
private val log = LoggerFactory.getLogger(CursorStoreStores::class.java)
|
||||
|
||||
fun restore(terminal: Terminal) {
|
||||
val terminalModel = terminal.getTerminalModel()
|
||||
val cursorStore = if (terminalModel.hasData(DataKey.SaveCursor)) {
|
||||
terminalModel.getData(DataKey.SaveCursor)
|
||||
} else {
|
||||
CursorStore(
|
||||
position = Position(1, 1),
|
||||
textStyle = TextStyle.Default,
|
||||
autoWarpMode = false,
|
||||
originMode = false,
|
||||
graphicCharacterSet = GraphicCharacterSet()
|
||||
)
|
||||
}
|
||||
|
||||
terminalModel.setData(DataKey.OriginMode, cursorStore.originMode)
|
||||
terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle)
|
||||
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
|
||||
terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet)
|
||||
|
||||
val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion()
|
||||
else ScrollingRegion(top = 1, bottom = terminalModel.getRows())
|
||||
var y = cursorStore.position.y
|
||||
if (y < region.top) {
|
||||
y = 1
|
||||
} else if (y > region.bottom) {
|
||||
y = region.bottom
|
||||
}
|
||||
|
||||
terminal.getCursorModel().move(row = y, col = cursorStore.position.x)
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Restore Cursor (DECRC). $cursorStore")
|
||||
}
|
||||
}
|
||||
|
||||
fun store(terminal: Terminal) {
|
||||
val terminalModel = terminal.getTerminalModel()
|
||||
|
||||
val graphicCharacterSet = terminalModel.getData(DataKey.GraphicCharacterSet)
|
||||
// 避免引用
|
||||
val characterSets = mutableMapOf<Graphic, CharacterSet>()
|
||||
characterSets.putAll(graphicCharacterSet.characterSets)
|
||||
|
||||
val cursorStore = CursorStore(
|
||||
position = terminal.getCursorModel().getPosition(),
|
||||
textStyle = terminalModel.getData(DataKey.TextStyle),
|
||||
autoWarpMode = terminalModel.getData(DataKey.AutoWrapMode, false),
|
||||
originMode = terminalModel.isOriginMode(),
|
||||
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
|
||||
)
|
||||
|
||||
terminalModel.setData(DataKey.SaveCursor, cursorStore)
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Save Cursor (DECSC). $cursorStore")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class DeviceControlProcessor(private val terminal: Terminal) : Processor {
|
||||
private val args = StringBuilder()
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(DeviceControlProcessor::class.java)
|
||||
}
|
||||
|
||||
|
||||
override fun process(ch: Char): ProcessorState {
|
||||
val state = when (ch) {
|
||||
ControlCharacters.ST -> {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Ignore DCS: {}", args)
|
||||
}
|
||||
TerminalState.READY
|
||||
}
|
||||
|
||||
else -> {
|
||||
args.append(ch)
|
||||
TerminalState.DCS
|
||||
}
|
||||
}
|
||||
|
||||
if (state == TerminalState.READY) {
|
||||
args.clear()
|
||||
}
|
||||
|
||||
return state
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class DeviceControlStringProcessor(terminal: Terminal, reader: TerminalReader) : AbstractProcessor(terminal, reader) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(DeviceControlStringProcessor::class.java)
|
||||
}
|
||||
|
||||
private val systemCommandSequence = SystemCommandSequence()
|
||||
|
||||
override fun process(ch: Char): ProcessorState {
|
||||
// 回退回去,然后重新读取出来
|
||||
reader.addFirst(ch)
|
||||
|
||||
do {
|
||||
|
||||
if (systemCommandSequence.process(reader.read())) {
|
||||
break
|
||||
}
|
||||
|
||||
// 如果没有检测到结束,那么退出重新来
|
||||
if (reader.isEmpty()) {
|
||||
return TerminalState.DCS
|
||||
}
|
||||
|
||||
} while (reader.isNotEmpty())
|
||||
|
||||
processCommand(systemCommandSequence.getCommand())
|
||||
|
||||
systemCommandSequence.reset()
|
||||
|
||||
return TerminalState.READY
|
||||
}
|
||||
|
||||
|
||||
private fun processCommand(command: String) {
|
||||
if (command.isEmpty()) {
|
||||
return
|
||||
}
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Cannot process command: {}", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,9 +128,9 @@ class EscapeSequenceProcessor(terminal: Terminal, reader: TerminalReader) : Abst
|
||||
|
||||
}
|
||||
|
||||
// TODO Device Control String (DCS is 0x90).
|
||||
// Device Control String (DCS is 0x90).
|
||||
'P' -> {
|
||||
|
||||
state = TerminalState.DCS
|
||||
}
|
||||
|
||||
// Start of Guarded Area (SPA is 0x96).
|
||||
@@ -333,59 +333,12 @@ class EscapeSequenceProcessor(terminal: Terminal, reader: TerminalReader) : Abst
|
||||
|
||||
// ESC 7 Save Cursor (DECSC), VT100.
|
||||
'7' -> {
|
||||
val graphicCharacterSet = terminalModel.getData(DataKey.GraphicCharacterSet)
|
||||
// 避免引用
|
||||
val characterSets = mutableMapOf<Graphic, CharacterSet>()
|
||||
characterSets.putAll(graphicCharacterSet.characterSets)
|
||||
|
||||
val cursorStore = CursorStore(
|
||||
position = terminal.getCursorModel().getPosition(),
|
||||
textStyle = terminalModel.getData(DataKey.TextStyle),
|
||||
autoWarpMode = terminalModel.getData(DataKey.AutoWrapMode, false),
|
||||
originMode = terminalModel.isOriginMode(),
|
||||
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
|
||||
)
|
||||
|
||||
terminalModel.setData(DataKey.SaveCursor, cursorStore)
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Save Cursor (DECSC). $cursorStore")
|
||||
}
|
||||
CursorStoreStores.store(terminal)
|
||||
}
|
||||
|
||||
// Restore Cursor (DECRC), VT100.
|
||||
'8' -> {
|
||||
val cursorStore = if (terminalModel.hasData(DataKey.SaveCursor)) {
|
||||
terminalModel.getData(DataKey.SaveCursor)
|
||||
} else {
|
||||
CursorStore(
|
||||
position = Position(1, 1),
|
||||
textStyle = TextStyle.Default,
|
||||
autoWarpMode = false,
|
||||
originMode = false,
|
||||
graphicCharacterSet = GraphicCharacterSet()
|
||||
)
|
||||
}
|
||||
|
||||
terminalModel.setData(DataKey.OriginMode, cursorStore.originMode)
|
||||
terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle)
|
||||
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
|
||||
terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet)
|
||||
|
||||
val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion()
|
||||
else ScrollingRegion(top = 1, bottom = terminalModel.getRows())
|
||||
var y = cursorStore.position.y
|
||||
if (y < region.top) {
|
||||
y = 1
|
||||
} else if (y > region.bottom) {
|
||||
y = region.bottom
|
||||
}
|
||||
|
||||
terminal.getCursorModel().move(row = y, col = cursorStore.position.x)
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Restore Cursor (DECRC). $cursorStore")
|
||||
}
|
||||
CursorStoreStores.restore(terminal)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.event.KeyEvent
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataListener {
|
||||
|
||||
private val mapping = mutableMapOf<TerminalKeyEvent, String>()
|
||||
private val nothing = String()
|
||||
private val nothing = StringUtils.EMPTY
|
||||
|
||||
init {
|
||||
|
||||
@@ -27,6 +28,7 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
|
||||
|
||||
configureLeftRight()
|
||||
|
||||
// Ctrl + C
|
||||
putCode(TerminalKeyEvent(keyCode = 8), String(byteArrayOf(127)))
|
||||
|
||||
// Enter
|
||||
@@ -38,15 +40,15 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
|
||||
|
||||
|
||||
// Page Up
|
||||
putCode(TerminalKeyEvent(keyCode = 0x21), encode = "${ControlCharacters.ESC}[5~")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_PAGE_UP), encode = "${ControlCharacters.ESC}[5~")
|
||||
// Page Down
|
||||
putCode(TerminalKeyEvent(keyCode = 0x22), encode = "${ControlCharacters.ESC}[6~")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_PAGE_DOWN), encode = "${ControlCharacters.ESC}[6~")
|
||||
|
||||
|
||||
// Insert
|
||||
putCode(TerminalKeyEvent(keyCode = 0x9B), encode = "${ControlCharacters.ESC}[2~")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_INSERT), encode = "${ControlCharacters.ESC}[2~")
|
||||
// Delete
|
||||
putCode(TerminalKeyEvent(keyCode = 0x7F), encode = "${ControlCharacters.ESC}[3~")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_DELETE), encode = "${ControlCharacters.ESC}[3~")
|
||||
|
||||
// Function Keys
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_F1), encode = "${ControlCharacters.ESC}OP")
|
||||
@@ -84,26 +86,29 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
|
||||
|
||||
fun arrowKeysApplicationSequences() {
|
||||
// Up
|
||||
putCode(TerminalKeyEvent(keyCode = 0x26), encode = "${ControlCharacters.ESC}OA")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_UP), encode = "${ControlCharacters.ESC}OA")
|
||||
// Down
|
||||
putCode(TerminalKeyEvent(keyCode = 0x28), encode = "${ControlCharacters.ESC}OB")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_DOWN), encode = "${ControlCharacters.ESC}OB")
|
||||
// Left
|
||||
putCode(TerminalKeyEvent(keyCode = 0x25), encode = "${ControlCharacters.ESC}OD")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_LEFT), encode = "${ControlCharacters.ESC}OD")
|
||||
// Right
|
||||
putCode(TerminalKeyEvent(keyCode = 0x27), encode = "${ControlCharacters.ESC}OC")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_RIGHT), encode = "${ControlCharacters.ESC}OC")
|
||||
}
|
||||
|
||||
fun arrowKeysAnsiCursorSequences() {
|
||||
// Up
|
||||
putCode(TerminalKeyEvent(keyCode = 0x26), encode = "${ControlCharacters.ESC}[A")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_UP), encode = "${ControlCharacters.ESC}[A")
|
||||
// Down
|
||||
putCode(TerminalKeyEvent(keyCode = 0x28), encode = "${ControlCharacters.ESC}[B")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_DOWN), encode = "${ControlCharacters.ESC}[B")
|
||||
// Left
|
||||
putCode(TerminalKeyEvent(keyCode = 0x25), encode = "${ControlCharacters.ESC}[D")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_LEFT), encode = "${ControlCharacters.ESC}[D")
|
||||
// Right
|
||||
putCode(TerminalKeyEvent(keyCode = 0x27), encode = "${ControlCharacters.ESC}[C")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_RIGHT), encode = "${ControlCharacters.ESC}[C")
|
||||
}
|
||||
|
||||
/**
|
||||
* Alt + Left/Right
|
||||
*/
|
||||
fun configureLeftRight() {
|
||||
if (SystemInfo.isMacOS) {
|
||||
putCode(
|
||||
@@ -141,32 +146,32 @@ open class KeyEncoderImpl(private val terminal: Terminal) : KeyEncoder, DataList
|
||||
|
||||
fun keypadApplicationSequences() {
|
||||
// Up
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE0), encode = "${ControlCharacters.ESC}OA")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_UP), encode = "${ControlCharacters.ESC}OA")
|
||||
// Down
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE1), encode = "${ControlCharacters.ESC}OB")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_DOWN), encode = "${ControlCharacters.ESC}OB")
|
||||
// Left
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE2), encode = "${ControlCharacters.ESC}OD")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_LEFT), encode = "${ControlCharacters.ESC}OD")
|
||||
// Right
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE3), encode = "${ControlCharacters.ESC}OC")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_RIGHT), encode = "${ControlCharacters.ESC}OC")
|
||||
// Home
|
||||
putCode(TerminalKeyEvent(keyCode = 0x24), encode = "${ControlCharacters.ESC}OH")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_HOME), encode = "${ControlCharacters.ESC}OH")
|
||||
// End
|
||||
putCode(TerminalKeyEvent(keyCode = 0x23), encode = "${ControlCharacters.ESC}OF")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_END), encode = "${ControlCharacters.ESC}OF")
|
||||
}
|
||||
|
||||
fun keypadAnsiSequences() {
|
||||
// Up
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE0), encode = "${ControlCharacters.ESC}[A")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_UP), encode = "${ControlCharacters.ESC}[A")
|
||||
// Down
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE1), encode = "${ControlCharacters.ESC}[B")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_DOWN), encode = "${ControlCharacters.ESC}[B")
|
||||
// Left
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE2), encode = "${ControlCharacters.ESC}[D")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_LEFT), encode = "${ControlCharacters.ESC}[D")
|
||||
// Right
|
||||
putCode(TerminalKeyEvent(keyCode = 0xE3), encode = "${ControlCharacters.ESC}[C")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_KP_RIGHT), encode = "${ControlCharacters.ESC}[C")
|
||||
// Home
|
||||
putCode(TerminalKeyEvent(keyCode = 0x24), encode = "${ControlCharacters.ESC}[H")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_HOME), encode = "${ControlCharacters.ESC}[H")
|
||||
// End
|
||||
putCode(TerminalKeyEvent(keyCode = 0x23), encode = "${ControlCharacters.ESC}[F")
|
||||
putCode(TerminalKeyEvent(keyCode = KeyEvent.VK_END), encode = "${ControlCharacters.ESC}[F")
|
||||
}
|
||||
|
||||
override fun onChanged(key: DataKey<*>, data: Any) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import java.awt.datatransfer.StringSelection
|
||||
|
||||
class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader) :
|
||||
AbstractProcessor(terminal, reader) {
|
||||
private val args = StringBuilder()
|
||||
private val systemCommandSequence = SystemCommandSequence()
|
||||
private val colorPalette get() = terminal.getTerminalModel().getColorPalette()
|
||||
|
||||
companion object {
|
||||
@@ -20,14 +20,7 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
|
||||
|
||||
do {
|
||||
|
||||
val c = reader.read()
|
||||
args.append(c)
|
||||
if (c == ControlCharacters.BEL || c == ControlCharacters.ST) {
|
||||
args.deleteAt(args.lastIndex)
|
||||
break
|
||||
} else if (c == '\\' && args.length >= 2 && args[args.length - 2] == ControlCharacters.ESC) {
|
||||
args.deleteAt(args.lastIndex)
|
||||
args.deleteAt(args.lastIndex)
|
||||
if (systemCommandSequence.process(reader.read())) {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -42,7 +35,7 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
|
||||
// process osc
|
||||
processOperatingSystemCommandProcessor()
|
||||
|
||||
args.clear()
|
||||
systemCommandSequence.reset()
|
||||
|
||||
return TerminalState.READY
|
||||
}
|
||||
@@ -52,6 +45,7 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
|
||||
* https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
|
||||
*/
|
||||
private fun processOperatingSystemCommandProcessor() {
|
||||
val args = systemCommandSequence.getCommand()
|
||||
val idx = args.indexOfFirst { it == ';' }
|
||||
if (idx == -1) {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
|
||||
|
||||
interface PtyConnector {
|
||||
@@ -15,15 +16,18 @@ interface PtyConnector {
|
||||
*/
|
||||
fun write(buffer: ByteArray, offset: Int, len: Int)
|
||||
|
||||
/**
|
||||
* 写入数组。
|
||||
*
|
||||
* 如果要写入 String 字符串,请通过 [getCharset] 编码。
|
||||
*/
|
||||
fun write(buffer: ByteArray) {
|
||||
write(buffer, 0, buffer.size)
|
||||
}
|
||||
|
||||
fun write(buffer: String) {
|
||||
if (buffer.isEmpty()) return
|
||||
write(buffer.toByteArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入单个 Int
|
||||
*/
|
||||
fun write(buffer: Int) {
|
||||
write(ByteBuffer.allocate(Integer.BYTES).putInt(buffer).flip().array())
|
||||
}
|
||||
@@ -43,4 +47,8 @@ interface PtyConnector {
|
||||
*/
|
||||
fun close()
|
||||
|
||||
/**
|
||||
* 编码
|
||||
*/
|
||||
fun getCharset(): Charset = Charsets.UTF_8
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import java.nio.charset.Charset
|
||||
|
||||
open class PtyConnectorDelegate(
|
||||
@Volatile var ptyConnector: PtyConnector? = null
|
||||
) : PtyConnector {
|
||||
@@ -26,5 +28,7 @@ open class PtyConnectorDelegate(
|
||||
ptyConnector = null
|
||||
}
|
||||
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return ptyConnector?.getCharset() ?: super.getCharset()
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,6 @@ class PtyProcessConnector(private val process: PtyProcess, private val charset:
|
||||
output.flush()
|
||||
}
|
||||
|
||||
override fun write(buffer: String) {
|
||||
write(buffer.toByteArray(charset))
|
||||
}
|
||||
|
||||
override fun resize(rows: Int, cols: Int) {
|
||||
process.winSize = WinSize(cols, rows)
|
||||
@@ -38,5 +35,7 @@ class PtyProcessConnector(private val process: PtyProcess, private val charset:
|
||||
process.destroyForcibly()
|
||||
}
|
||||
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return charset
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package app.termora.terminal
|
||||
|
||||
class SystemCommandSequence {
|
||||
private var isTerminated = false
|
||||
private val command = StringBuilder()
|
||||
|
||||
/**
|
||||
* @return 返回 true 表示处理完毕
|
||||
*/
|
||||
fun process(c: Char): Boolean {
|
||||
|
||||
if (isTerminated) {
|
||||
throw UnsupportedOperationException("Cannot be processed, call the reset method")
|
||||
}
|
||||
|
||||
command.append(c)
|
||||
if (c == ControlCharacters.BEL || c == ControlCharacters.ST) {
|
||||
command.deleteAt(command.lastIndex)
|
||||
isTerminated = true
|
||||
} else if (c == '\\' && command.length >= 2 && command[command.length - 2] == ControlCharacters.ESC) {
|
||||
command.deleteAt(command.lastIndex)
|
||||
command.deleteAt(command.lastIndex)
|
||||
isTerminated = true
|
||||
}
|
||||
|
||||
return isTerminated
|
||||
}
|
||||
|
||||
fun getCommand(): String {
|
||||
return command.toString()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
isTerminated = false
|
||||
command.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora.terminal
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Toolkit
|
||||
import kotlin.reflect.cast
|
||||
@@ -8,7 +9,7 @@ open class TerminalModelImpl(private val terminal: Terminal) : TerminalModel {
|
||||
private var rows: Int = 27
|
||||
private var cols: Int = 80
|
||||
private val data = mutableMapOf<DataKey<*>, Any>()
|
||||
private val listeners = mutableListOf<DataListener>()
|
||||
private var listeners = emptyArray<DataListener>()
|
||||
private val colorPalette = ColorPaletteImpl(terminal)
|
||||
|
||||
companion object {
|
||||
@@ -92,11 +93,11 @@ open class TerminalModelImpl(private val terminal: Terminal) : TerminalModel {
|
||||
}
|
||||
|
||||
override fun addDataListener(listener: DataListener) {
|
||||
listeners.add(listener)
|
||||
listeners = ArrayUtils.add(listeners, listener)
|
||||
}
|
||||
|
||||
override fun removeDataListener(listener: DataListener) {
|
||||
listeners.remove(listener)
|
||||
listeners = ArrayUtils.removeElement(listeners, listener)
|
||||
}
|
||||
|
||||
override fun bell() {
|
||||
@@ -129,9 +130,8 @@ open class TerminalModelImpl(private val terminal: Terminal) : TerminalModel {
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
protected fun <T : Any> fireDataChanged(key: DataKey<T>, data: T) {
|
||||
val size = listeners.size
|
||||
for (i in 0 until size) {
|
||||
listeners.getOrNull(i)?.onChanged(key, data)
|
||||
for (listener in listeners) {
|
||||
listener.onChanged(key, data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ private class MyProcessor(private val terminal: Terminal, reader: TerminalReader
|
||||
TerminalState.CSI to ControlSequenceIntroducerProcessor(terminal, reader),
|
||||
TerminalState.OSC to OperatingSystemCommandProcessor(terminal, reader),
|
||||
TerminalState.ESC_LPAREN to EscapeDesignateCharacterSetProcessor(terminal, reader),
|
||||
TerminalState.DCS to DeviceControlProcessor(terminal),
|
||||
TerminalState.DCS to DeviceControlStringProcessor(terminal, reader),
|
||||
TerminalState.Text to TextProcessor(terminal, reader),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package app.termora.terminal.panel
|
||||
|
||||
import app.termora.Database
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.assertEventDispatchThread
|
||||
import app.termora.Database
|
||||
import app.termora.terminal.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
@@ -49,6 +49,8 @@ class TerminalDisplay(
|
||||
init {
|
||||
terminalPanel.addTerminalPaintListener(toaster)
|
||||
putClientProperty(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON)
|
||||
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR)
|
||||
}
|
||||
|
||||
override fun paint(g: Graphics) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora.terminal.panel
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.actions.DataProviders
|
||||
@@ -30,7 +31,7 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnector) :
|
||||
JPanel(BorderLayout()), DataProvider {
|
||||
JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
|
||||
companion object {
|
||||
val Debug = DataKey(Boolean::class)
|
||||
@@ -298,7 +299,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
|
||||
// 输入法提交
|
||||
if (committedCharacterCount > 0) {
|
||||
ptyConnector.write(sb.toString())
|
||||
ptyConnector.write(sb.toString().toByteArray(ptyConnector.getCharset()))
|
||||
} else {
|
||||
val breakIterator = BreakIterator.getCharacterInstance()
|
||||
val chars = mutableListOf<Char>()
|
||||
@@ -397,16 +398,20 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
* 执行粘贴操作
|
||||
*/
|
||||
fun paste(text: String) {
|
||||
val content = if (SystemInfo.isWindows) {
|
||||
text.replace("${ControlCharacters.CR}${ControlCharacters.LF}", "${ControlCharacters.LF}")
|
||||
} else {
|
||||
text.replace(ControlCharacters.LF, ControlCharacters.CR)
|
||||
var content = text
|
||||
if (!SystemInfo.isWindows) {
|
||||
content = content.replace("\r\n", "\n")
|
||||
}
|
||||
content = content.replace('\n', '\r')
|
||||
|
||||
if (terminal.getTerminalModel().getData(DataKey.BracketedPasteMode, false)) {
|
||||
ptyConnector.write("${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~")
|
||||
ptyConnector.write(
|
||||
"${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~".toByteArray(
|
||||
ptyConnector.getCharset()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
ptyConnector.write(content)
|
||||
ptyConnector.write(content.toByteArray(ptyConnector.getCharset()))
|
||||
}
|
||||
|
||||
terminal.getScrollingModel().scrollToRow(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.termora.terminal.panel
|
||||
|
||||
import app.termora.keymap.KeyShortcut
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.Terminal
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
@@ -12,8 +14,9 @@ class TerminalPanelKeyAdapter(
|
||||
private val terminalPanel: TerminalPanel,
|
||||
private val terminal: Terminal,
|
||||
private val ptyConnector: PtyConnector
|
||||
) :
|
||||
KeyAdapter() {
|
||||
) : KeyAdapter() {
|
||||
|
||||
private val activeKeymap get() = KeymapManager.getInstance().getActiveKeymap()
|
||||
|
||||
override fun keyTyped(e: KeyEvent) {
|
||||
if (Character.isISOControl(e.keyChar)) {
|
||||
@@ -21,7 +24,7 @@ class TerminalPanelKeyAdapter(
|
||||
}
|
||||
|
||||
terminal.getSelectionModel().clearSelection()
|
||||
ptyConnector.write("${e.keyChar}")
|
||||
ptyConnector.write("${e.keyChar}".toByteArray(ptyConnector.getCharset()))
|
||||
terminal.getScrollingModel().scrollTo(Int.MAX_VALUE)
|
||||
|
||||
}
|
||||
@@ -44,7 +47,7 @@ class TerminalPanelKeyAdapter(
|
||||
|
||||
val encode = terminal.getKeyEncoder().encode(AWTTerminalKeyEvent(e))
|
||||
if (encode.isNotEmpty()) {
|
||||
ptyConnector.write(encode)
|
||||
ptyConnector.write(encode.toByteArray(ptyConnector.getCharset()))
|
||||
}
|
||||
|
||||
// https://github.com/TermoraDev/termora/issues/52
|
||||
@@ -52,11 +55,16 @@ class TerminalPanelKeyAdapter(
|
||||
return
|
||||
}
|
||||
|
||||
if (Character.isISOControl(e.keyChar) && isCtrlPressedOnly(e)) {
|
||||
// 如果命中了全局快捷键,那么不处理
|
||||
if (keyStroke.modifiers != 0 && activeKeymap.getActionIds(KeyShortcut(keyStroke)).isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Character.isISOControl(e.keyChar)) {
|
||||
terminal.getSelectionModel().clearSelection()
|
||||
// 如果不为空表示已经发送过了,所以这里为空的时候再发送
|
||||
if (encode.isEmpty()) {
|
||||
ptyConnector.write("${e.keyChar}")
|
||||
ptyConnector.write("${e.keyChar}".toByteArray(ptyConnector.getCharset()))
|
||||
}
|
||||
terminal.getScrollingModel().scrollTo(Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
@@ -70,9 +70,9 @@ class TerminalPanelMouseTrackingAdapter(
|
||||
val encode = terminal.getKeyEncoder()
|
||||
.encode(TerminalKeyEvent(if (e.wheelRotation < 0) KeyEvent.VK_UP else KeyEvent.VK_DOWN))
|
||||
if (encode.isBlank()) return
|
||||
|
||||
val bytes = encode.toByteArray(ptyConnector.getCharset())
|
||||
for (i in 0 until abs(unitsToScroll)) {
|
||||
ptyConnector.write(encode)
|
||||
ptyConnector.write(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import java.io.FileNotFoundException
|
||||
import java.nio.file.Files
|
||||
import javax.swing.Icon
|
||||
|
||||
class LogViewerTerminalTab(windowScope: WindowScope, private val file: File) : PtyHostTerminalTab(
|
||||
class LogViewerTerminalTab(
|
||||
windowScope: WindowScope,
|
||||
private val file: File,
|
||||
) : PtyHostTerminalTab(
|
||||
windowScope,
|
||||
Host(
|
||||
name = file.name,
|
||||
|
||||
@@ -25,9 +25,8 @@ import java.awt.Component
|
||||
import java.awt.Desktop
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.dnd.DnDConstants
|
||||
import java.awt.dnd.DropTarget
|
||||
import java.awt.dnd.DropTargetDropEvent
|
||||
import java.awt.datatransfer.Transferable
|
||||
import java.awt.datatransfer.UnsupportedFlavorException
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
@@ -80,6 +79,8 @@ class FileSystemPanel(
|
||||
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString())
|
||||
|
||||
table.setUI(FlatTableUI())
|
||||
table.dragEnabled = true
|
||||
table.dropMode = DropMode.INSERT_ROWS
|
||||
table.rowHeight = UIManager.getInt("Table.rowHeight")
|
||||
table.autoResizeMode = JTable.AUTO_RESIZE_OFF
|
||||
table.fillsViewportHeight = true
|
||||
@@ -231,17 +232,45 @@ class FileSystemPanel(
|
||||
}
|
||||
})
|
||||
|
||||
// 本地文件系统不支持本地拖拽进去
|
||||
if (!tableModel.isLocalFileSystem) {
|
||||
table.dropTarget = object : DropTarget() {
|
||||
override fun drop(dtde: DropTargetDropEvent) {
|
||||
dtde.acceptDrop(DnDConstants.ACTION_COPY)
|
||||
val files = dtde.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
|
||||
if (files.isEmpty()) return
|
||||
copyLocalFileToFileSystem(files.filterIsInstance<File>())
|
||||
|
||||
table.transferHandler = object : TransferHandler() {
|
||||
override fun canImport(support: TransferSupport): Boolean {
|
||||
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
|
||||
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
|
||||
return data is FileSystemTableRowTransferable && data.fileSystemPanel != this@FileSystemPanel
|
||||
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||
return !tableModel.isLocalFileSystem
|
||||
}
|
||||
}.apply {
|
||||
this.defaultActions = DnDConstants.ACTION_COPY
|
||||
return false
|
||||
}
|
||||
|
||||
override fun importData(comp: JComponent?, t: Transferable): Boolean {
|
||||
if (t.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
|
||||
val data = t.getTransferData(FileSystemTableRowTransferable.dataFlavor)
|
||||
if (data !is FileSystemTableRowTransferable) {
|
||||
return false
|
||||
}
|
||||
data.fileSystemPanel.transport(data.paths)
|
||||
return true
|
||||
} else if (t.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||
val files = t.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
|
||||
if (files.isEmpty()) return false
|
||||
copyLocalFileToFileSystem(files.filterIsInstance<File>())
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getSourceActions(c: JComponent?): Int {
|
||||
return COPY
|
||||
}
|
||||
|
||||
override fun createTransferable(c: JComponent?): Transferable? {
|
||||
val paths = table.selectedRows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
|
||||
if (paths.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return FileSystemTableRowTransferable(this@FileSystemPanel, paths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,4 +867,28 @@ class FileSystemPanel(
|
||||
}
|
||||
|
||||
|
||||
private class FileSystemTableRowTransferable(
|
||||
val fileSystemPanel: FileSystemPanel,
|
||||
val paths: List<FileSystemTableModel.CacheablePath>
|
||||
) : Transferable {
|
||||
companion object {
|
||||
val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable")
|
||||
}
|
||||
|
||||
override fun getTransferDataFlavors(): Array<DataFlavor> {
|
||||
return arrayOf(dataFlavor)
|
||||
}
|
||||
|
||||
override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean {
|
||||
return flavor == dataFlavor
|
||||
}
|
||||
|
||||
override fun getTransferData(flavor: DataFlavor?): Any {
|
||||
if (flavor != dataFlavor) {
|
||||
throw UnsupportedFlavorException(flavor)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,10 @@ class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableMod
|
||||
when (column) {
|
||||
COLUMN_NAME -> path
|
||||
COLUMN_FILE_SIZE -> if (path.isDirectory) StringUtils.EMPTY else formatBytes(path.fileSize)
|
||||
COLUMN_TYPE -> if (path.isDirectory) I18n.getString("termora.transport.table.type.folder") else path.extension
|
||||
COLUMN_TYPE -> if (path.isDirectory) I18n.getString("termora.transport.table.type.folder")
|
||||
else if (path.isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
|
||||
else path.extension
|
||||
|
||||
COLUMN_LAST_MODIFIED_TIME -> DateFormatUtils.format(Date(path.lastModifiedTime), "yyyy/MM/dd HH:mm")
|
||||
|
||||
// 如果是本地的并且还是Windows系统
|
||||
@@ -173,6 +176,7 @@ class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableMod
|
||||
val extension by lazy { path.extension }
|
||||
|
||||
open val isDirectory by lazy { path.isDirectory() }
|
||||
open val isSymbolicLink by lazy { path.isSymbolicLink() }
|
||||
open val isHidden by lazy { fileName != ".." && path.isHidden() }
|
||||
open val fileSize by lazy { path.fileSize() }
|
||||
open val lastModifiedTime by lazy { Files.getLastModifiedTime(path).toMillis() }
|
||||
@@ -227,8 +231,10 @@ class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableMod
|
||||
}
|
||||
}
|
||||
|
||||
override val isDirectory: Boolean
|
||||
get() = attributes.isDirectory
|
||||
override val isDirectory by lazy { attributes.isDirectory || (isSymbolicLink && Files.isDirectory(path)) }
|
||||
|
||||
override val isSymbolicLink: Boolean
|
||||
get() = attributes.isSymbolicLink
|
||||
|
||||
override val isHidden: Boolean
|
||||
get() = fileName != ".." && fileName.startsWith(".")
|
||||
|
||||
@@ -11,6 +11,8 @@ termora.date-format=MM/dd/yyyy hh:mm:ss a
|
||||
termora.finder=Finder
|
||||
termora.folder=Folder
|
||||
termora.explorer=Explorer
|
||||
termora.quit-confirm=Quit {0}?
|
||||
|
||||
|
||||
# update
|
||||
termora.update.title=New version
|
||||
@@ -61,16 +63,22 @@ termora.settings.terminal.font=Font
|
||||
termora.settings.terminal.size=Size
|
||||
termora.settings.terminal.max-rows=Max rows
|
||||
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.local-shell=Local shell
|
||||
termora.settings.terminal.auto-close-tab=Auto Close Tab
|
||||
termora.settings.terminal.auto-close-tab-description=Automatically close the tab when the terminal is disconnected normally
|
||||
|
||||
termora.settings.sync=Sync
|
||||
termora.settings.sync.push=Push
|
||||
termora.settings.sync.push-warning=Pushing will overwrite the configuration. It is recommended to pull before pushing
|
||||
termora.settings.sync.pull=Pull
|
||||
termora.settings.sync.done=Synchronized data successfully
|
||||
termora.settings.sync.export=Export
|
||||
termora.settings.sync.export=${termora.keymgr.export}
|
||||
termora.settings.sync.import=${termora.keymgr.import}
|
||||
termora.settings.sync.import.file-too-large=The file is too large
|
||||
termora.settings.sync.import.successful=Import data successfully
|
||||
termora.settings.sync.export-done=The export was successful
|
||||
termora.settings.sync.export-done-open-folder=The export was successful. Do you want to open the folder?
|
||||
termora.settings.sync.range=Range
|
||||
@@ -80,6 +88,7 @@ termora.settings.sync.last-sync-time=Last sync time
|
||||
termora.settings.sync.gist=Gist
|
||||
termora.settings.sync.token=Token
|
||||
termora.settings.sync.type=Type
|
||||
termora.settings.sync.webdav.help=WebDAV storage address, e.g. https://yourhost/webdav/termora.json
|
||||
|
||||
termora.settings.about=About
|
||||
termora.settings.about.author=Author
|
||||
@@ -106,10 +115,13 @@ 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.open=Open
|
||||
termora.welcome.contextmenu.open-in-new-window=${termora.tabbed.contextmenu.open-in-new-window}
|
||||
termora.welcome.contextmenu.copy=${termora.copy}
|
||||
termora.welcome.contextmenu.remove=${termora.remove}
|
||||
termora.welcome.contextmenu.rename=Rename
|
||||
@@ -141,6 +153,14 @@ termora.new-host.terminal.heartbeat-interval=Heartbeat Interval
|
||||
termora.new-host.terminal.startup-commands=Startup Command
|
||||
termora.new-host.terminal.env=Environment
|
||||
|
||||
termora.new-host.serial=Serial
|
||||
termora.new-host.serial.port=Port
|
||||
termora.new-host.serial.baud-rate=Baud rate
|
||||
termora.new-host.serial.data-bits=Data bits
|
||||
termora.new-host.serial.parity=Parity
|
||||
termora.new-host.serial.stop-bits=Stop bits
|
||||
termora.new-host.serial.flow-control=Flow control
|
||||
|
||||
termora.new-host.tunneling=Tunneling
|
||||
termora.new-host.tunneling.table.name=Name
|
||||
termora.new-host.tunneling.table.type=Type
|
||||
@@ -168,6 +188,12 @@ termora.keymgr.table.type=Type
|
||||
termora.keymgr.table.length=Length
|
||||
termora.keymgr.table.remark=Description
|
||||
|
||||
termora.keymgr.ssh-copy-id.number=Number of hosts [{0}] Number of public keys [{1}]
|
||||
termora.keymgr.ssh-copy-id.successful=${termora.terminal.copied}
|
||||
termora.keymgr.ssh-copy-id.failed=Copy Failure
|
||||
termora.keymgr.ssh-copy-id.end=End of public key copying
|
||||
|
||||
|
||||
# Tabbed
|
||||
termora.tabbed.contextmenu.rename=Rename
|
||||
termora.tabbed.contextmenu.clone=Clone
|
||||
@@ -221,6 +247,7 @@ termora.transport.bookmarks.down=Down
|
||||
termora.transport.table.filename=Filename
|
||||
termora.transport.table.type=Type
|
||||
termora.transport.table.type.folder=${termora.welcome.contextmenu.new.folder}
|
||||
termora.transport.table.type.symbolic-link=Symbolic Link
|
||||
termora.transport.table.size=Size
|
||||
termora.transport.table.modified-time=Modified
|
||||
termora.transport.table.permissions=Permissions
|
||||
@@ -298,6 +325,8 @@ termora.actions.switch-tab=Switch to specific Tab [1..9]
|
||||
# Terminal
|
||||
termora.terminal.size=Size: {0} x {1}
|
||||
termora.terminal.copied=Copied
|
||||
termora.terminal.channel-disconnected=Channel has been disconnected.\u0020
|
||||
termora.terminal.channel-reconnect=Type {0} to reconnect.
|
||||
|
||||
|
||||
# zmodem
|
||||
|
||||
@@ -10,6 +10,7 @@ termora.date-format=yyyy-MM-dd HH:mm:ss
|
||||
termora.finder=访达
|
||||
termora.folder=文件夹
|
||||
termora.explorer=文件管理器
|
||||
termora.quit-confirm=你要退出 {0} 吗?
|
||||
|
||||
# update
|
||||
termora.update.title=新版本
|
||||
@@ -59,31 +60,38 @@ 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=字体
|
||||
termora.settings.terminal.size=大小
|
||||
termora.settings.terminal.max-rows=最大行数
|
||||
termora.settings.terminal.debug=调试模式
|
||||
termora.settings.terminal.beep=蜂鸣声
|
||||
termora.settings.terminal.select-copy=选中复制
|
||||
termora.settings.terminal.cursor-style=光标样式
|
||||
termora.settings.terminal.local-shell=本地终端
|
||||
termora.settings.terminal.auto-close-tab=自动关闭标签
|
||||
termora.settings.terminal.auto-close-tab-description=当终端正常断开连接时自动关闭标签页
|
||||
|
||||
|
||||
termora.settings.sync=同步
|
||||
termora.settings.sync.push=推送
|
||||
termora.settings.sync.push-warning=推送将覆盖已有配置,建议先拉取再推送
|
||||
termora.settings.sync.pull=拉取
|
||||
termora.settings.sync.export=导出
|
||||
termora.settings.sync.export-done=导出成功
|
||||
termora.settings.sync.export-done-open-folder=导出成功,是否需要打开所在文件夹?
|
||||
termora.settings.sync.range=范围
|
||||
termora.settings.sync.range.keys=我的密钥
|
||||
termora.settings.sync.last-sync-time=最后同步时间
|
||||
termora.settings.sync.done=同步数据成功
|
||||
termora.settings.sync.import.file-too-large=文件太大
|
||||
termora.settings.sync.import.successful=导入数据成功
|
||||
termora.settings.sync.gist=片段
|
||||
termora.settings.sync.token=令牌
|
||||
termora.settings.sync.type=类型
|
||||
termora.settings.sync.webdav.help=WebDAV 的存储地址,例如:https://yourhost/webdav/termora.json
|
||||
|
||||
termora.settings.about=关于
|
||||
termora.settings.about.author=作者
|
||||
@@ -131,6 +139,14 @@ termora.new-host.terminal.startup-commands=启动命令
|
||||
termora.new-host.terminal.env=环境
|
||||
|
||||
|
||||
termora.new-host.serial=串口
|
||||
termora.new-host.serial.port=端口
|
||||
termora.new-host.serial.baud-rate=波特率
|
||||
termora.new-host.serial.data-bits=数据位
|
||||
termora.new-host.serial.parity=校验位
|
||||
termora.new-host.serial.stop-bits=停止位
|
||||
termora.new-host.serial.flow-control=流控
|
||||
|
||||
|
||||
termora.new-host.test-connection=测试连接
|
||||
termora.new-host.test-connection-successful=连接成功
|
||||
@@ -158,6 +174,10 @@ termora.keymgr.table.type=类型
|
||||
termora.keymgr.table.length=长度
|
||||
termora.keymgr.table.remark=备注
|
||||
|
||||
termora.keymgr.ssh-copy-id.number=主机数量 [{0}] 公钥数量 [{1}]
|
||||
termora.keymgr.ssh-copy-id.failed=复制失败
|
||||
termora.keymgr.ssh-copy-id.end=复制公钥结束
|
||||
|
||||
# Tools
|
||||
termora.tools.multiple=将命令发送到所有会话
|
||||
|
||||
@@ -217,6 +237,7 @@ termora.transport.bookmarks.down=下移
|
||||
termora.transport.table.filename=文件名
|
||||
termora.transport.table.type=类型
|
||||
termora.transport.table.size=大小
|
||||
termora.transport.table.type.symbolic-link=软链接
|
||||
termora.transport.table.modified-time=修改时间
|
||||
termora.transport.table.permissions=权限
|
||||
termora.transport.table.owner=所有者
|
||||
@@ -273,6 +294,8 @@ termora.toolbar.customize-toolbar=自定义工具栏...
|
||||
|
||||
termora.terminal.size=大小: {0} x {1}
|
||||
termora.terminal.copied=已复制
|
||||
termora.terminal.channel-disconnected=终端断开连接,
|
||||
termora.terminal.channel-reconnect=按 {0} 进行重连。
|
||||
|
||||
|
||||
# Actions
|
||||
|
||||
@@ -9,6 +9,7 @@ termora.date-format=yyyy/MM/dd HH:mm:ss
|
||||
termora.finder=訪達
|
||||
termora.folder=資料夾
|
||||
termora.explorer=檔案管理器
|
||||
termora.quit-confirm=你要退出 {0} 嗎?
|
||||
|
||||
# update
|
||||
termora.update.title=新版本
|
||||
@@ -64,30 +65,37 @@ 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=字體
|
||||
termora.settings.terminal.size=大小
|
||||
termora.settings.terminal.max-rows=最大行數
|
||||
termora.settings.terminal.debug=偵錯模式
|
||||
termora.settings.terminal.beep=蜂鳴聲
|
||||
termora.settings.terminal.select-copy=選取複製
|
||||
termora.settings.terminal.cursor-style=遊標風格
|
||||
termora.settings.terminal.local-shell=本地端
|
||||
termora.settings.terminal.auto-close-tab=自動關閉標籤
|
||||
termora.settings.terminal.auto-close-tab-description=當終端正常斷開連線時自動關閉標籤頁
|
||||
|
||||
termora.settings.sync=同步
|
||||
termora.settings.sync.push=推送
|
||||
termora.settings.sync.push-warning=推送將覆蓋先前的配置,建議先拉取再推送
|
||||
termora.settings.sync.pull=拉取
|
||||
termora.settings.sync.export=匯出
|
||||
termora.settings.sync.export-done=匯出成功
|
||||
termora.settings.sync.export-done-open-folder=匯出成功,是否需要打開所在資料夾?
|
||||
termora.settings.sync.range=範圍
|
||||
termora.settings.sync.range.keys=我的密鑰
|
||||
termora.settings.sync.last-sync-time=最後同步時間
|
||||
termora.settings.sync.done=同步資料成功
|
||||
termora.settings.sync.import.file-too-large=檔案太大
|
||||
termora.settings.sync.import.successful=導入資料成功
|
||||
termora.settings.sync.gist=片段
|
||||
termora.settings.sync.token=令牌
|
||||
termora.settings.sync.type=類型
|
||||
termora.settings.sync.webdav.help=WebDAV 的儲存位址,例如:https://yourhost/webdav/termora.json
|
||||
|
||||
termora.settings.about=關於
|
||||
termora.settings.about.author=作者
|
||||
@@ -129,6 +137,14 @@ termora.new-host.terminal.startup-commands=啟動命令
|
||||
termora.new-host.terminal.heartbeat-interval=心跳間隔
|
||||
termora.new-host.terminal.env=環境
|
||||
|
||||
termora.new-host.serial=串口
|
||||
termora.new-host.serial.port=端口
|
||||
termora.new-host.serial.baud-rate=波特率
|
||||
termora.new-host.serial.data-bits=資料位
|
||||
termora.new-host.serial.parity=校驗位
|
||||
termora.new-host.serial.stop-bits=停止位
|
||||
termora.new-host.serial.flow-control=流控
|
||||
|
||||
termora.new-host.test-connection=測試連接
|
||||
termora.new-host.test-connection-successful=連線成功
|
||||
|
||||
@@ -155,6 +171,10 @@ termora.keymgr.table.type=型別
|
||||
termora.keymgr.table.length=長度
|
||||
termora.keymgr.table.remark=備註
|
||||
|
||||
termora.keymgr.ssh-copy-id.number=主機數量 [{0}] 公鑰數量 [{1}]
|
||||
termora.keymgr.ssh-copy-id.failed=複製失敗
|
||||
termora.keymgr.ssh-copy-id.end=複製公鑰結束
|
||||
|
||||
# Tools
|
||||
termora.tools.multiple=將指令傳送到所有會話
|
||||
|
||||
@@ -211,6 +231,7 @@ termora.transport.bookmarks.down=下移
|
||||
termora.transport.table.filename=檔名
|
||||
termora.transport.table.type=類型
|
||||
termora.transport.table.size=大小
|
||||
termora.transport.table.type.symbolic-link=軟連結
|
||||
termora.transport.table.modified-time=修改時間
|
||||
termora.transport.table.permissions=權限
|
||||
termora.transport.table.owner=所有者
|
||||
@@ -255,6 +276,8 @@ termora.toolbar.customize-toolbar=自訂工具列...
|
||||
|
||||
termora.terminal.size=大小: {0} x {1}
|
||||
termora.terminal.copied=已複製
|
||||
termora.terminal.channel-disconnected=終端機連線中斷,
|
||||
termora.terminal.channel-reconnect=按 {0} 進行重新連線。
|
||||
|
||||
# Actions
|
||||
termora.actions.copy-from-terminal=從終端複製
|
||||
|
||||
4
src/main/resources/icons/plugin.svg
Normal file
4
src/main/resources/icons/plugin.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 4H7C5.34315 4 4 5.34315 4 7V9C4 10.6569 5.34315 12 7 12H11V11V10V6V5V4ZM12 5V4C12 3.44772 11.5523 3 11 3H7C5.13616 3 3.57006 4.27477 3.12602 6H1C0.447715 6 0 6.44772 0 7V9C0 9.55228 0.447715 10 1 10H3.12602C3.57006 11.7252 5.13616 13 7 13H11C11.5523 13 12 12.5523 12 12V11H15.5C15.7761 11 16 10.7761 16 10.5C16 10.2239 15.7761 10 15.5 10H12V6H15.5C15.7761 6 16 5.77614 16 5.5C16 5.22386 15.7761 5 15.5 5H12ZM3 7V9H1V7L3 7Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 724 B |
4
src/main/resources/icons/plugin_dark.svg
Normal file
4
src/main/resources/icons/plugin_dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 4H7C5.34315 4 4 5.34315 4 7V9C4 10.6569 5.34315 12 7 12H11V11V10V6V5V4ZM12 5V4C12 3.44772 11.5523 3 11 3H7C5.13616 3 3.57006 4.27477 3.12602 6H1C0.447715 6 0 6.44772 0 7V9C0 9.55228 0.447715 10 1 10H3.12602C3.57006 11.7252 5.13616 13 7 13H11C11.5523 13 12 12.5523 12 12V11H15.5C15.7761 11 16 10.7761 16 10.5C16 10.2239 15.7761 10 15.5 10H12V6H15.5C15.7761 6 16 5.77614 16 5.5C16 5.22386 15.7761 5 15.5 5H12ZM3 7V9H1V7L3 7Z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 724 B |
4
src/main/resources/icons/run.svg
Normal file
4
src/main/resources/icons/run.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5 7.13397C14.1667 7.51888 14.1667 8.48113 13.5 8.86603L4.5 14.0622C3.83333 14.4471 3 13.966 3 13.1962L3 2.80385C3 2.03405 3.83333 1.55292 4.5 1.93782L13.5 7.13397Z" stroke="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 427 B |
4
src/main/resources/icons/run_dark.svg
Normal file
4
src/main/resources/icons/run_dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5 7.13397C14.1667 7.51888 14.1667 8.48113 13.5 8.86603L4.5 14.0622C3.83333 14.4471 3 13.966 3 13.1962L3 2.80385C3 2.03405 3.83333 1.55292 4.5 1.93782L13.5 7.13397Z" stroke="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -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 && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \
|
||||
&& 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 \
|
||||
&& 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user