Compare commits

...

78 Commits
1.0.2 ... 1.0.7

Author SHA1 Message Date
hstyi
4e690bafed fix: macOS sign 2025-02-12 09:03:41 +08:00
hstyi
28b511e179 release: 1.0.7 2025-02-12 08:47:00 +08:00
hstyi
f010a13abd fix: center the MFA Code dialog (#199) 2025-02-11 16:38:09 +08:00
hstyi
4d80ffafdd fix: SSH password authentication reading local private key (#185) 2025-02-10 14:18:14 +08:00
hstyi
9aecd4d54b chore: browse 2025-02-10 14:01:59 +08:00
hstyi
65091823eb chore: copy-ssh-id i18n 2025-02-10 09:29:06 +08:00
hstyi
d17218bfbd chore: disable jpackage verbose 2025-02-09 17:07:08 +08:00
hstyi
724c5d2632 feat: copy public key display name (#186) 2025-02-09 16:56:10 +08:00
hstyi
6806c26028 feat: deprecate double-click Shift shortcut (#184) 2025-02-09 15:26:36 +08:00
hstyi
dcd89174c9 chore: new version dialog (#182) 2025-02-09 11:10:06 +08:00
hstyi
9a8707b8cb fix: encoding error 2025-02-09 10:25:43 +08:00
hstyi
28f1d05f06 feat: support ssh-copy-id (#177) 2025-02-08 12:32:18 +08:00
hstyi
54b044584e fix: line breaks 2025-02-08 11:01:59 +08:00
hstyi
ed39449a20 feat: GitHub actions macOS sign (#175) 2025-02-08 10:42:41 +08:00
hstyi
2ff3f3a352 chore: improve code 2025-02-08 09:18:14 +08:00
Mystery0 M
91e2e964a5 chore: move terminal disconnection messages to i18n (#168) 2025-02-08 09:15:21 +08:00
Mystery0 M
ca6cc68fed feat: support auto close terminal tab when ssh disconnected normally (#169) 2025-02-08 09:14:57 +08:00
hstyi
0962de7735 feat: winget releaser 2025-02-08 08:52:56 +08:00
hstyi
062b957fdb docs: README 2025-02-07 15:40:27 +08:00
hstyi
4efe4e5663 chore: opengl 2025-02-07 14:43:03 +08:00
hstyi
25eb6966c4 feat: external release to create a new window (#162) 2025-02-07 14:11:07 +08:00
hstyi
7843460020 feat: confirmation required to exit program 2025-02-07 13:50:34 +08:00
hstyi
1cbc6ba4a9 fix: color mismatch issue 2025-02-07 11:15:21 +08:00
hstyi
a43407bee8 feat: support drag and drop transfer (#157) 2025-02-07 11:15:01 +08:00
hstyi
05c4ec9af2 feat: support for turning off beep (#155) 2025-02-07 09:22:01 +08:00
hstyi
9236064293 docs: README 2025-02-06 16:03:52 +08:00
hstyi
e1955a371e feat: support for WebDAV (#150) 2025-02-06 16:03:25 +08:00
hstyi
58b56c4221 fix: drag and drop cancel 2025-02-06 11:30:09 +08:00
hstyi
1e461e529f release: 1.0.6 2025-02-06 10:52:15 +08:00
hstyi
38ada1207c chore: PasswordField allows copying and cutting 2025-02-06 10:05:18 +08:00
hstyi
8bd1b34f46 feat: support drag and drop to other windows (#145) 2025-02-06 09:51:45 +08:00
hstyi
4a513360e6 chore: text cursor 2025-02-05 14:19:02 +08:00
hstyi
22da5c1c37 chore: jbrsdk-21.0.6 2025-01-28 12:01:46 +08:00
hstyi
483582a8d1 feat: serial comm (#141) 2025-01-28 10:23:05 +08:00
hstyi
f037cbfac0 docs: README 2025-01-26 21:04:54 +08:00
hstyi
343d11482d release: 1.0.5 2025-01-26 20:35:18 +08:00
hstyi
7ef81a0116 feat: xterm DCS 2025-01-26 14:42:59 +08:00
hstyi
5df62d5d3e fix: possible invalid window creation 2025-01-26 10:24:55 +08:00
hstyi
7db650d69f feat: open in new window 2025-01-26 10:20:26 +08:00
hstyi
8d80d38d63 fix: missing exchange algorithms 2025-01-26 08:44:00 +08:00
hstyi
48f05d4cff feat: ssh insecure key exchange algorithms 2025-01-26 08:44:00 +08:00
hstyi
9a1cf387c0 fix: check-license 2025-01-25 21:20:08 +08:00
hstyi
8b7efefbdb fix: shift to close tabs causes switching 2025-01-25 21:11:54 +08:00
hstyi
75f21db325 fix: theAwtToolkitWindow 2025-01-25 18:06:01 +08:00
hstyi
b094c9d4ff chore: remove tabbed hover background 2025-01-25 17:03:06 +08:00
hstyi
0da3c95759 feat: press and hold Shift to close Tab (#131) 2025-01-25 16:24:36 +08:00
hstyi
fa79473ece chore: optimize key encoder 2025-01-25 15:03:52 +08:00
hstyi
86ccb5e0cc chore: LANG=en_US.UTF-8 2025-01-24 17:27:47 +08:00
hstyi
f385f4b277 feat: support import (#127) 2025-01-24 16:45:36 +08:00
hstyi
3d0ef2a331 feat: shortcut key prediction (#126) 2025-01-24 15:40:14 +08:00
hstyi
96999205a8 fix: host test connection 2025-01-24 10:55:42 +08:00
hstyi
ee7f3871eb fix: sftp symbolic link (#120) 2025-01-24 10:27:15 +08:00
hstyi
df2e9b0743 feat: support drag and drop sorting 2025-01-23 16:23:16 +08:00
hstyi
7964950149 fix: #112 2025-01-23 14:47:39 +08:00
hstyi
e2d77fe881 fix: key manager 2025-01-23 14:43:48 +08:00
hstyi
f5783c8587 feat: support more monospaced fonts 2025-01-23 11:26:24 +08:00
hstyi
346044b1ba fix: shortcut keys lead to terminal input 2025-01-23 11:26:12 +08:00
hstyi
aa6ec8dd43 feat: xcrun stapler staple 2025-01-23 10:17:18 +08:00
hstyi
e0e6a85a81 release: 1.0.4 2025-01-22 21:04:09 +08:00
hstyi
56ba107c87 fix: tab key not working 2025-01-22 20:58:10 +08:00
hstyi
0345848418 release: 1.0.3 2025-01-22 19:17:52 +08:00
hstyi
f1073fb53f fix: deadlock 2025-01-22 15:50:36 +08:00
hstyi
ce1924c422 docs: README 2025-01-22 15:47:36 +08:00
hstyi
d6de0922c6 feat: Device Status Report (DSR) 2025-01-22 15:32:10 +08:00
hstyi
d5157d3a16 feat: left right key 2025-01-22 15:01:40 +08:00
hstyi
63b27a2f83 feat: improved keyPair comboBox (#92) 2025-01-16 18:18:58 +08:00
hstyi
992015c8e5 feat: GitHub actions 2025-01-16 17:31:53 +08:00
hstyi
5d459f9b0d fix: FindEverywhereAction name (#89) 2025-01-16 16:09:23 +08:00
hstyi
88f20c4898 feat: SFTP supports pasting files for upload (#87) 2025-01-16 14:59:01 +08:00
hstyi
314c112d4b feat: Windows keyboard shortcut (#86) 2025-01-16 12:38:43 +08:00
hstyi
0cd818e9a0 feat: support fast reconnect 2025-01-15 23:02:05 +08:00
hstyi
0884486e91 feat: theme sync with OS (#82) 2025-01-15 22:24:19 +08:00
hstyi
e30316eab3 feat: support keymap sync 2025-01-15 20:05:26 +08:00
hstyi
d321e766b1 docs: README 2025-01-15 17:22:02 +08:00
hstyi
6aaed92f2c feat: SFTP 支持不显示隐藏文件 2025-01-15 16:52:58 +08:00
hstyi
21cf22906b fix: 修复可能导致内存泄漏的问题 2025-01-15 15:14:30 +08:00
hstyi
1476368673 feat: support jump hosts 2025-01-15 15:14:30 +08:00
hstyi
45ea822fd6 feat: 改进事件系统与全局快捷键 (#62) 2025-01-15 14:54:39 +08:00
200 changed files with 6781 additions and 1763 deletions

33
.github/workflows/linux-x86-64.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Linux x86-64
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# download jdk
- 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
uses: actions/setup-java@v4
with:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.6'
architecture: x64
# dist
- run: |
./gradlew dist --no-daemon
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-linux-x86-64
path: build/distributions/*.tar.gz

60
.github/workflows/osx-aarch64.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: macOS aarch64
on: [ push, pull_request ]
jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
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.6-osx-aarch64-b825.69.tar.gz
# install jdk
- name: Installing Java
uses: actions/setup-java@v4
with:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.6'
architecture: aarch64
# dist
- 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
uses: actions/upload-artifact@v4
with:
name: termora-osx-aarch64
path: build/distributions/*.dmg

61
.github/workflows/osx-x86-64.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: macOS x86-64
on: [ push, pull_request ]
jobs:
build:
runs-on: macos-13
steps:
- uses: actions/checkout@v4
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.6-osx-x64-b825.69.tar.gz
# install jdk
- name: Installing Java
uses: actions/setup-java@v4
with:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.6'
architecture: x64
# dist
- 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
uses: actions/upload-artifact@v4
with:
name: termora-osx-x86-64
path: build/distributions/*.dmg

29
.github/workflows/windows-x86-64.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Windows x86-64
on: [ push, pull_request ]
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Installing Java
uses: actions/setup-java@v4
with:
distribution: 'jetbrains'
java-version: '21'
# dist
- run: |
.\gradlew.bat dist --no-daemon
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-windows-x86-64
path: |
build/distributions/*.zip
build/distributions/*.msi

13
.github/workflows/winget.yml vendored Normal file
View 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 }}

View File

@@ -1,46 +1,51 @@
<div align="center">
<a href="./README.zh_CN.md">🇨🇳 简体中文</a>
</div>
# Termora
**Termora** 是一个终端模拟器和 SSH 客户端,支持 WindowsmacOS Linux
**Termora** is a terminal emulator and SSH client for Windows, macOS and Linux.
<div align="center">
<img src="./docs/readme.png" alt="termora" />
</div>
**Termora** 采用 [Kotlin/JVM](https://kotlinlang.org/) 开发并实现了 [XTerm](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 协议(尚未完全实现),它的最终目标是通过 [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) 实现全平台(含 Android、iOS、iPadOS 等)。
**Termora** is developed using [Kotlin/JVM](https://kotlinlang.org) and partially implements the [XTerm](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) protocol (with ongoing improvements). Its ultimate vision is to achieve full platform support (including Android, iOS, and iPadOS) through [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html).
## 功能特性
## Features
- 支持 SSH 和本地终端
- 支持 [SFTP](./docs/sftp-zh_CN.png) 文件传输
- 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议
- 支持 SSH 端口转发
- 支持配置同步到 [Gist](https://gist.github.com)
- 支持宏(录制脚本并回放)
- 支持关键词高亮
- 支持密钥管理器
- 支持将命令发送到多个会话
- 支持 [Find Everywhere](./docs/findeverywhere.png) 快速跳转
- 支持数据加密
- 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 & Jump hosts
- Terminal log
- Configuration synchronization via [Gist](https://gist.github.com)
- Macro support (record and replay scripts)
- Keyword highlighting
- Key management
- Broadcast commands to multiple sessions
- [Find Everywhere](./docs/findeverywhere.png?raw=1) quick navigation
- Data encryption
- ...
## 下载
## Download
- [releases](https://github.com/TermoraDev/termora/releases/latest)
- [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`
### macOS
## Development
由于苹果开发者证书正在申请中,所以 macOS 用户需要执行 `sudo xattr -r -d com.apple.quarantine /Applications/Termora.app` 后才可以运行程序。
It is recommended to use the [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) version of the JDK and run the program via `./gradlew :run` to run the program.
## 开发
The program can be run via `./gradlew dist` to automatically build the local version. On macOS: `dmg`, on Windows: `zip`, on Linux: `tar.gz`.
建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) 的 JDK 版本,通过 `./gradlew :run` 即可运行程序。
通过 `./gradlew dist` 可以自动构建适用于本机的版本。在 macOS 上是:`dmg`,在 Windows 上是:`zip`,在 Linux 上是:`tar.gz`
## LICENSE
## 协议
This software is distributed under a dual-license model. You may choose one of the following options:
本软件采用双重许可模式,您可以选择以下任意一种许可方式:
- AGPL-3.0:根据 [AGPL-3.0](https://opensource.org/license/agpl-v3) 的条款,您可以自由使用、分发和修改本软件。
- 专有许可:如果希望在闭源或专有环境中使用,请联系作者获取许可。
- AGPL-3.0: Use, distribute, and modify the software under the terms of the [AGPL-3.0](https://opensource.org/license/agpl-v3).
- Proprietary License: For closed-source or proprietary use, please contact the author to obtain a commercial license.

46
README.zh_CN.md Normal file
View File

@@ -0,0 +1,46 @@
# Termora
**Termora** 是一个终端模拟器和 SSH 客户端,支持 WindowsmacOS 和 Linux。
<div align="center">
<img src="./docs/readme-zh_CN.png" alt="termora" />
</div>
**Termora** 采用 [Kotlin/JVM](https://kotlinlang.org/) 开发并实现了 [XTerm](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 协议(尚未完全实现),它的最终目标是通过 [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) 实现全平台(含 Android、iOS、iPadOS 等)。
## 功能特性
- 支持 SSH 和本地终端
- 支持串口协议
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) 文件传输
- 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议
- 支持 SSH 端口转发和跳板机
- 终端日志记录
- 支持配置同步到 [Gist](https://gist.github.com)
- 支持宏(录制脚本并回放)
- 支持关键词高亮
- 支持密钥管理器
- 支持将命令发送到多个会话
- 支持 [Find Everywhere](./docs/findeverywhere-zh_CN.png?raw=1) 快速跳转
- 支持数据加密
- ...
## 下载
- [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`
## 开发
建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) 的 JDK 版本,通过 `./gradlew :run` 即可运行程序。
通过 `./gradlew dist` 可以自动构建适用于本机的版本。在 macOS 上是:`dmg`,在 Windows 上是:`zip`,在 Linux 上是:`tar.gz`
## 协议
本软件采用双重许可模式,您可以选择以下任意一种许可方式:
- AGPL-3.0:根据 [AGPL-3.0](https://opensource.org/license/agpl-v3) 的条款,您可以自由使用、分发和修改本软件。
- 专有许可:如果希望在闭源或专有环境中使用,请联系作者获取许可。

View File

@@ -240,4 +240,8 @@ https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
json-20231013
Public Domain.
https://github.com/stleary/JSON-java/blob/master/LICENSE
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

View File

@@ -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.2"
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,19 +38,19 @@ 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)
testImplementation(libs.sshj)
testImplementation(platform(libs.koin.bom))
testImplementation(libs.koin.core)
testImplementation(libs.jsch)
testImplementation(libs.rhino)
testImplementation(libs.delight.rhino.sandbox)
testImplementation(platform(libs.testcontainers.bom))
testImplementation(libs.testcontainers)
// implementation(platform(libs.koin.bom))
// implementation(libs.koin.core)
implementation(libs.slf4j.api)
implementation(libs.pty4j)
implementation(libs.slf4j.tinylog)
@@ -104,11 +105,18 @@ dependencies {
implementation(libs.bip39)
implementation(libs.colorpicker)
implementation(libs.mixpanel)
implementation(libs.jSerialComm)
}
application {
val args = mutableListOf(
"--add-exports java.base/sun.nio.ch=ALL-UNNAMED",
"-Xmx2g",
"-XX:+UseZGC",
"-XX:+ZUncommit",
"-XX:+ZGenerational",
"-XX:ZUncommitDelay=60",
"-XX:SoftMaxHeapSize=64m"
)
if (os.isMacOsX) {
@@ -142,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)
@@ -166,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/*") }
}
}
@@ -215,21 +240,29 @@ tasks.register<Exec>("jpackage") {
val options = mutableListOf(
"--add-exports java.base/sun.nio.ch=ALL-UNNAMED",
"-Xmx2g",
"-XX:+UseZGC",
"-XX:+ZUncommit",
"-XX:+ZGenerational",
"-XX:ZUncommitDelay=60",
"-XX:SoftMaxHeapSize=64m",
"-XX:+HeapDumpOnOutOfMemoryError",
"-Dlogger.console.level=off",
"-Dkotlinx.coroutines.debug=off",
"-Dapp-version=${project.version}",
)
options.add("-Dsun.java2d.metal=true")
if (os.isMacOsX) {
options.add("-Dsun.java2d.metal=true")
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}"))
@@ -372,6 +405,14 @@ tasks.register("dist") {
"--wait",
)
}
// 绑定公证信息
exec {
commandLine(
"/usr/bin/xcrun",
"stapler", "staple", macOSFinalFilePath,
)
}
}
}
}
@@ -396,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")
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 61 KiB

BIN
docs/readme-zh_CN.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,3 +1,4 @@
org.gradle.caching=true
org.gradle.parallel=true
kotlin.code.style=official
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx4g

View File

@@ -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" }

View File

@@ -0,0 +1,433 @@
package app.termora;/*
* @(#)SwingUtils.java 1.02 11/15/08
*
*/
//package darrylbu.util;
import javax.swing.*;
import java.awt.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.*;
/**
* A collection of utility methods for Swing.
*
* @author Darryl Burke
*/
public final class SwingUtils {
private SwingUtils() {
throw new Error("SwingUtils is just a container for static methods");
}
/**
* Convenience method for searching below <code>container</code> in the
* component hierarchy and return nested components that are instances of
* class <code>clazz</code> it finds. Returns an empty list if no such
* components exist in the container.
* <P>
* Invoking this method with a class parameter of JComponent.class
* will return all nested components.
* <P>
* This method invokes getDescendantsOfType(clazz, container, true)
*
* @param clazz the class of components whose instances are to be found.
* @param container the container at which to begin the search
* @return the List of components
*/
public static <T extends JComponent> List<T> getDescendantsOfType(
Class<T> clazz, Container container) {
return getDescendantsOfType(clazz, container, true);
}
/**
* Convenience method for searching below <code>container</code> in the
* component hierarchy and return nested components that are instances of
* class <code>clazz</code> it finds. Returns an empty list if no such
* components exist in the container.
* <P>
* Invoking this method with a class parameter of JComponent.class
* will return all nested components.
*
* @param clazz the class of components whose instances are to be found.
* @param container the container at which to begin the search
* @param nested true to list components nested within another listed
* component, false otherwise
* @return the List of components
*/
public static <T extends JComponent> List<T> getDescendantsOfType(
Class<T> clazz, Container container, boolean nested) {
List<T> tList = new ArrayList<T>();
for (Component component : container.getComponents()) {
if (clazz.isAssignableFrom(component.getClass())) {
tList.add(clazz.cast(component));
}
if (nested || !clazz.isAssignableFrom(component.getClass())) {
tList.addAll(SwingUtils.<T>getDescendantsOfType(clazz,
(Container) component, nested));
}
}
return tList;
}
/**
* Convenience method that searches below <code>container</code> in the
* component hierarchy and returns the first found component that is an
* instance of class <code>clazz</code> having the bound property value.
* Returns {@code null} if such component cannot be found.
* <P>
* This method invokes getDescendantOfType(clazz, container, property, value,
* true)
*
* @param clazz the class of component whose instance is to be found.
* @param container the container at which to begin the search
* @param property the className of the bound property, exactly as expressed in
* the accessor e.g. "Text" for getText(), "Value" for getValue().
* @param value the value of the bound property
* @return the component, or null if no such component exists in the
* container
* @throws java.lang.IllegalArgumentException if the bound property does
* not exist for the class or cannot be accessed
*/
public static <T extends JComponent> T getDescendantOfType(
Class<T> clazz, Container container, String property, Object value)
throws IllegalArgumentException {
return getDescendantOfType(clazz, container, property, value, true);
}
/**
* Convenience method that searches below <code>container</code> in the
* component hierarchy and returns the first found component that is an
* instance of class <code>clazz</code> and has the bound property value.
* Returns {@code null} if such component cannot be found.
*
* @param clazz the class of component whose instance to be found.
* @param container the container at which to begin the search
* @param property the className of the bound property, exactly as expressed in
* the accessor e.g. "Text" for getText(), "Value" for getValue().
* @param value the value of the bound property
* @param nested true to list components nested within another component
* which is also an instance of <code>clazz</code>, false otherwise
* @return the component, or null if no such component exists in the
* container
* @throws java.lang.IllegalArgumentException if the bound property does
* not exist for the class or cannot be accessed
*/
public static <T extends JComponent> T getDescendantOfType(Class<T> clazz,
Container container, String property, Object value, boolean nested)
throws IllegalArgumentException {
List<T> list = getDescendantsOfType(clazz, container, nested);
return getComponentFromList(clazz, list, property, value);
}
/**
* Convenience method for searching below <code>container</code> in the
* component hierarchy and return nested components of class
* <code>clazz</code> it finds. Returns an empty list if no such
* components exist in the container.
* <P>
* This method invokes getDescendantsOfClass(clazz, container, true)
*
* @param clazz the class of components to be found.
* @param container the container at which to begin the search
* @return the List of components
*/
public static <T extends JComponent> List<T> getDescendantsOfClass(
Class<T> clazz, Container container) {
return getDescendantsOfClass(clazz, container, true);
}
/**
* Convenience method for searching below <code>container</code> in the
* component hierarchy and return nested components of class
* <code>clazz</code> it finds. Returns an empty list if no such
* components exist in the container.
*
* @param clazz the class of components to be found.
* @param container the container at which to begin the search
* @param nested true to list components nested within another listed
* component, false otherwise
* @return the List of components
*/
public static <T extends JComponent> List<T> getDescendantsOfClass(
Class<T> clazz, Container container, boolean nested) {
List<T> tList = new ArrayList<T>();
for (Component component : container.getComponents()) {
if (clazz.equals(component.getClass())) {
tList.add(clazz.cast(component));
}
if (nested || !clazz.equals(component.getClass())) {
tList.addAll(SwingUtils.<T>getDescendantsOfClass(clazz,
(Container) component, nested));
}
}
return tList;
}
/**
* Convenience method that searches below <code>container</code> in the
* component hierarchy in a depth first manner and returns the first
* found component of class <code>clazz</code> having the bound property
* value.
* <P>
* Returns {@code null} if such component cannot be found.
* <P>
* This method invokes getDescendantOfClass(clazz, container, property,
* value, true)
*
* @param clazz the class of component to be found.
* @param container the container at which to begin the search
* @param property the className of the bound property, exactly as expressed in
* the accessor e.g. "Text" for getText(), "Value" for getValue().
* This parameter is case sensitive.
* @param value the value of the bound property
* @return the component, or null if no such component exists in the
* container's hierarchy.
* @throws java.lang.IllegalArgumentException if the bound property does
* not exist for the class or cannot be accessed
*/
public static <T extends JComponent> T getDescendantOfClass(Class<T> clazz,
Container container, String property, Object value)
throws IllegalArgumentException {
return getDescendantOfClass(clazz, container, property, value, true);
}
/**
* Convenience method that searches below <code>container</code> in the
* component hierarchy in a depth first manner and returns the first
* found component of class <code>clazz</code> having the bound property
* value.
* <P>
* Returns {@code null} if such component cannot be found.
*
* @param clazz the class of component to be found.
* @param container the container at which to begin the search
* @param property the className of the bound property, exactly as expressed
* in the accessor e.g. "Text" for getText(), "Value" for getValue().
* This parameter is case sensitive.
* @param value the value of the bound property
* @param nested true to include components nested within another listed
* component, false otherwise
* @return the component, or null if no such component exists in the
* container's hierarchy
* @throws java.lang.IllegalArgumentException if the bound property does
* not exist for the class or cannot be accessed
*/
public static <T extends JComponent> T getDescendantOfClass(Class<T> clazz,
Container container, String property, Object value, boolean nested)
throws IllegalArgumentException {
List<T> list = getDescendantsOfClass(clazz, container, nested);
return getComponentFromList(clazz, list, property, value);
}
private static <T extends JComponent> T getComponentFromList(Class<T> clazz,
List<T> list, String property, Object value)
throws IllegalArgumentException {
T retVal = null;
Method method = null;
try {
method = clazz.getMethod("get" + property);
} catch (NoSuchMethodException ex) {
try {
method = clazz.getMethod("is" + property);
} catch (NoSuchMethodException ex1) {
throw new IllegalArgumentException("Property " + property +
" not found in class " + clazz.getName());
}
}
try {
for (T t : list) {
Object testVal = method.invoke(t);
if (equals(value, testVal)) {
return t;
}
}
} catch (InvocationTargetException ex) {
throw new IllegalArgumentException(
"Error accessing property " + property +
" in class " + clazz.getName());
} catch (IllegalAccessException ex) {
throw new IllegalArgumentException(
"Property " + property +
" cannot be accessed in class " + clazz.getName());
} catch (SecurityException ex) {
throw new IllegalArgumentException(
"Property " + property +
" cannot be accessed in class " + clazz.getName());
}
return retVal;
}
/**
* Convenience method for determining whether two objects are either
* equal or both null.
*
* @param obj1 the first reference object to compare.
* @param obj2 the second reference object to compare.
* @return true if obj1 and obj2 are equal or if both are null,
* false otherwise
*/
public static boolean equals(Object obj1, Object obj2) {
return obj1 == null ? obj2 == null : obj1.equals(obj2);
}
/**
* Convenience method for mapping a container in the hierarchy to its
* contained components. The keys are the containers, and the values
* are lists of contained components.
* <P>
* Implementation note: The returned value is a HashMap and the values
* are of type ArrayList. This is subject to change, so callers should
* code against the interfaces Map and List.
*
* @param container The JComponent to be mapped
* @param nested true to drill down to nested containers, false otherwise
* @return the Map of the UI
*/
public static Map<JComponent, List<JComponent>> getComponentMap(
JComponent container, boolean nested) {
HashMap<JComponent, List<JComponent>> retVal =
new HashMap<JComponent, List<JComponent>>();
for (JComponent component : getDescendantsOfType(JComponent.class,
container, false)) {
if (!retVal.containsKey(container)) {
retVal.put(container,
new ArrayList<JComponent>());
}
retVal.get(container).add(component);
if (nested) {
retVal.putAll(getComponentMap(component, nested));
}
}
return retVal;
}
/**
* Convenience method for retrieving a subset of the UIDefaults pertaining
* to a particular class.
*
* @param clazz the class of interest
* @return the UIDefaults of the class
*/
public static UIDefaults getUIDefaultsOfClass(Class clazz) {
String name = clazz.getName();
name = name.substring(name.lastIndexOf(".") + 2);
return getUIDefaultsOfClass(name);
}
/**
* Convenience method for retrieving a subset of the UIDefaults pertaining
* to a particular class.
*
* @param className fully qualified name of the class of interest
* @return the UIDefaults of the class named
*/
public static UIDefaults getUIDefaultsOfClass(String className) {
UIDefaults retVal = new UIDefaults();
UIDefaults defaults = UIManager.getLookAndFeelDefaults();
List<?> listKeys = Collections.list(defaults.keys());
for (Object key : listKeys) {
if (key instanceof String && ((String) key).startsWith(className)) {
String stringKey = (String) key;
String property = stringKey;
if (stringKey.contains(".")) {
property = stringKey.substring(stringKey.indexOf(".") + 1);
}
retVal.put(property, defaults.get(key));
}
}
return retVal;
}
/**
* Convenience method for retrieving the UIDefault for a single property
* of a particular class.
*
* @param clazz the class of interest
* @param property the property to query
* @return the UIDefault property, or null if not found
*/
public static Object getUIDefaultOfClass(Class clazz, String property) {
Object retVal = null;
UIDefaults defaults = getUIDefaultsOfClass(clazz);
List<Object> listKeys = Collections.list(defaults.keys());
for (Object key : listKeys) {
if (key.equals(property)) {
return defaults.get(key);
}
if (key.toString().equalsIgnoreCase(property)) {
retVal = defaults.get(key);
}
}
return retVal;
}
/**
* Exclude methods that return values that are meaningless to the user
*/
static Set<String> setExclude = new HashSet<String>();
static {
setExclude.add("getFocusCycleRootAncestor");
setExclude.add("getAccessibleContext");
setExclude.add("getColorModel");
setExclude.add("getGraphics");
setExclude.add("getGraphicsConfiguration");
}
/**
* Convenience method for obtaining most non-null human readable properties
* of a JComponent. Array properties are not included.
* <P>
* Implementation note: The returned value is a HashMap. This is subject
* to change, so callers should code against the interface Map.
*
* @param component the component whose proerties are to be determined
* @return the class and value of the properties
*/
public static Map<Object, Object> getProperties(JComponent component) {
Map<Object, Object> retVal = new HashMap<Object, Object>();
Class<?> clazz = component.getClass();
Method[] methods = clazz.getMethods();
Object value = null;
for (Method method : methods) {
if (method.getName().matches("^(is|get).*") &&
method.getParameterTypes().length == 0) {
try {
Class returnType = method.getReturnType();
if (returnType != void.class &&
!returnType.getName().startsWith("[") &&
!setExclude.contains(method.getName())) {
String key = method.getName();
value = method.invoke(component);
if (value != null && !(value instanceof Component)) {
retVal.put(key, value);
}
}
// ignore exceptions that arise if the property could not be accessed
} catch (IllegalAccessException ex) {
} catch (IllegalArgumentException ex) {
} catch (InvocationTargetException ex) {
}
}
}
return retVal;
}
/**
* Convenience method to obtain the Swing class from which this
* component was directly or indirectly derived.
*
* @param component The component whose Swing superclass is to be
* determined
* @return The nearest Swing class in the inheritance tree
*/
public static <T extends JComponent> Class getJClass(T component) {
Class<?> clazz = component.getClass();
while (!clazz.getName().matches("javax.swing.J[^.]*$")) {
clazz = clazz.getSuperclass();
}
return clazz;
}
}

View File

@@ -2,21 +2,12 @@ package app.termora
object Actions {
/**
* 打开设置
*/
const val SETTING = "SettingAction"
/**
* 将命令发送到多个会话
*/
const val MULTIPLE = "MultipleAction"
/**
* 查找
*/
const val FIND_EVERYWHERE = "FindEverywhereAction"
/**
* 关键词高亮
*/
@@ -38,15 +29,6 @@ object Actions {
*/
const val MACRO = "MacroAction"
/**
* 添加主机对话框
*/
const val ADD_HOST = "AddHostAction"
/**
* 打开一个主机
*/
const val OPEN_HOST = "OpenHostAction"
/**
* 终端日志记录
@@ -57,4 +39,5 @@ object Actions {
* 打开 SFTP Tab Action
*/
const val SFTP = "SFTPAction"
}

View File

@@ -1,16 +0,0 @@
package app.termora
import org.jdesktop.swingx.action.BoundAction
import javax.swing.Icon
abstract class AnAction : BoundAction {
constructor() : super()
constructor(icon: Icon) : super() {
super.putValue(SMALL_ICON, icon)
}
constructor(name: String?) : super(name)
constructor(name: String?, icon: Icon?) : super(name, icon)
}

View File

@@ -16,14 +16,11 @@ import java.awt.Desktop
import java.io.File
import java.net.URI
import java.time.Duration
import java.util.*
import kotlin.math.ln
import kotlin.math.pow
import kotlin.reflect.KClass
object Application {
private val services = Collections.synchronizedMap(mutableMapOf<KClass<*>, Any>())
private lateinit var baseDataDir: File
@@ -114,33 +111,24 @@ 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)
}
}
@Suppress("UNCHECKED_CAST")
fun <T : Any> getService(clazz: KClass<T>): T {
if (services.containsKey(clazz)) {
return services[clazz] as T
}
throw IllegalStateException("$clazz does not exist")
}
@Synchronized
fun registerService(clazz: KClass<*>, service: Any) {
if (services.containsKey(clazz)) {
throw IllegalStateException("$clazz already registered")
}
services[clazz] = service
}
private fun tryBrowse(uri: URI) {
if (SystemInfo.isWindows) {
ProcessBuilder("explorer", uri.toString()).start()

View File

@@ -1,10 +0,0 @@
package app.termora
/**
* 将在 JVM 进程退出时释放
*/
class ApplicationDisposable : Disposable {
companion object {
val instance by lazy { ApplicationDisposable() }
}
}

View File

@@ -1,6 +1,7 @@
package app.termora
import app.termora.db.Database
import app.termora.actions.ActionManager
import app.termora.keymap.KeymapManager
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatInspector
@@ -9,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
@@ -19,61 +17,86 @@ 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 javax.swing.WindowConstants.DISPOSE_ON_CLOSE
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() {
// 覆盖 tinylog 配置
setupTinylog()
measureTimeMillis {
// 覆盖 tinylog 配置
val setupTinylog = measureTimeMillis { setupTinylog() }
// 是否单例
checkSingleton()
// 是否单例
val checkSingleton = measureTimeMillis { checkSingleton() }
// 打印系统信息
printSystemInfo()
// 打印系统信息
val printSystemInfo = measureTimeMillis { printSystemInfo() }
SwingUtilities.invokeAndWait {
// 打开数据库
openDatabase()
val openDatabase = measureTimeMillis { openDatabase() }
// 加载设置
loadSettings()
val loadSettings = measureTimeMillis { loadSettings() }
// 统计
enableAnalytics()
val enableAnalytics = measureTimeMillis { enableAnalytics() }
// init ActionManager、KeymapManager
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) {
ActionManager.getInstance()
KeymapManager.getInstance()
}
// 设置 LAF
setupLaf()
val setupLaf = measureTimeMillis { setupLaf() }
// 解密数据
openDoor()
val openDoor = measureTimeMillis { openDoor() }
// 启动主窗口
startMainFrame()
val startMainFrame = measureTimeMillis { startMainFrame() }
if (log.isDebugEnabled) {
log.debug("setupTinylog: {}ms", setupTinylog)
log.debug("checkSingleton: {}ms", checkSingleton)
log.debug("printSystemInfo: {}ms", printSystemInfo)
log.debug("openDatabase: {}ms", openDatabase)
log.debug("loadSettings: {}ms", loadSettings)
log.debug("enableAnalytics: {}ms", enableAnalytics)
log.debug("setupLaf: {}ms", setupLaf)
log.debug("openDoor: {}ms", openDoor)
log.debug("startMainFrame: {}ms", startMainFrame)
}
}.let {
if (log.isDebugEnabled) {
log.debug("run: {}ms", it)
}
}
}
private fun openDoor() {
if (Doorman.instance.isWorking()) {
if (Doorman.getInstance().isWorking()) {
if (!DoormanDialog(null).open()) {
exitProcess(1)
}
@@ -81,17 +104,11 @@ class ApplicationRunner {
}
private fun startMainFrame() {
val frame = TermoraFrame()
frame.title = if (SystemInfo.isLinux) null else Application.getName()
frame.defaultCloseOperation = DISPOSE_ON_CLOSE
frame.setSize(1280, 800)
frame.setLocationRelativeTo(null)
frame.isVisible = true
TermoraFrameManager.getInstance().createWindow().isVisible = true
}
private fun loadSettings() {
val language = Database.instance.appearance.language
val language = Database.getDatabase().appearance.language
val locale = runCatching { LocaleUtils.toLocale(language) }.getOrElse { Locale.getDefault() }
if (log.isInfoEnabled) {
log.info("Language: {} , Locale: {}", language, locale)
@@ -110,22 +127,22 @@ class ApplicationRunner {
JDialog.setDefaultLookAndFeelDecorated(true)
}
val themeManager = ThemeManager.instance
val settings = Database.instance
var theme = settings.appearance.theme
// 如果是跟随系统或者不存在样式,那么使用默认的
if (settings.appearance.followSystem || !themeManager.themes.containsKey(theme)) {
val themeManager = ThemeManager.getInstance()
val appearance = Database.getDatabase().appearance
var theme = appearance.theme
// 如果是跟随系统
if (appearance.followSystem) {
theme = if (OsThemeDetector.getDetector().isDark) {
"Dark"
appearance.darkTheme
} else {
"Light"
appearance.lightTheme
}
}
themeManager.change(theme, true)
FlatInspector.install("ctrl shift alt X");
if (Application.isUnknownVersion())
FlatInspector.install("ctrl shift alt X");
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)
@@ -173,21 +190,21 @@ class ApplicationRunner {
}
private fun printSystemInfo() {
if (log.isInfoEnabled) {
log.info("Welcome to ${Application.getName()} ${Application.getVersion()}!")
log.info(
if (log.isDebugEnabled) {
log.debug("Welcome to ${Application.getName()} ${Application.getVersion()}!")
log.debug(
"JVM name: {} , vendor: {} , version: {}",
SystemUtils.JAVA_VM_NAME,
SystemUtils.JAVA_VM_VENDOR,
SystemUtils.JAVA_VM_VERSION,
)
log.info(
log.debug(
"OS name: {} , version: {} , arch: {}",
SystemUtils.OS_NAME,
SystemUtils.OS_VERSION,
SystemUtils.OS_ARCH
)
log.info("Base config dir: ${Application.getBaseDataDir().absolutePath}")
log.debug("Base config dir: ${Application.getBaseDataDir().absolutePath}")
}
}
@@ -206,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)
}
@@ -245,9 +240,8 @@ class ApplicationRunner {
private fun openDatabase() {
val dir = Application.getDatabaseFile()
try {
Database.open(dir)
Database.getDatabase()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
@@ -296,10 +290,10 @@ class ApplicationRunner {
}
private fun getAnalyticsUserID(): String {
var id = Database.instance.properties.getString("AnalyticsUserID")
var id = Database.getDatabase().properties.getString("AnalyticsUserID")
if (id.isNullOrBlank()) {
id = UUID.randomUUID().toSimpleString()
Database.instance.properties.putString("AnalyticsUserID", id)
Database.getDatabase().properties.putString("AnalyticsUserID", id)
}
return id
}

View File

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

View File

@@ -1,7 +1,7 @@
package app.termora
import app.termora.Application.ohMyJson
import app.termora.db.Database
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.serialization.encodeToString
@@ -358,7 +358,8 @@ class CustomizeToolBarDialog(
actions.add(ToolBarAction(leftList.model.getElementAt(i).id, false))
}
Database.instance.properties.putString("Termora.ToolBar.Actions", ohMyJson.encodeToString(actions))
Database.getDatabase()
.properties.putString("Termora.ToolBar.Actions", ohMyJson.encodeToString(actions))
super.doOKAction()
}

View File

@@ -1,8 +1,8 @@
package app.termora.db
package app.termora
import app.termora.*
import app.termora.Application.ohMyJson
import app.termora.highlight.KeywordHighlight
import app.termora.keymap.Keymap
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.sync.SyncType
@@ -26,24 +26,15 @@ import kotlin.time.Duration.Companion.minutes
class Database private constructor(private val env: Environment) : Disposable {
companion object {
private const val KEYMAP_STORE = "Keymap"
private const val HOST_STORE = "Host"
private const val KEYWORD_HIGHLIGHT_STORE = "KeywordHighlight"
private const val MACRO_STORE = "Macro"
private const val KEY_PAIR_STORE = "KeyPair"
private val log = LoggerFactory.getLogger(Database::class.java)
private lateinit var database: Database
val instance by lazy {
if (!::database.isInitialized) {
throw UnsupportedOperationException("Database has not been initialized!")
}
database
}
fun open(dir: File) {
if (::database.isInitialized) {
throw UnsupportedOperationException("Database is already open")
}
private fun open(dir: File): Database {
val config = EnvironmentConfig()
// 32MB
config.setLogFileSize(1024 * 32)
@@ -51,8 +42,12 @@ class Database private constructor(private val env: Environment) : Disposable {
// 5m
config.setGcStartIn(5.minutes.inWholeMilliseconds.toInt())
val environment = Environments.newInstance(dir, config)
database = Database(environment)
Disposer.register(ApplicationDisposable.instance, database)
return Database(environment)
}
fun getDatabase(): Database {
return ApplicationScope.forApplicationScope()
.getOrCreate(Database::class) { open(Application.getDatabaseFile()) }
}
}
@@ -62,7 +57,41 @@ class Database private constructor(private val env: Environment) : Disposable {
val appearance by lazy { Appearance() }
val sync by lazy { Sync() }
private val doorman get() = Doorman.instance
private val doorman get() = Doorman.getInstance()
fun getKeymaps(): Collection<Keymap> {
val array = env.computeInTransaction { tx ->
openCursor<String>(tx, KEYMAP_STORE) { _, value ->
value
}.values
}
val keymaps = mutableListOf<Keymap>()
for (text in array.iterator()) {
keymaps.add(Keymap.fromJSON(text) ?: continue)
}
return keymaps
}
fun addKeymap(keymap: Keymap) {
env.executeInTransaction {
put(it, KEYMAP_STORE, keymap.name, keymap.toJSON())
if (log.isDebugEnabled) {
log.debug("Added Keymap: ${keymap.name}")
}
}
}
fun removeKeymap(name: String) {
env.executeInTransaction {
delete(it, KEYMAP_STORE, name)
if (log.isDebugEnabled) {
log.debug("Removed Keymap: $name")
}
}
}
fun getHosts(): Collection<Host> {
@@ -413,7 +442,7 @@ class Database private constructor(private val env: Environment) : Disposable {
/**
* 字体大小
*/
var fontSize by IntPropertyDelegate(16)
var fontSize by IntPropertyDelegate(14)
/**
* 最大行数
@@ -425,6 +454,11 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var debug by BooleanPropertyDelegate(false)
/**
* 蜂鸣声
*/
var beep by BooleanPropertyDelegate(true)
/**
* 选中复制
*/
@@ -434,6 +468,11 @@ class Database private constructor(private val env: Environment) : Disposable {
* 光标样式
*/
var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
/**
* 终端断开连接时自动关闭Tab
*/
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
}
/**
@@ -459,7 +498,7 @@ class Database private constructor(private val env: Environment) : Disposable {
* 安全的通用属性
*/
open inner class SafetyProperties(name: String) : Property(name) {
private val doorman get() = Doorman.instance
private val doorman get() = Doorman.getInstance()
public override fun getString(key: String): String? {
var value = super.getString(key)
@@ -522,6 +561,8 @@ class Database private constructor(private val env: Environment) : Disposable {
* 跟随系统
*/
var followSystem by BooleanPropertyDelegate(true)
var darkTheme by StringPropertyDelegate("Dark")
var lightTheme by StringPropertyDelegate("Light")
/**
* 语言
@@ -548,6 +589,7 @@ class Database private constructor(private val env: Environment) : Disposable {
var rangeKeyPairs by BooleanPropertyDelegate(true)
var rangeKeywordHighlights by BooleanPropertyDelegate(true)
var rangeMacros by BooleanPropertyDelegate(true)
var rangeKeymap by BooleanPropertyDelegate(true)
/**
* Token

View File

@@ -1,18 +1,18 @@
package app.termora
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import java.awt.event.ActionEvent
import java.awt.*
import java.awt.event.KeyEvent
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.*
abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
private val rootPanel = JPanel(BorderLayout())
private val titleLabel = JLabel()
@@ -21,6 +21,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
companion object {
const val DEFAULT_ACTION = "DEFAULT_ACTION"
private const val PROCESS_GLOBAL_KEYMAP = "PROCESS_GLOBAL_KEYMAP"
}
@@ -38,9 +39,21 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
protected var lostFocusDispose = false
protected var escapeDispose = true
var processGlobalKeymap: Boolean
get() {
val v = super.rootPane.getClientProperty(PROCESS_GLOBAL_KEYMAP)
if (v is Boolean) {
return v
}
return false
}
protected set(value) {
super.rootPane.putClientProperty(PROCESS_GLOBAL_KEYMAP, value)
}
protected fun init() {
defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
initTitleBar()
@@ -132,7 +145,32 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_W, toolkit.menuShortcutKeyMaskEx), "close")
rootPane.actionMap.put("close", object : AnAction() {
override fun actionPerformed(e: ActionEvent) {
override fun actionPerformed(evt: AnActionEvent) {
val c = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
val popups: List<JPopupMenu> = SwingUtils.getDescendantsOfType(
JPopupMenu::class.java,
c as Container, true
)
var openPopup = false
for (p in popups) {
p.isVisible = false
openPopup = true
}
val window = SwingUtilities.windowForComponent(c)
val windows = window.ownedWindows
for (w in windows) {
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
openPopup = true
w.dispose()
}
}
if (openPopup) {
return
}
doCancelAction()
}
})
@@ -154,12 +192,12 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
if (SystemInfo.isWindows) {
addWindowListener(object : WindowAdapter(), ThemeChangeListener {
override fun windowClosed(e: WindowEvent) {
ThemeManager.instance.removeThemeChangeListener(this)
ThemeManager.getInstance().removeThemeChangeListener(this)
}
override fun windowOpened(e: WindowEvent) {
onChanged()
ThemeManager.instance.addThemeChangeListener(this)
ThemeManager.getInstance().addThemeChangeListener(this)
}
override fun onChanged() {
@@ -190,7 +228,8 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
putValue(DEFAULT_ACTION, true)
}
override fun actionPerformed(e: ActionEvent) {
override fun actionPerformed(evt: AnActionEvent) {
doOKAction()
}
@@ -198,7 +237,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
protected inner class CancelAction : AnAction(I18n.getString("termora.cancel")) {
override fun actionPerformed(e: ActionEvent) {
override fun actionPerformed(evt: AnActionEvent) {
doCancelAction()
}

View File

@@ -2,16 +2,17 @@ package app.termora
import app.termora.AES.decodeBase64
import app.termora.AES.encodeBase64String
import app.termora.db.Database
class PasswordWrongException : RuntimeException()
class Doorman private constructor() {
private val properties get() = Database.instance.properties
class Doorman private constructor() : Disposable {
private val properties get() = Database.getDatabase().properties
private var key = byteArrayOf()
companion object {
val instance by lazy { Doorman() }
fun getInstance(): Doorman {
return ApplicationScope.forApplicationScope().getOrCreate(Doorman::class) { Doorman() }
}
}
fun isWorking(): Boolean {
@@ -82,4 +83,8 @@ class Doorman private constructor() {
checkIsWorking()
return key.contentEquals(convertKey(password))
}
override fun dispose() {
key = byteArrayOf()
}
}

View File

@@ -1,7 +1,8 @@
package app.termora
import app.termora.AES.decodeBase64
import app.termora.db.Database
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.terminal.ControlCharacters
import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties
@@ -17,7 +18,6 @@ import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.Window
import java.awt.datatransfer.DataFlavor
import java.awt.event.ActionEvent
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import javax.imageio.ImageIO
@@ -95,7 +95,7 @@ class DoormanDialog(owner: Window?) : DialogWrapper(owner) {
.add(safeBtn).xy(4, rows).apply { rows += step }
.add(tip).xyw(2, rows, 4, "center, fill").apply { rows += step }
.add(JXHyperlink(object : AnAction(I18n.getString("termora.doorman.forget-password")) {
override fun actionPerformed(e: ActionEvent) {
override fun actionPerformed(evt: AnActionEvent) {
val option = OptionPane.showConfirmDialog(
this@DoormanDialog, I18n.getString("termora.doorman.forget-password-message"),
options = arrayOf(
@@ -130,10 +130,11 @@ class DoormanDialog(owner: Window?) : DialogWrapper(owner) {
}
try {
val keyBackup = Database.instance.properties.getString("doorman-key-backup")
val keyBackup = Database.getDatabase()
.properties.getString("doorman-key-backup")
?: throw IllegalStateException("doorman-key-backup is null")
val key = AES.ECB.decrypt(entropy, keyBackup.decodeBase64())
Doorman.instance.work(key)
Doorman.getInstance().work(key)
} catch (e: Exception) {
OptionPane.showMessageDialog(
this, I18n.getString("termora.doorman.mnemonic-data-corrupted"),
@@ -157,7 +158,7 @@ class DoormanDialog(owner: Window?) : DialogWrapper(owner) {
}
try {
Doorman.instance.work(passwordTextField.password)
Doorman.getInstance().work(passwordTextField.password)
} catch (e: Exception) {
if (e is PasswordWrongException) {
OptionPane.showMessageDialog(

View File

@@ -1,8 +1,5 @@
package app.termora
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
init {
generalOption.portTextField.value = host.port
@@ -10,15 +7,12 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
generalOption.protocolTypeComboBox.selectedItem = host.protocol
generalOption.usernameTextField.text = host.username
generalOption.hostTextField.text = host.host
generalOption.passwordTextField.text = host.authentication.password
generalOption.remarkTextArea.text = host.remark
generalOption.authenticationTypeComboBox.selectedItem = host.authentication.type
if (host.authentication.type == AuthenticationType.PublicKey) {
val ohKeyPair = KeyManager.instance.getOhKeyPair(host.authentication.password)
if (ohKeyPair != null) {
generalOption.publicKeyTextField.text = ohKeyPair.name
generalOption.publicKeyTextField.putClientProperty(OhKeyPair::class, ohKeyPair)
}
if (host.authentication.type == AuthenticationType.Password) {
generalOption.passwordTextField.text = host.authentication.password
} else if (host.authentication.type == AuthenticationType.PublicKey) {
generalOption.publicKeyComboBox.selectedItem = host.authentication.password
}
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
@@ -34,6 +28,25 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
tunnelingOption.tunnelings.addAll(host.tunnelings)
if (host.options.jumpHosts.isNotEmpty()) {
val hosts = HostManager.getInstance().hosts().associateBy { it.id }
for (id in host.options.jumpHosts) {
jumpHostsOption.jumpHosts.add(hosts[id] ?: continue)
}
}
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 {

View File

@@ -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()

View File

@@ -1,12 +1,16 @@
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
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import java.awt.event.ActionEvent
import javax.swing.*
class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
@@ -40,44 +44,76 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
private fun createTestConnectionAction(): AbstractAction {
return object : AnAction(I18n.getString("termora.new-host.test-connection")) {
override fun actionPerformed(e: ActionEvent) {
override fun actionPerformed(evt: AnActionEvent) {
if (!pane.validateFields()) {
return
}
putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...")
SwingUtilities.invokeLater {
testConnection(pane.getHost())
putValue(NAME, I18n.getString("termora.new-host.test-connection"))
}
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() {

View File

@@ -1,6 +1,5 @@
package app.termora
import app.termora.db.Database
import java.util.*
interface HostListener : EventListener {
@@ -12,10 +11,12 @@ interface HostListener : EventListener {
class HostManager private constructor() {
companion object {
val instance by lazy { HostManager() }
fun getInstance(): HostManager {
return ApplicationScope.forApplicationScope().getOrCreate(HostManager::class) { HostManager() }
}
}
private val database get() = Database.instance
private val database get() = Database.getDatabase()
private val listeners = mutableListOf<HostListener>()
fun addHost(host: Host, notify: Boolean = true) {

View File

@@ -1,17 +1,24 @@
package app.termora
import app.termora.keymgr.KeyManager
import app.termora.keymgr.KeyManagerDialog
import app.termora.keymgr.OhKeyPair
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.*
import java.nio.charset.Charset
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel
@@ -20,13 +27,17 @@ open class HostOptionsPane : OptionsPane() {
protected val generalOption = GeneralOption()
protected val proxyOption = ProxyOption()
protected val terminalOption = TerminalOption()
protected val owner: Window? get() = SwingUtilities.getWindowAncestor(this)
protected val jumpHostsOption = JumpHostsOption()
protected val serialCommOption = SerialCommOption()
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init {
addOption(generalOption)
addOption(proxyOption)
addOption(tunnelingOption)
addOption(jumpHostsOption)
addOption(terminalOption)
addOption(serialCommOption)
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
}
@@ -40,16 +51,16 @@ open class HostOptionsPane : OptionsPane() {
var authentication = Authentication.No
var proxy = Proxy.No
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
authentication = authentication.copy(
type = AuthenticationType.Password,
password = String(generalOption.passwordTextField.password)
)
} else if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
val keyPair = generalOption.publicKeyTextField.getClientProperty(OhKeyPair::class) as OhKeyPair?
authentication = authentication.copy(
type = AuthenticationType.PublicKey,
password = keyPair?.id ?: StringUtils.EMPTY
password = generalOption.publicKeyComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
)
}
@@ -64,11 +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 },
serialComm = serialComm
)
return Host(
@@ -100,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) {
@@ -107,7 +136,7 @@ open class HostOptionsPane : OptionsPane() {
return false
}
} else if (host.authentication.type == AuthenticationType.PublicKey) {
if (validateField(generalOption.publicKeyTextField)) {
if (validateField(generalOption.publicKeyComboBox)) {
return false
}
}
@@ -145,6 +174,20 @@ open class HostOptionsPane : OptionsPane() {
return false
}
/**
* 返回 true 表示有错误
*/
private fun validateField(comboBox: JComboBox<*>): Boolean {
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()
return true
}
return false
}
protected inner class GeneralOption : JPanel(BorderLayout()), Option {
val portTextField = PortSpinner()
val nameTextField = OutlineTextField(128)
@@ -154,7 +197,7 @@ open class HostOptionsPane : OptionsPane() {
private val passwordPanel = JPanel(BorderLayout())
private val chooseKeyBtn = JButton(Icons.greyKey)
val passwordTextField = OutlinePasswordField(255)
val publicKeyTextField = OutlineTextField()
val publicKeyComboBox = OutlineComboBox<String>()
val remarkTextArea = FixedLengthTextArea(512)
val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
@@ -166,7 +209,7 @@ open class HostOptionsPane : OptionsPane() {
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
publicKeyTextField.isEditable = false
publicKeyComboBox.isEditable = false
chooseKeyBtn.isFocusable = false
protocolTypeComboBox.renderer = object : DefaultListCellRenderer() {
@@ -187,6 +230,28 @@ open class HostOptionsPane : OptionsPane() {
}
}
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = StringUtils.EMPTY
if (value is String) {
text = KeyManager.getInstance().getOhKeyPair(value)?.name ?: text
}
return super.getListCellRendererComponent(
list,
text,
index,
isSelected,
cellHasFocus
)
}
}
authenticationTypeComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
@@ -221,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)
@@ -265,14 +331,20 @@ open class HostOptionsPane : OptionsPane() {
dialog.pack()
dialog.setLocationRelativeTo(null)
dialog.isVisible = true
if (dialog.ok) {
val lastKeyPair = dialog.getLasOhKeyPair()
if (lastKeyPair != null) {
publicKeyTextField.putClientProperty(OhKeyPair::class, lastKeyPair)
publicKeyTextField.text = lastKeyPair.name
publicKeyTextField.outline = null
}
val selectedItem = publicKeyComboBox.selectedItem
publicKeyComboBox.removeAllItems()
for (keyPair in KeyManager.getInstance().getOhKeyPairs()) {
publicKeyComboBox.addItem(keyPair.id)
}
publicKeyComboBox.selectedItem = selectedItem
if (!dialog.ok) {
return
}
publicKeyComboBox.selectedItem = dialog.getLasOhKeyPair()?.id ?: return
}
private fun refreshStates() {
@@ -280,15 +352,19 @@ open class HostOptionsPane : OptionsPane() {
portTextField.isEnabled = true
usernameTextField.isEnabled = true
authenticationTypeComboBox.isEnabled = true
publicKeyComboBox.isEnabled = true
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
authenticationTypeComboBox.isEnabled = false
passwordTextField.isEnabled = false
publicKeyComboBox.isEnabled = false
chooseKeyBtn.isEnabled = false
}
@@ -365,10 +441,16 @@ open class HostOptionsPane : OptionsPane() {
passwordPanel.removeAll()
if (authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
val selectedItem = publicKeyComboBox.selectedItem
publicKeyComboBox.removeAllItems()
for (pair in KeyManager.getInstance().getOhKeyPairs()) {
publicKeyComboBox.addItem(pair.id)
}
publicKeyComboBox.selectedItem = selectedItem
passwordPanel.add(
FormBuilder.create()
.layout(FormLayout("default:grow, 4dlu, left:pref", "pref"))
.add(publicKeyTextField).xy(1, 1)
.add(publicKeyComboBox).xy(1, 1)
.add(chooseKeyBtn).xy(3, 1)
.build(), BorderLayout.CENTER
)
@@ -635,6 +717,12 @@ open class HostOptionsPane : OptionsPane() {
model.addColumn(I18n.getString("termora.new-host.tunneling.table.destination"))
table.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
)
)
table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS
table.border = BorderFactory.createEmptyBorder()
table.fillsViewportHeight = true
@@ -843,4 +931,290 @@ 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>()
var filter: (host: Host) -> Boolean = { true }
private val model = object : DefaultTableModel() {
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
override fun getRowCount(): Int {
return jumpHosts.size
}
override fun getValueAt(row: Int, column: Int): Any {
val host = jumpHosts.getOrNull(row) ?: return StringUtils.EMPTY
return if (column == 0)
host.name
else "${host.host}:${host.port}"
}
}
private val table = JTable(model)
private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add"))
private val moveUpBtn = JButton(I18n.getString("termora.transport.bookmarks.up"))
private val moveDownBtn = JButton(I18n.getString("termora.transport.bookmarks.down"))
private val deleteBtn = JButton(I18n.getString("termora.new-host.tunneling.delete"))
init {
initView()
initEvents()
}
private fun initView() {
val scrollPane = JScrollPane(table)
model.addColumn(I18n.getString("termora.new-host.general.name"))
model.addColumn(I18n.getString("termora.new-host.general.host"))
table.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
)
)
table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS
table.setDefaultRenderer(
Any::class.java,
DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER })
table.fillsViewportHeight = true
scrollPane.border = BorderFactory.createCompoundBorder(
BorderFactory.createEmptyBorder(4, 0, 4, 0),
BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
)
table.border = BorderFactory.createEmptyBorder()
moveUpBtn.isFocusable = false
moveDownBtn.isFocusable = false
deleteBtn.isFocusable = false
moveUpBtn.isEnabled = false
moveDownBtn.isEnabled = false
deleteBtn.isEnabled = false
addBtn.isFocusable = false
val box = Box.createHorizontalBox()
box.add(addBtn)
box.add(Box.createHorizontalStrut(4))
box.add(deleteBtn)
box.add(Box.createHorizontalStrut(4))
box.add(moveUpBtn)
box.add(Box.createHorizontalStrut(4))
box.add(moveDownBtn)
add(JLabel("${getTitle()}:"), BorderLayout.NORTH)
add(scrollPane, BorderLayout.CENTER)
add(box, BorderLayout.SOUTH)
}
private fun initEvents() {
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent?) {
val dialog = HostTreeDialog(owner) { host ->
jumpHosts.none { it.id == host.id } && filter.invoke(host)
}
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
val hosts = dialog.hosts
if (hosts.isEmpty()) {
return
}
hosts.forEach {
val rowCount = model.rowCount
jumpHosts.add(it)
model.fireTableRowsInserted(rowCount, rowCount + 1)
}
}
})
deleteBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val rows = table.selectedRows.sortedDescending()
if (rows.isEmpty()) return
for (row in rows) {
jumpHosts.removeAt(row)
model.fireTableRowsDeleted(row, row)
}
}
})
table.selectionModel.addListSelectionListener {
deleteBtn.isEnabled = table.selectedRowCount > 0
moveUpBtn.isEnabled = deleteBtn.isEnabled && !table.selectedRows.contains(0)
moveDownBtn.isEnabled = deleteBtn.isEnabled && !table.selectedRows.contains(table.rowCount - 1)
}
moveUpBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val rows = table.selectedRows.sorted()
if (rows.isEmpty()) return
table.clearSelection()
for (row in rows) {
val host = jumpHosts[(row)]
jumpHosts.removeAt(row)
jumpHosts.add(row - 1, host)
table.addRowSelectionInterval(row - 1, row - 1)
}
}
})
moveDownBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val rows = table.selectedRows.sortedDescending()
if (rows.isEmpty()) return
table.clearSelection()
for (row in rows) {
val host = jumpHosts[(row)]
jumpHosts.removeAt(row)
jumpHosts.add(row + 1, host)
table.addRowSelectionInterval(row + 1, row + 1)
}
}
})
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.server
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.jump-hosts")
}
override fun getJComponent(): JComponent {
return this
}
}
}

View File

@@ -9,8 +9,9 @@ import java.beans.PropertyChangeEvent
import javax.swing.Icon
abstract class HostTerminalTab(
val windowScope: WindowScope,
val host: Host,
protected val terminal: Terminal = TerminalFactory.instance.createTerminal()
protected val terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal()
) : PropertyTerminalTab() {
companion object {
val Host = DataKey(app.termora.Host::class)

View File

@@ -1,6 +1,8 @@
package app.termora
import app.termora.db.Database
import app.termora.actions.NewHostAction
import app.termora.actions.OpenHostAction
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeOpenIcon
@@ -24,7 +26,7 @@ import javax.swing.tree.TreeSelectionModel
class HostTree : JTree(), Disposable {
private val hostManager get() = HostManager.instance
private val hostManager get() = HostManager.getInstance()
private val editor = OutlineTextField(64)
var contextmenu = true
@@ -67,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
}
@@ -83,7 +87,7 @@ class HostTree : JTree(), Disposable {
})
val state = Database.instance.properties.getString("HostTreeExpansionState")
val state = Database.getDatabase().properties.getString("HostTreeExpansionState")
if (state != null) {
TreeUtils.loadExpansionState(this@HostTree, state)
}
@@ -132,8 +136,8 @@ class HostTree : JTree(), Disposable {
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val host = lastSelectedPathComponent
if (host is Host && host.protocol != Protocol.Folder) {
ActionManager.getInstance().getAction(Actions.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(this, host))
ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(e.source, host, e))
}
}
}
@@ -316,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"))
@@ -328,15 +333,8 @@ class HostTree : JTree(), Disposable {
popupMenu.addSeparator()
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
open.addActionListener {
getSelectionNodes()
.filter { it.protocol != Protocol.Folder }
.forEach {
ActionManager.getInstance()
.getAction(Actions.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(this, it))
}
}
open.addActionListener { openHosts(it, false) }
openInNewWindow.addActionListener { openHosts(it, true) }
rename.addActionListener {
startEditingAtPath(TreePath(model.getPathToRoot(lastHost)))
@@ -412,7 +410,8 @@ class HostTree : JTree(), Disposable {
newHost.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
showAddHostDialog()
ActionManager.getInstance().getAction(NewHostAction.NEW_HOST)
?.actionPerformed(e)
}
})
@@ -451,30 +450,19 @@ class HostTree : JTree(), Disposable {
popupMenu.show(this, event.x, event.y)
}
fun showAddHostDialog() {
var lastHost = lastSelectedPathComponent
if (lastHost !is Host) {
return
}
if (lastHost.protocol != Protocol.Folder) {
val p = model.getParent(lastHost) ?: return
lastHost = p
}
val dialog = HostDialog(SwingUtilities.getWindowAncestor(this))
dialog.isVisible = true
val host = (dialog.host ?: return).copy(parentId = lastHost.id)
runCatchingHost(host)
expandNode(lastHost)
selectionPath = TreePath(model.getPathToRoot(host))
private fun openHosts(evt: EventObject, openInNewWindow: Boolean) {
assertEventDispatchThread()
val nodes = getSelectionNodes().filter { it.protocol != Protocol.Folder }
if (nodes.isEmpty()) return
val openHostAction = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return
val source = if (openInNewWindow)
TermoraFrameManager.getInstance().createWindow().apply { isVisible = true }
else evt.source
nodes.forEach { openHostAction.actionPerformed(OpenHostActionEvent(source, it, evt)) }
}
private fun expandNode(node: Host, including: Boolean = false) {
fun expandNode(node: Host, including: Boolean = false) {
expandPath(TreePath(model.getPathToRoot(node)))
if (including) {
model.getChildren(node).forEach { expandNode(it, true) }
@@ -541,18 +529,23 @@ class HostTree : JTree(), Disposable {
while (parents.isNotEmpty()) {
val p = parents.removeFirst()
for (i in 0 until model.getChildCount(p)) {
val child = model.getChild(p, i) as Host
for (i in 0 until getModel().getChildCount(p)) {
val child = getModel().getChild(p, i) as Host
nodes.add(child)
parents.add(child)
}
}
// 确保是最新的
for (i in 0 until nodes.size) {
nodes[i] = model.getHost(nodes[i].id) ?: continue
}
return nodes
}
override fun dispose() {
Database.instance.properties.putString(
Database.getDatabase().properties.putString(
"HostTreeExpansionState",
TreeUtils.saveExpansionState(this)
)

View File

@@ -1,6 +1,5 @@
package app.termora
import app.termora.db.Database
import java.awt.Dimension
import java.awt.Window
import java.awt.event.MouseAdapter
@@ -10,7 +9,10 @@ import java.awt.event.WindowEvent
import javax.swing.*
import javax.swing.tree.TreeSelectionModel
class HostTreeDialog(owner: Window) : DialogWrapper(owner) {
class HostTreeDialog(
owner: Window,
private val filter: (host: Host) -> Boolean = { true }
) : DialogWrapper(owner) {
private val tree = HostTree()
@@ -34,7 +36,7 @@ class HostTreeDialog(owner: Window) : DialogWrapper(owner) {
title = I18n.getString("termora.transport.sftp.select-host")
tree.setModel(SearchableHostTreeModel(tree.model) { host ->
host.protocol == Protocol.Folder || host.protocol == Protocol.SSH
(host.protocol == Protocol.Folder || host.protocol == Protocol.SSH) && filter.invoke(host)
})
tree.contextmenu = true
tree.doubleClickConnection = false
@@ -51,7 +53,7 @@ class HostTreeDialog(owner: Window) : DialogWrapper(owner) {
addWindowListener(object : WindowAdapter() {
override fun windowActivated(e: WindowEvent) {
removeWindowListener(this)
val state = Database.instance.properties.getString("HostTreeDialog.HostTreeExpansionState")
val state = Database.getDatabase().properties.getString("HostTreeDialog.HostTreeExpansionState")
if (state != null) {
TreeUtils.loadExpansionState(tree, state)
}
@@ -71,7 +73,8 @@ class HostTreeDialog(owner: Window) : DialogWrapper(owner) {
addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
Database.instance.properties.putString(
tree.setModel(null)
Database.getDatabase().properties.putString(
"HostTreeDialog.HostTreeExpansionState",
TreeUtils.saveExpansionState(tree)
)

View File

@@ -10,7 +10,7 @@ class HostTreeModel : TreeModel {
val listeners = mutableListOf<TreeModelListener>()
private val hostManager get() = HostManager.instance
private val hostManager get() = HostManager.getInstance()
private val hosts = mutableMapOf<String, Host>()
private val myRoot by lazy {
Host(

View File

@@ -1,5 +1,6 @@
package app.termora
import app.termora.actions.AnAction
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.FlatSVGIcon.ColorFilter
import org.jdesktop.swingx.JXHyperlink

View File

@@ -40,12 +40,17 @@ object I18n {
}
fun getString(key: String, vararg args: Any): String {
val text = getString(key)
if (args.isNotEmpty()) {
return MessageFormat.format(text, *args)
}
return text
}
fun getString(key: String): String {
try {
val text = substitutor.replace(bundle.getString(key))
if (args.isNotEmpty()) {
return MessageFormat.format(text, *args)
}
return text
return substitutor.replace(bundle.getString(key))
} catch (e: MissingResourceException) {
if (log.isWarnEnabled) {
log.warn(e.message, e)
@@ -54,4 +59,5 @@ object I18n {
}
}
}

View File

@@ -3,17 +3,23 @@ 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") }
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") }
val regex by lazy { DynamicIcon("icons/regex.svg", "icons/regex_dark.svg") }
val vcs by lazy { DynamicIcon("icons/vcs.svg", "icons/vcs_dark.svg") }
val dumpThreads by lazy { DynamicIcon("icons/dumpThreads.svg", "icons/dumpThreads_dark.svg") }
val supertypes by lazy { DynamicIcon("icons/supertypes.svg", "icons/supertypes_dark.svg") }
val fitContent by lazy { DynamicIcon("icons/fitContent.svg", "icons/fitContent_dark.svg") }
val settings by lazy { DynamicIcon("icons/settings.svg", "icons/settings_dark.svg") }
val copy by lazy { DynamicIcon("icons/copy.svg", "icons/copy_dark.svg") }
val delete by lazy { DynamicIcon("icons/delete.svg", "icons/delete_dark.svg") }
val pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") }
val empty by lazy { DynamicIcon("icons/empty.svg") }
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") }
@@ -62,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") }

View File

@@ -8,6 +8,11 @@ import com.formdev.flatlaf.FlatPropertiesLaf
import com.formdev.flatlaf.util.SystemInfo
import java.util.*
interface LafTag
interface LightLafTag : LafTag
interface DarkLafTag : LafTag
class DraculaLaf : FlatPropertiesLaf("Dracula", Properties().apply {
putAll(
mapOf(
@@ -16,7 +21,7 @@ class DraculaLaf : FlatPropertiesLaf("Dracula", Properties().apply {
"@windowText" to "#eaeaea",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Basic.BACKGROUND -> 0x282935
@@ -54,7 +59,8 @@ class DraculaLaf : FlatPropertiesLaf("Dracula", Properties().apply {
}
class LightLaf : FlatLightLaf(), ColorTheme {
class LightLaf : FlatLightLaf(), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0
@@ -81,7 +87,7 @@ class LightLaf : FlatLightLaf(), ColorTheme {
}
class DarkLaf : FlatDarkLaf(), ColorTheme {
class DarkLaf : FlatDarkLaf(), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0
@@ -110,7 +116,7 @@ class DarkLaf : FlatDarkLaf(), ColorTheme {
}
}
class iTerm2DarkLaf : FlatDarkLaf(), ColorTheme {
class iTerm2DarkLaf : FlatDarkLaf(), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
@@ -158,7 +164,7 @@ class TermiusLightLaf : FlatPropertiesLaf("Termius Light", Properties().apply {
"@windowText" to "#32364a",
)
)
}), ColorTheme {
}), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
@@ -201,7 +207,7 @@ class TermiusDarkLaf : FlatPropertiesLaf("Termius Dark", Properties().apply {
"@windowText" to "#21b568",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
@@ -243,7 +249,7 @@ class NovelLaf : FlatPropertiesLaf("Novel", Properties().apply {
"@windowText" to "#3b2322",
)
)
}), ColorTheme {
}), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x000000
@@ -282,7 +288,7 @@ class AtomOneDarkLaf : FlatPropertiesLaf("Atom One Dark", Properties().apply {
"@windowText" to "#abb2bf",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x000000
@@ -320,7 +326,7 @@ class AtomOneLightLaf : FlatPropertiesLaf("Atom One Light", Properties().apply {
"@windowText" to "#383a42",
)
)
}), ColorTheme {
}), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x000000
@@ -358,7 +364,7 @@ class EverforestDarkLaf : FlatPropertiesLaf("Everforest Dark", Properties().appl
"@windowText" to "#d3c6aa",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x42494e
@@ -395,7 +401,7 @@ class EverforestLightLaf : FlatPropertiesLaf("Everforest Light", Properties().ap
"@windowText" to "#5c6a72",
)
)
}), ColorTheme {
}), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x42494e
@@ -432,7 +438,7 @@ class NightOwlLaf : FlatPropertiesLaf("Night Owl", Properties().apply {
"@windowText" to "#d6deeb",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x072945
@@ -469,7 +475,7 @@ class LightOwlLaf : FlatPropertiesLaf("Light Owl", Properties().apply {
"@windowText" to "#403f53",
)
)
}), ColorTheme {
}), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x403f53
@@ -506,7 +512,7 @@ class AuraLaf : FlatPropertiesLaf("Aura", Properties().apply {
"@windowText" to "#edecee",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x1c1b22
@@ -543,7 +549,7 @@ class Cobalt2Laf : FlatPropertiesLaf("Cobalt2", Properties().apply {
"@windowText" to "#ffffff",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x000000
@@ -580,7 +586,7 @@ class OctocatDarkLaf : FlatPropertiesLaf("Octocat Dark", Properties().apply {
"@windowText" to "#8b949e",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x000000
@@ -617,7 +623,7 @@ class OctocatLightLaf : FlatPropertiesLaf("Octocat Light", Properties().apply {
"@windowText" to "#3e3e3e",
)
)
}), ColorTheme {
}), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x000000
@@ -654,7 +660,7 @@ class AyuDarkLaf : FlatPropertiesLaf("Ayu Dark", Properties().apply {
"@windowText" to "#e6e1cf",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x000000
@@ -691,7 +697,7 @@ class AyuLightLaf : FlatPropertiesLaf("Ayu Light", Properties().apply {
"@windowText" to "#5c6773",
)
)
}), ColorTheme {
}), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x000000
@@ -728,7 +734,7 @@ class HomebrewLaf : FlatPropertiesLaf("Homebrew", Properties().apply {
"@windowText" to "#00ff00",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x2e2e2e
@@ -767,7 +773,7 @@ class ProLaf : FlatPropertiesLaf("Pro", Properties().apply {
"@windowText" to "#f2f2f2",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x2e2e2e
@@ -806,7 +812,7 @@ class NordLightLaf : FlatPropertiesLaf("Nord Light", Properties().apply {
"@windowText" to "#414858",
)
)
}), ColorTheme {
}), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x2c3344
@@ -845,7 +851,7 @@ class NordDarkLaf : FlatPropertiesLaf("Nord Dark", Properties().apply {
"@windowText" to "#d8dee9",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x3b4252
@@ -885,7 +891,7 @@ class GitHubLightLaf : FlatPropertiesLaf("GitHub Light", Properties().apply {
"@windowText" to "#3e3e3e",
)
)
}), ColorTheme {
}), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x3e3e3e
@@ -924,7 +930,7 @@ class GitHubDarkLaf : FlatPropertiesLaf("GitHub Dark", Properties().apply {
"@windowText" to "#8b949e",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x000000
@@ -964,7 +970,7 @@ class ChalkLaf : FlatPropertiesLaf("Chalk", Properties().apply {
"@windowText" to "#d2d8d9",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0x7d8b8f

View File

@@ -4,11 +4,12 @@ import app.termora.terminal.PtyConnector
import org.apache.commons.io.Charsets
import java.nio.charset.StandardCharsets
class LocalTerminalTab(host: Host) : PtyHostTerminalTab(host) {
class LocalTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
override suspend fun openPtyConnector(): PtyConnector {
val winSize = terminalPanel.winSize()
val ptyConnector = PtyConnectorFactory.instance.createPtyConnector(
val ptyConnector = PtyConnectorFactory.getInstance(windowScope).createPtyConnector(
winSize.rows, winSize.cols,
host.options.envs(),
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),

View File

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

View File

@@ -1,5 +1,6 @@
package app.termora
import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate
import org.jdesktop.swingx.action.ActionManager
@@ -7,10 +8,15 @@ import org.jdesktop.swingx.action.ActionManager
/**
* 当开启转发时,会获取到所有的 [PtyConnector] 然后跳过中间层,直接找到最近的一个 [MultiplePtyConnector],如果找不到那就以最后一个匹配不到的为准 [getMultiplePtyConnector]。
*/
class MultiplePtyConnector(private val myConnector: PtyConnector) : PtyConnectorDelegate(myConnector) {
class MultiplePtyConnector(
private val myConnector: PtyConnector
) : PtyConnectorDelegate(myConnector) {
private val isMultiple get() = ActionManager.getInstance().isSelected(Actions.MULTIPLE)
private val ptyConnectors get() = PtyConnectorFactory.instance.getPtyConnectors()
private val ptyConnectors
get() = ApplicationScope.forApplicationScope()
.windowScopes().map { PtyConnectorFactory.getInstance(it).getPtyConnectors() }
.flatten()
override fun write(buffer: ByteArray, offset: Int, len: Int) {
if (isMultiple) {

View File

@@ -1,12 +1,13 @@
package app.termora
import app.termora.actions.ActionManager
import app.termora.terminal.Terminal
import app.termora.terminal.TerminalColor
import app.termora.terminal.TextStyle
import app.termora.terminal.panel.TerminalDisplay
import app.termora.terminal.panel.TerminalPaintListener
import app.termora.terminal.panel.TerminalPanel
import org.jdesktop.swingx.action.ActionManager
import java.awt.Color
import java.awt.Graphics

View File

@@ -1,11 +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)
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)
}
}
}
}

View File

@@ -1,5 +1,7 @@
package app.termora
import java.awt.event.ActionEvent
import app.termora.actions.AnActionEvent
import java.util.*
class OpenHostActionEvent(source: Any, val host: Host) : ActionEvent(source, ACTION_PERFORMED, String())
class OpenHostActionEvent(source: Any, val host: Host, event: EventObject) :
AnActionEvent(source, String(), event)

View File

@@ -1,22 +1,26 @@
package app.termora
import app.termora.db.Database
import app.termora.macro.MacroPtyConnector
import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate
import app.termora.terminal.PtyProcessConnector
import com.pty4j.PtyProcessBuilder
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.slf4j.LoggerFactory
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.util.*
class PtyConnectorFactory {
class PtyConnectorFactory : Disposable {
private val ptyConnectors = Collections.synchronizedList(mutableListOf<PtyConnector>())
private val database get() = Database.instance
private val database get() = Database.getDatabase()
companion object {
val instance by lazy { PtyConnectorFactory() }
private val log = LoggerFactory.getLogger(PtyConnectorFactory::class.java)
fun getInstance(scope: Scope): PtyConnectorFactory {
return scope.getOrCreate(PtyConnectorFactory::class) { PtyConnectorFactory() }
}
}
fun createPtyConnector(
@@ -29,12 +33,27 @@ class PtyConnectorFactory {
envs["TERM"] = "xterm-256color"
envs.putAll(env)
if (SystemUtils.IS_OS_UNIX) {
if (!envs.containsKey("LANG")) {
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"
}
}
}
val command = database.terminal.localShell
val commands = mutableListOf(command)
if (SystemUtils.IS_OS_UNIX) {
commands.add("-l")
}
if (log.isDebugEnabled) {
log.debug("command: {} , envs: {}", commands.joinToString(" "), envs)
}
val ptyProcess = PtyProcessBuilder(commands.toTypedArray())
.setEnvironment(envs)
.setInitialRows(rows)

View File

@@ -3,6 +3,7 @@ package app.termora
import app.termora.terminal.PtyConnector
import app.termora.terminal.Terminal
import kotlinx.coroutines.delay
import org.slf4j.LoggerFactory
import javax.swing.SwingUtilities
import kotlin.time.Duration.Companion.milliseconds
@@ -11,9 +12,14 @@ class PtyConnectorReader(
private val terminal: Terminal,
) {
companion object {
private val log = LoggerFactory.getLogger(PtyConnectorReader::class.java)
}
suspend fun start() {
var i: Int
val buffer = CharArray(1024 * 8)
while ((ptyConnector.read(buffer).also { i = it }) != -1) {
if (i == 0) {
delay(10.milliseconds)
@@ -22,6 +28,10 @@ class PtyConnectorReader(
val text = String(buffer, 0, i)
SwingUtilities.invokeLater { terminal.write(text) }
}
if (log.isDebugEnabled) {
log.debug("PtyConnectorReader stopped")
}
}
}

View File

@@ -10,9 +10,10 @@ import javax.swing.JComponent
import kotlin.time.Duration.Companion.milliseconds
abstract class PtyHostTerminalTab(
windowScope: WindowScope,
host: Host,
terminal: Terminal = TerminalFactory.instance.createTerminal()
) : HostTerminalTab(host, terminal) {
terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal()
) : HostTerminalTab(windowScope, host, terminal) {
companion object {
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
@@ -22,8 +23,14 @@ abstract class PtyHostTerminalTab(
private var readerJob: Job? = null
private val ptyConnectorDelegate = PtyConnectorDelegate()
protected val terminalPanel = TerminalPanelFactory.instance.createTerminalPanel(terminal, ptyConnectorDelegate)
protected val ptyConnectorFactory get() = PtyConnectorFactory.instance
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 {
terminal.getTerminalModel().setData(DataKey.PtyConnector, ptyConnectorDelegate)
}
override fun start() {
coroutineScope.launch(Dispatchers.IO) {
@@ -47,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)
)
}
}
}
@@ -110,6 +121,7 @@ abstract class PtyHostTerminalTab(
override fun dispose() {
stop()
terminalPanel
super.dispose()

View File

@@ -1,5 +1,6 @@
package app.termora
import app.termora.transport.TransportDataProviders
import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener
import javax.swing.Icon
@@ -40,8 +41,8 @@ class SFTPTerminalTab : Disposable, TerminalTab {
override fun canClose(): Boolean {
assertEventDispatchThread()
if (transportPanel.transportManager.getTransports().isEmpty()) {
val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true
if (transportManager.getTransports().isEmpty()) {
return true
}

View File

@@ -1,7 +1,12 @@
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
import app.termora.keymap.KeyShortcut
import app.termora.keymap.KeymapManager
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector
@@ -24,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(host: Host) : PtyHostTerminalTab(host) {
class SSHTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
companion object {
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
}
@@ -38,6 +45,9 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
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
@@ -109,12 +119,32 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
channel.addChannelListener(object : ChannelListener {
private val reconnectShortcut
get() = KeymapManager.getInstance().getActiveKeymap()
.getShortcut(TabReconnectAction.RECONNECT_TAB).firstOrNull()
override fun channelClosed(channel: Channel, reason: Throwable?) {
coroutineScope.launch(Dispatchers.Swing) {
terminal.write("\r\n${ControlCharacters.ESC}[31m")
terminal.write("Channel has been disconnected.\r\n")
terminal.write("\r\n\r\n${ControlCharacters.ESC}[31m")
terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
if (reconnectShortcut is KeyShortcut) {
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)
}
}
}
}
}
})

View File

@@ -0,0 +1,173 @@
package app.termora
import org.slf4j.LoggerFactory
import java.awt.Component
import java.awt.Window
import java.util.concurrent.ConcurrentHashMap
import javax.swing.JPopupMenu
import javax.swing.SwingUtilities
import kotlin.reflect.KClass
@Suppress("UNCHECKED_CAST")
open class Scope(
private val beans: MutableMap<KClass<*>, Any> = ConcurrentHashMap(),
private val properties: MutableMap<String, Any> = ConcurrentHashMap()
) : Disposable {
fun <T : Any> get(clazz: KClass<T>): T {
return beans[clazz] as T
}
fun <T : Any> getOrCreate(clazz: KClass<T>, create: () -> T): T {
if (beans.containsKey(clazz)) {
return get(clazz)
}
synchronized(clazz) {
if (beans.containsKey(clazz)) {
return get(clazz)
}
val instance = create.invoke()
beans[clazz] = instance
if (instance is Disposable) {
Disposer.register(this, instance)
}
return instance
}
}
fun putBoolean(name: String, value: Boolean) {
properties[name] = value
}
fun getBoolean(name: String, defaultValue: Boolean): Boolean {
return properties[name]?.toString()?.toBoolean() ?: defaultValue
}
fun putAny(name: String, value: Any) {
properties[name] = value
}
fun getAny(name: String, defaultValue: Any): Any {
return properties[name]?.toString() ?: defaultValue
}
fun getAnyOrNull(name: String): Any? {
return properties[name]
}
override fun dispose() {
beans.clear()
}
}
class ApplicationScope private constructor() : Scope() {
private val scopes = mutableMapOf<Any, WindowScope>()
companion object {
private val log = LoggerFactory.getLogger(ApplicationScope::class.java)
private val instance by lazy { ApplicationScope() }
fun forApplicationScope(): ApplicationScope {
return instance
}
fun forWindowScope(frame: TermoraFrame): WindowScope {
return forApplicationScope().forWindowScope(frame)
}
fun forWindowScope(container: Component): WindowScope {
val frame = getFrameForComponent(container)
?: throw IllegalStateException("Unexpected owner in $container")
return forWindowScope(frame)
}
fun windowScopes(): List<WindowScope> {
return forApplicationScope().windowScopes()
}
private fun getFrameForComponent(component: Component): TermoraFrame? {
if (component is TermoraFrame) {
return component
}
var owner = SwingUtilities.getWindowAncestor(component) as Component?
if (owner is TermoraFrame) {
return owner
}
if (owner == null) {
owner = component
}
while (owner != null) {
if (owner is JPopupMenu) {
owner = owner.invoker
if (owner is TermoraFrame) {
return owner
}
continue
}
owner = owner.parent
if (owner is TermoraFrame) {
return owner
}
}
return null
}
}
private fun forWindowScope(frame: TermoraFrame): WindowScope {
val windowScope = scopes.getOrPut(frame) { WindowScope(frame) }
Disposer.register(windowScope, object : Disposable {
override fun dispose() {
scopes.remove(frame)
}
})
return windowScope
}
fun windowScopes(): List<WindowScope> {
return scopes.values.toList()
}
override fun dispose() {
if (log.isInfoEnabled) {
log.info("ApplicationScope disposed")
}
super.dispose()
}
}
class WindowScope(
val window: Window,
) : Scope() {
companion object {
private val log = LoggerFactory.getLogger(WindowScope::class.java)
}
override fun dispose() {
if (log.isInfoEnabled) {
log.info("WindowScope disposed")
}
super.dispose()
}
}

View File

@@ -48,10 +48,10 @@ class SearchableHostTreeModel(
val children = model.getChildren(parent)
if (children.isEmpty()) return emptyList()
return children.filter { e ->
filter.invoke(e) && e.name.contains(text, true) || TreeUtils.children(model, e, true)
.filterIsInstance<Host>().any {
it.name.contains(text, true)
}
filter.invoke(e)
&& e.name.contains(text, true)
|| e.host.contains(text, true)
|| TreeUtils.children(model, e, true).filterIsInstance<Host>().any { it.name.contains(text, true) || it.host.contains(text, true) }
}
}

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

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

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

View File

@@ -1,6 +1,5 @@
package app.termora
import app.termora.db.Database
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
@@ -13,7 +12,7 @@ import javax.swing.UIManager
class SettingsDialog(owner: Window) : DialogWrapper(owner) {
private val optionsPane = SettingsOptionsPane()
private val properties get() = Database.instance.properties
private val properties get() = Database.getDatabase().properties
init {
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))

View File

@@ -2,9 +2,16 @@ package app.termora
import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.db.Database
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
@@ -15,11 +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.FlatLaf
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.FlatButton
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatLabel
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
@@ -28,19 +34,18 @@ 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.ActionEvent
import java.awt.event.ItemEvent
import java.io.File
import java.net.URI
@@ -48,12 +53,19 @@ import java.nio.charset.StandardCharsets
import java.util.*
import javax.swing.*
import javax.swing.event.DocumentEvent
import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener
import kotlin.time.Duration.Companion.milliseconds
class SettingsOptionsPane : OptionsPane() {
private val owner get() = SwingUtilities.getWindowAncestor(this@SettingsOptionsPane)
private val database get() = Database.instance
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)
@@ -96,6 +108,7 @@ class SettingsOptionsPane : OptionsPane() {
init {
addOption(AppearanceOption())
addOption(TerminalOption())
addOption(KeyShortcutsOption())
addOption(CloudSyncOption())
addOption(DoormanOption())
addOption(AboutOption())
@@ -103,10 +116,11 @@ class SettingsOptionsPane : OptionsPane() {
}
private inner class AppearanceOption : JPanel(BorderLayout()), Option {
val themeManager = ThemeManager.instance
val themeManager = ThemeManager.getInstance()
val themeComboBox = FlatComboBox<String>()
val languageComboBox = FlatComboBox<String>()
val followSystemCheckBox = JCheckBox(I18n.getString("termora.settings.appearance.follow-system"))
val preferredThemeBtn = JButton(Icons.settings)
private val appearance get() = database.appearance
init {
@@ -117,6 +131,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun initView() {
followSystemCheckBox.isSelected = appearance.followSystem
preferredThemeBtn.isEnabled = followSystemCheckBox.isSelected
themeComboBox.isEnabled = !followSystemCheckBox.isSelected
themeManager.themes.keys.forEach { themeComboBox.addItem(it) }
@@ -156,19 +171,17 @@ class SettingsOptionsPane : OptionsPane() {
followSystemCheckBox.addActionListener {
appearance.followSystem = followSystemCheckBox.isSelected
themeComboBox.isEnabled = !followSystemCheckBox.isSelected
preferredThemeBtn.isEnabled = followSystemCheckBox.isSelected
appearance.theme = themeComboBox.selectedItem as String
if (followSystemCheckBox.isSelected) {
SwingUtilities.invokeLater {
if (OsThemeDetector.getDetector().isDark) {
if (!FlatLaf.isLafDark()) {
themeManager.change("Dark")
themeComboBox.selectedItem = "Dark"
}
themeManager.change(appearance.darkTheme)
themeComboBox.selectedItem = appearance.darkTheme
} else {
if (FlatLaf.isLafDark()) {
themeManager.change("Light")
themeComboBox.selectedItem = "Light"
}
themeManager.change(appearance.lightTheme)
themeComboBox.selectedItem = appearance.lightTheme
}
}
}
@@ -187,6 +200,8 @@ class SettingsOptionsPane : OptionsPane() {
}
}
}
preferredThemeBtn.addActionListener { showPreferredThemeContextmenu() }
}
override fun getIcon(isSelected: Boolean): Icon {
@@ -201,23 +216,78 @@ class SettingsOptionsPane : OptionsPane() {
return this
}
private fun showPreferredThemeContextmenu() {
val popupMenu = FlatPopupMenu()
val dark = JMenu("For Dark OS")
val light = JMenu("For Light OS")
val darkTheme = appearance.darkTheme
val lightTheme = appearance.lightTheme
for (e in themeManager.themes) {
val clazz = Class.forName(e.value)
val item = JCheckBoxMenuItem(e.key)
item.isSelected = e.key == lightTheme || e.key == darkTheme
if (clazz.interfaces.contains(DarkLafTag::class.java)) {
dark.add(item).addActionListener {
if (e.key != darkTheme) {
appearance.darkTheme = e.key
if (OsThemeDetector.getDetector().isDark) {
themeComboBox.selectedItem = e.key
}
}
}
} else if (clazz.interfaces.contains(LightLafTag::class.java)) {
light.add(item).addActionListener {
if (e.key != lightTheme) {
appearance.lightTheme = e.key
if (!OsThemeDetector.getDetector().isDark) {
themeComboBox.selectedItem = e.key
}
}
}
}
}
popupMenu.add(dark)
popupMenu.addSeparator()
popupMenu.add(light)
popupMenu.addPopupMenuListener(object : PopupMenuListener {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
}
override fun popupMenuCanceled(e: PopupMenuEvent) {
}
})
popupMenu.show(preferredThemeBtn, 0, preferredThemeBtn.height + 2)
}
private fun getFormPanel(): JPanel {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, default, default:grow",
"pref, $formMargin, pref, $formMargin"
)
val box = FlatToolBar()
box.add(followSystemCheckBox)
box.add(Box.createHorizontalStrut(2))
box.add(preferredThemeBtn)
var rows = 1
val step = 2
return FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.settings.appearance.theme")}:").xy(1, rows)
.add(themeComboBox).xy(3, rows)
.add(followSystemCheckBox).xy(5, rows).apply { rows += step }
.add(box).xy(5, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.appearance.language")}:").xy(1, rows)
.add(languageComboBox).xy(3, rows)
.add(Hyperlink(object : AnAction(I18n.getString("termora.settings.appearance.i-want-to-translate")) {
override fun actionPerformed(evt: ActionEvent) {
override fun actionPerformed(evt: AnActionEvent) {
Application.browse(URI.create("https://github.com/TermoraDev/termora/tree/main/src/main/resources/i18n"))
}
})).xy(5, rows).apply { rows += step }
@@ -230,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.instance.terminal
private val terminalSetting get() = Database.getDatabase().terminal
private val selectCopyComboBox = YesOrNoComboBox()
private val autoCloseTabComboBox = YesOrNoComboBox()
init {
initView()
@@ -251,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
@@ -270,7 +349,7 @@ class SettingsOptionsPane : OptionsPane() {
if (it.stateChange == ItemEvent.SELECTED) {
val style = cursorStyleComboBox.selectedItem as CursorStyle
terminalSetting.cursor = style
TerminalFactory.instance.getTerminals().forEach { e ->
TerminalFactory.getInstance(ApplicationScope.forWindowScope(owner)).getTerminals().forEach { e ->
e.getTerminalModel().setData(DataKey.CursorStyle, style)
}
}
@@ -280,13 +359,20 @@ class SettingsOptionsPane : OptionsPane() {
debugComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.debug = debugComboBox.selectedItem as Boolean
TerminalFactory.instance.getTerminals().forEach {
TerminalFactory.getInstance(ApplicationScope.forWindowScope(owner)).getTerminals().forEach {
it.getTerminalModel().setData(TerminalPanel.Debug, terminalSetting.debug)
}
}
}
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
@@ -296,7 +382,10 @@ class SettingsOptionsPane : OptionsPane() {
}
private fun fireFontChanged() {
TerminalPanelFactory.instance.fireResize()
ApplicationScope.windowScopes().forEach {
TerminalPanelFactory.getInstance(it)
.fireResize()
}
}
private fun initView() {
@@ -318,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)
@@ -330,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 {
@@ -354,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)
@@ -369,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()
@@ -390,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
@@ -397,6 +546,7 @@ class SettingsOptionsPane : OptionsPane() {
val keysCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keys"))
val keywordHighlightsCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keyword-highlights"))
val macrosCheckBox = JCheckBox(I18n.getString("termora.macro"))
val keymapCheckBox = JCheckBox(I18n.getString("termora.settings.keymap"))
val visitGistBtn = JButton(Icons.externalLink)
val getTokenBtn = JButton(Icons.externalLink)
@@ -430,12 +580,6 @@ class SettingsOptionsPane : OptionsPane() {
}
}
if (typeComboBox.selectedItem == SyncType.Gitee) {
gistTextField.trailingComponent = null
} else {
gistTextField.trailingComponent = visitGistBtn
}
removeAll()
add(getCenterComponent(), BorderLayout.CENTER)
revalidate()
@@ -489,13 +633,18 @@ class SettingsOptionsPane : OptionsPane() {
getTokenBtn.addActionListener {
when (typeComboBox.selectedItem) {
SyncType.GitLab -> Application.browse(URI.create("https://gitlab.com/-/user_settings/personal_access_tokens"))
SyncType.GitLab -> {
val uri = URI.create(domainTextField.text)
Application.browse(URI.create("${uri.scheme}://${uri.host}/-/user_settings/personal_access_tokens?name=Termora%20Sync%20Config&scopes=api"))
}
SyncType.GitHub -> Application.browse(URI.create("https://github.com/settings/tokens"))
SyncType.Gitee -> Application.browse(URI.create("https://gitee.com/profile/personal_access_tokens"))
}
}
exportConfigButton.addActionListener { export() }
importConfigButton.addActionListener { import() }
keysCheckBox.addActionListener { refreshButtons() }
hostsCheckBox.addActionListener { refreshButtons() }
@@ -512,6 +661,7 @@ class SettingsOptionsPane : OptionsPane() {
|| keywordHighlightsCheckBox.isSelected
uploadConfigButton.isEnabled = downloadConfigButton.isEnabled
exportConfigButton.isEnabled = downloadConfigButton.isEnabled
importConfigButton.isEnabled = downloadConfigButton.isEnabled
}
private fun export() {
@@ -527,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 {
@@ -537,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.instance.hosts()))
put("hosts", ohMyJson.encodeToJsonElement(hostManager.hosts()))
}
if (syncConfig.ranges.contains(SyncRange.KeyPairs)) {
put("keyPairs", ohMyJson.encodeToJsonElement(KeyManager.instance.getOhKeyPairs()))
put("keyPairs", ohMyJson.encodeToJsonElement(keyManager.getOhKeyPairs()))
}
if (syncConfig.ranges.contains(SyncRange.KeywordHighlights)) {
put(
"keywordHighlights",
ohMyJson.encodeToJsonElement(KeywordHighlightManager.instance.getKeywordHighlights())
ohMyJson.encodeToJsonElement(keywordHighlightManager.getKeywordHighlights())
)
}
if (syncConfig.ranges.contains(SyncRange.Macros)) {
put(
"macros",
ohMyJson.encodeToJsonElement(MacroManager.instance.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 {
@@ -584,6 +845,9 @@ class SettingsOptionsPane : OptionsPane() {
if (macrosCheckBox.isSelected) {
range.add(SyncRange.Macros)
}
if (keymapCheckBox.isSelected) {
range.add(SyncRange.Keymap)
}
return SyncConfig(
type = typeComboBox.selectedItem as SyncType,
token = String(tokenTextField.password),
@@ -593,6 +857,7 @@ class SettingsOptionsPane : OptionsPane() {
)
}
@Suppress("DuplicatedCode")
private suspend fun pushOrPull(push: Boolean) {
if (typeComboBox.selectedItem == SyncType.GitLab) {
@@ -648,6 +913,7 @@ class SettingsOptionsPane : OptionsPane() {
withContext(Dispatchers.Swing) {
exportConfigButton.isEnabled = false
importConfigButton.isEnabled = false
downloadConfigButton.isEnabled = false
uploadConfigButton.isEnabled = false
typeComboBox.isEnabled = false
@@ -655,6 +921,7 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.isEnabled = false
keysCheckBox.isEnabled = false
macrosCheckBox.isEnabled = false
keymapCheckBox.isEnabled = false
keywordHighlightsCheckBox.isEnabled = false
hostsCheckBox.isEnabled = false
domainTextField.isEnabled = false
@@ -670,7 +937,7 @@ class SettingsOptionsPane : OptionsPane() {
// sync
val syncResult = kotlin.runCatching {
val syncer = SyncerProvider.instance.getSyncer(syncConfig.type)
val syncer = SyncerProvider.getInstance().getSyncer(syncConfig.type)
if (push) {
syncer.push(syncConfig)
} else {
@@ -682,11 +949,13 @@ class SettingsOptionsPane : OptionsPane() {
withContext(Dispatchers.Swing) {
downloadConfigButton.isEnabled = true
exportConfigButton.isEnabled = true
importConfigButton.isEnabled = true
uploadConfigButton.isEnabled = true
keysCheckBox.isEnabled = true
hostsCheckBox.isEnabled = true
typeComboBox.isEnabled = true
macrosCheckBox.isEnabled = true
keymapCheckBox.isEnabled = true
gistTextField.isEnabled = true
tokenTextField.isEnabled = true
domainTextField.isEnabled = true
@@ -742,23 +1011,50 @@ 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
keywordHighlightsCheckBox.isFocusable = false
macrosCheckBox.isFocusable = false
keymapCheckBox.isFocusable = false
hostsCheckBox.isSelected = sync.rangeHosts
keysCheckBox.isSelected = sync.rangeKeyPairs
keywordHighlightsCheckBox.isSelected = sync.rangeKeywordHighlights
macrosCheckBox.isSelected = sync.rangeMacros
keymapCheckBox.isSelected = sync.rangeKeymap
typeComboBox.selectedItem = sync.type
gistTextField.text = sync.gist
tokenTextField.text = sync.token
domainTextField.trailingComponent = JButton(Icons.externalLink).apply {
addActionListener {
Application.browse(URI.create("https://docs.gitlab.com/ee/api/snippets.html"))
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()))
}
}
}
}
@@ -768,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 (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(
@@ -814,33 +1113,55 @@ class SettingsOptionsPane : OptionsPane() {
.add(keysCheckBox).xy(3, 1)
.add(keywordHighlightsCheckBox).xy(5, 1)
.add(macrosCheckBox).xy(1, 3)
.add(keymapCheckBox).xy(3, 3)
.build()
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 }
@@ -905,10 +1226,10 @@ class SettingsOptionsPane : OptionsPane() {
private fun createHyperlink(url: String, text: String = url): Hyperlink {
return Hyperlink(object : AnAction(text) {
override fun actionPerformed(evt: ActionEvent) {
override fun actionPerformed(evt: AnActionEvent) {
Application.browse(URI.create(url))
}
});
})
}
private fun initEvents() {}
@@ -934,9 +1255,7 @@ class SettingsOptionsPane : OptionsPane() {
private val twoPasswordTextField = OutlinePasswordField(255)
private val tip = FlatLabel()
private val safeBtn = FlatButton()
private val doorman get() = Doorman.instance
private val hostManager get() = HostManager.instance
private val keyManager get() = KeyManager.instance
private val doorman get() = Doorman.getInstance()
init {
initView()
@@ -1159,5 +1478,35 @@ class SettingsOptionsPane : OptionsPane() {
}
private inner class KeyShortcutsOption : JPanel(BorderLayout()), Option {
private val keymapPanel = KeymapPanel()
init {
initView()
initEvents()
}
private fun initView() {
add(keymapPanel, BorderLayout.CENTER)
}
private fun initEvents() {}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.fitContent
}
override fun getTitle(): String {
return I18n.getString("termora.settings.keymap")
}
override fun getJComponent(): JComponent {
return this
}
}
}

View File

@@ -5,10 +5,15 @@ import app.termora.terminal.TerminalSize
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
import org.apache.sshd.server.forward.RejectAllForwardingFilter
@@ -16,14 +21,15 @@ import org.eclipse.jgit.internal.transport.sshd.JGitSshClient
import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
import org.eclipse.jgit.transport.sshd.ProxyData
import org.slf4j.LoggerFactory
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
import kotlin.math.max
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
object SshClients {
private val timeout = Duration.ofSeconds(30)
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
/**
* 打开一个 Shell
@@ -57,6 +63,54 @@ object SshClients {
* 打开一个会话
*/
fun openSession(host: Host, client: SshClient): ClientSession {
// 如果没有跳板机直接连接
if (host.options.jumpHosts.isEmpty()) {
return doOpenSession(host, client)
}
val jumpHosts = mutableListOf<Host>()
val hosts = HostManager.getInstance().hosts().associateBy { it.id }
for (jumpHostId in host.options.jumpHosts) {
val e = hosts[jumpHostId]
if (e == null) {
if (log.isWarnEnabled) {
log.warn("Failed to find jump host: $jumpHostId")
}
continue
}
jumpHosts.add(e)
}
// 最后一跳是目标机器
jumpHosts.add(host)
val sessions = mutableListOf<ClientSession>()
for (i in 0 until jumpHosts.size) {
val currentHost = jumpHosts[i]
sessions.add(doOpenSession(currentHost, client))
// 如果有下一跳
if (i < jumpHosts.size - 1) {
val nextHost = jumpHosts[i + 1]
// 通过 currentHost 的 Session 将远程端口映射到本地
val address = sessions.last().startLocalPortForwarding(
SshdSocketAddress.LOCALHOST_ADDRESS,
SshdSocketAddress(nextHost.host, nextHost.port),
)
if (log.isInfoEnabled) {
log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
}
// 映射完毕之后修改Host和端口
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port)
}
}
return sessions.last()
}
private fun doOpenSession(host: Host, client: SshClient): ClientSession {
val session = client.connect(host.username, host.host, host.port)
.verify(timeout).session
if (host.authentication.type == AuthenticationType.Password) {
@@ -73,6 +127,7 @@ object SshClients {
return session
}
/**
* 打开一个客户端
*/
@@ -81,7 +136,19 @@ object SshClients {
builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE))
.factory { JGitSshClient() }
if (host.tunnelings.isEmpty()) {
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 {
builder.forwardingFilter(AcceptAllForwardingFilter.INSTANCE)
@@ -90,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) {

View File

@@ -1,24 +1,31 @@
package app.termora
import app.termora.db.Database
import app.termora.terminal.*
import app.termora.terminal.panel.TerminalPanel
import app.termora.tlog.TerminalLoggerDataListener
import java.awt.Color
import javax.swing.UIManager
class TerminalFactory {
class TerminalFactory private constructor() : Disposable {
private val terminals = mutableListOf<Terminal>()
companion object {
val instance by lazy { TerminalFactory() }
fun getInstance(scope: WindowScope): TerminalFactory {
return scope.getOrCreate(TerminalFactory::class) { TerminalFactory() }
}
}
fun createTerminal(): Terminal {
val terminal = MyVisualTerminal()
// 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
@@ -38,7 +45,7 @@ class TerminalFactory {
open class MyTerminalModel(terminal: Terminal) : TerminalModelImpl(terminal) {
private val colorPalette by lazy { MyColorPalette(terminal) }
private val config get() = Database.instance.terminal
private val config get() = Database.getDatabase().terminal
init {
this.setData(DataKey.CursorStyle, config.cursor)
@@ -49,6 +56,11 @@ class TerminalFactory {
return colorPalette
}
override fun bell() {
if (config.beep) {
super.bell()
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(key: DataKey<T>): T {
@@ -95,7 +107,7 @@ class TerminalFactory {
TerminalColor.Basic.SELECTION_FOREGROUND
)
else -> DefaultColorTheme.instance.getColor(color)
else -> DefaultColorTheme.getInstance().getColor(color)
}
}
@@ -108,4 +120,6 @@ class TerminalFactory {
return colorTheme
}
}
}

View File

@@ -13,14 +13,21 @@ class TerminalPanelFactory {
private val terminalPanels = mutableListOf<TerminalPanel>()
companion object {
val instance by lazy { TerminalPanelFactory() }
fun getInstance(scope: Scope): TerminalPanelFactory {
return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
}
}
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
val terminalPanel = TerminalPanel(terminal, ptyConnector)
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.instance)
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.instance)
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
Disposer.register(terminalPanel, object : Disposable {
override fun dispose() {
terminalPanels.remove(terminalPanel)
}
})
terminalPanels.add(terminalPanel)
return terminalPanel
}
@@ -45,4 +52,8 @@ class TerminalPanelFactory {
}
}
fun removeTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.remove(terminalPanel)
}
}

View File

@@ -1,5 +1,9 @@
package app.termora
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders
import app.termora.terminal.DataKey
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
@@ -11,7 +15,9 @@ class TerminalTabDialog(
owner: Window,
size: Dimension,
private val terminalTab: TerminalTab
) : DialogWrapper(null), Disposable {
) : DialogWrapper(null), Disposable, DataProvider {
private val dataProviderSupport = DataProviderSupport()
init {
title = terminalTab.getTitle()
@@ -19,6 +25,7 @@ class TerminalTabDialog(
isAlwaysOnTop = false
iconImages = owner.iconImages
escapeDispose = false
processGlobalKeymap = true
super.setSize(size)
@@ -34,6 +41,15 @@ class TerminalTabDialog(
})
setLocationRelativeTo(null)
if (owner is DataProvider) {
owner.getData(DataProviders.WindowScope)?.let {
dataProviderSupport.addData(DataProviders.WindowScope, it)
}
}
dataProviderSupport.addData(DataProviders.TerminalTab, terminalTab)
}
override fun createSouthPanel(): JComponent? {
@@ -52,4 +68,8 @@ class TerminalTabDialog(
super<DialogWrapper>.dispose()
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey)
}
}

View File

@@ -1,29 +1,41 @@
package app.termora
import app.termora.actions.*
import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhere
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.terminal.DataKey
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.jdesktop.swingx.action.ActionManager
import org.apache.commons.lang3.StringUtils
import java.awt.*
import java.awt.event.*
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(
private val windowScope: WindowScope,
private val termoraToolBar: TermoraToolBar,
private val tabbedPane: FlatTabbedPane,
) : JPanel(BorderLayout()), Disposable, TerminalTabbedManager {
) : JPanel(BorderLayout()), Disposable, TerminalTabbedManager, DataProvider {
private val tabs = mutableListOf<TerminalTab>()
private val customizeToolBarAWTEventListener = CustomizeToolBarAWTEventListener()
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) {
@@ -45,13 +57,14 @@ 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)
windowScope.getOrCreate(TerminalTabbedManager::class) { this }
dataProviderSupport.addData(DataProviders.TerminalTabbed, this)
dataProviderSupport.addData(DataProviders.TerminalTabbedManager, this)
}
@@ -79,35 +92,6 @@ class TerminalTabbed(
}
}
// 快捷键
val inputMap = getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
for (i in KeyEvent.VK_1..KeyEvent.VK_9) {
val tabIndex = i - KeyEvent.VK_1 + 1
val actionKey = "select_$tabIndex"
actionMap.put(actionKey, object : AnAction() {
override fun actionPerformed(e: ActionEvent) {
tabbedPane.selectedIndex = if (i == KeyEvent.VK_9 || tabIndex > tabbedPane.tabCount) {
tabbedPane.tabCount - 1
} else {
tabIndex - 1
}
}
})
inputMap.put(KeyStroke.getKeyStroke(i, toolkit.menuShortcutKeyMaskEx), actionKey)
}
// 关闭 tab
actionMap.put("closeTab", object : AnAction() {
override fun actionPerformed(e: ActionEvent) {
if (tabbedPane.selectedIndex >= 0) {
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabbedPane.selectedIndex)
}
}
})
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_W, toolkit.menuShortcutKeyMaskEx), "closeTab")
// 右键菜单
tabbedPane.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
@@ -136,44 +120,35 @@ class TerminalTabbed(
})
// 注册全局搜索
FindEverywhere.registerProvider(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider {
override fun find(pattern: String): List<FindEverywhereResult> {
val results = mutableListOf<FindEverywhereResult>()
for (i in 0 until tabbedPane.tabCount) {
val c = tabbedPane.getComponentAt(i)
if (c is WelcomePanel || c is TransportPanel) {
continue
}
results.add(
SwitchFindEverywhereResult(
tabbedPane.getTitleAt(i),
tabbedPane.getIconAt(i),
tabbedPane.getComponentAt(i)
FindEverywhereProvider.getFindEverywhereProviders(windowScope)
.add(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider {
override fun find(pattern: String): List<FindEverywhereResult> {
val results = mutableListOf<FindEverywhereResult>()
for (i in 0 until tabbedPane.tabCount) {
val c = tabbedPane.getComponentAt(i)
if (c is WelcomePanel || c is TransportPanel) {
continue
}
results.add(
SwitchFindEverywhereResult(
tabbedPane.getTitleAt(i),
tabbedPane.getIconAt(i),
tabbedPane.getComponentAt(i)
)
)
)
}
return results
}
return results
}
override fun group(): String {
return I18n.getString("termora.find-everywhere.groups.opened-hosts")
}
override fun order(): Int {
return Integer.MIN_VALUE + 1
}
}))
// 打开 Host
ActionManager.getInstance().addAction(Actions.OPEN_HOST, object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (e !is OpenHostActionEvent) {
return
override fun group(): String {
return I18n.getString("termora.find-everywhere.groups.opened-hosts")
}
openHost(e.host)
}
})
override fun order(): Int {
return Integer.MIN_VALUE + 1
}
}))
// 监听全局事件
toolkit.addAWTEventListener(customizeToolBarAWTEventListener, AWTEvent.MOUSE_EVENT_MASK)
@@ -208,14 +183,6 @@ class TerminalTabbed(
}
}
private fun openHost(host: Host) {
val tab = if (host.protocol == Protocol.SSH) SSHTerminalTab(host) else LocalTerminalTab(host)
addTab(tab)
tab.start()
}
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
val c = tabbedPane.getComponentAt(tabIndex) as JComponent
val tab = tabs[tabIndex]
@@ -225,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)
}
}
@@ -242,32 +209,34 @@ class TerminalTabbed(
// 克隆
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
clone.addActionListener {
clone.addActionListener { evt ->
if (tab is HostTerminalTab) {
ActionManager.getInstance()
.getAction(Actions.OPEN_HOST)
.actionPerformed(OpenHostActionEvent(this, tab.host))
actionManager
.getAction(OpenHostAction.OPEN_HOST)
.actionPerformed(OpenHostActionEvent(this, tab.host, evt))
}
}
// 在新窗口中打开
val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window"))
openInNewWindow.addActionListener {
val index = tabbedPane.selectedIndex
if (index > 0) {
val title = tabbedPane.getTitleAt(index)
removeTabAt(index, false)
val dialog = TerminalTabDialog(
owner = SwingUtilities.getWindowAncestor(this),
terminalTab = tab,
size = Dimension(min(size.width, 1280), min(size.height, 800))
)
dialog.title = title
Disposer.register(dialog, tab)
Disposer.register(this, dialog)
dialog.isVisible = true
openInNewWindow.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val owner = evt.getData(DataProviders.TermoraFrame) ?: return
if (tabIndex > 0) {
val title = tabbedPane.getTitleAt(tabIndex)
removeTabAt(tabIndex, false)
val dialog = TerminalTabDialog(
owner = owner,
terminalTab = tab,
size = Dimension(min(size.width, 1280), min(size.height, 800))
)
dialog.title = title
Disposer.register(dialog, tab)
Disposer.register(this@TerminalTabbed, dialog)
dialog.isVisible = true
}
}
}
})
popupMenu.addSeparator()
@@ -309,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()
}
}
@@ -322,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)
}
@@ -426,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? {
@@ -451,5 +429,24 @@ class TerminalTabbed(
}
}
override fun closeTerminalTab(tab: TerminalTab, disposable: Boolean) {
for (i in 0 until tabs.size) {
if (tabs[i] == tab) {
removeTabAt(i, disposable)
break
}
}
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == DataProviders.TerminalTab) {
dataProviderSupport.removeData(dataKey)
if (tabbedPane.selectedIndex >= 0 && tabs.size > tabbedPane.selectedIndex) {
dataProviderSupport.addData(dataKey, tabs[tabbedPane.selectedIndex])
}
}
return dataProviderSupport.getData(dataKey)
}
}

View File

@@ -2,7 +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, disposable: Boolean = true)
}

View File

@@ -1,84 +1,53 @@
package app.termora
import app.termora.findeverywhere.FindEverywhere
import app.termora.highlight.KeywordHighlightDialog
import app.termora.keymgr.KeyManagerDialog
import app.termora.macro.MacroAction
import app.termora.tlog.TerminalLoggerAction
import app.termora.transport.SFTPAction
import app.termora.actions.ActionManager
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders
import app.termora.terminal.DataKey
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatDesktop
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import io.github.g00fy2.versioncompare.Version
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXEditorPane
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.Insets
import java.awt.KeyEventDispatcher
import java.awt.KeyboardFocusManager
import java.awt.event.*
import java.net.URI
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.*
import javax.imageio.ImageIO
import javax.swing.*
import javax.swing.Box
import javax.swing.JFrame
import javax.swing.SwingUtilities
import javax.swing.SwingUtilities.isEventDispatchThread
import javax.swing.event.HyperlinkEvent
import kotlin.concurrent.fixedRateTimer
import javax.swing.UIManager
import kotlin.math.max
import kotlin.system.exitProcess
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
fun assertEventDispatchThread() {
if (!isEventDispatchThread()) throw WrongThreadException("AWT EventQueue")
}
class TermoraFrame : JFrame() {
class TermoraFrame : JFrame(), DataProvider {
companion object {
private val log = LoggerFactory.getLogger(TermoraFrame::class.java)
}
private val actionManager get() = ActionManager.getInstance()
private val id = UUID.randomUUID().toString()
private val windowScope = ApplicationScope.forWindowScope(this)
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
private val tabbedPane = MyTabbedPane()
private val toolbar = TermoraToolBar(titleBar, tabbedPane)
private lateinit var terminalTabbed: TerminalTabbed
private val disposable = Disposer.newDisposable()
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
private val updaterManager get() = UpdaterManager.instance
private val dataProviderSupport = DataProviderSupport()
private val welcomePanel = WelcomePanel(windowScope)
private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() }
private val preferencesHandler = object : Runnable {
override fun run() {
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow ?: this@TermoraFrame
if (owner != this@TermoraFrame) {
return
}
val that = this
FlatDesktop.setPreferencesHandler {}
val dialog = SettingsDialog(owner)
dialog.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
FlatDesktop.setPreferencesHandler(that)
}
})
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
}
}
init {
initActions()
initView()
initEvents()
initDesktopHandler()
scheduleUpdate()
}
private fun initEvents() {
@@ -97,154 +66,19 @@ class TermoraFrame : JFrame() {
}
}
// global shortcuts
rootPane.actionMap.put(Actions.FIND_EVERYWHERE, ActionManager.getInstance().getAction(Actions.FIND_EVERYWHERE))
rootPane.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_T, toolkit.menuShortcutKeyMaskEx), Actions.FIND_EVERYWHERE)
// double shift
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(object : KeyEventDispatcher {
private var lastTime = -1L
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
if (e.keyCode == KeyEvent.VK_SHIFT && e.id == KeyEvent.KEY_PRESSED) {
val now = System.currentTimeMillis()
if (now - 250 < lastTime) {
ActionManager.getInstance().getAction(Actions.FIND_EVERYWHERE)
.actionPerformed(ActionEvent(rootPane, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))
}
lastTime = now
} else if (e.keyCode != KeyEvent.VK_SHIFT) { // 如果不是 Shift 键,那么就阻断了连续性,重置时间
lastTime = -1
}
return false
}
})
// 监听主题变化 需要动态修改控制栏颜色
if (SystemInfo.isWindows && isWindowDecorationsSupported) {
ThemeManager.instance.addThemeChangeListener(object : ThemeChangeListener {
ThemeManager.getInstance().addThemeChangeListener(object : ThemeChangeListener {
override fun onChanged() {
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
}
})
}
// dispose
addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
Disposer.dispose(disposable)
Disposer.dispose(ApplicationDisposable.instance)
try {
Disposer.getTree().assertIsEmpty(true)
} catch (e: Exception) {
log.error(e.message)
}
exitProcess(0)
}
})
}
private fun initActions() {
// SETTING
ActionManager.getInstance().addAction(Actions.SETTING, object : AnAction(
I18n.getString("termora.setting"),
Icons.settings
) {
override fun actionPerformed(e: ActionEvent) {
preferencesHandler.run()
}
})
// MULTIPLE
ActionManager.getInstance().addAction(Actions.MULTIPLE, object : AnAction(
I18n.getString("termora.tools.multiple"),
Icons.vcs
) {
init {
setStateAction()
}
override fun actionPerformed(evt: ActionEvent) {
TerminalPanelFactory.instance.repaintAll()
}
})
// Keyword Highlight
ActionManager.getInstance().addAction(Actions.KEYWORD_HIGHLIGHT, object : AnAction(
I18n.getString("termora.highlight"),
Icons.edit
) {
override fun actionPerformed(evt: ActionEvent) {
KeywordHighlightDialog(this@TermoraFrame).isVisible = true
}
})
// app update
ActionManager.getInstance().addAction(Actions.APP_UPDATE, object :
AnAction(
StringUtils.EMPTY,
Icons.ideUpdate
) {
init {
isEnabled = false
}
override fun actionPerformed(evt: ActionEvent) {
showUpdateDialog()
}
})
// 终端日志记录
ActionManager.getInstance().addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
// SFTP
ActionManager.getInstance().addAction(Actions.SFTP, SFTPAction())
// macro
ActionManager.getInstance().addAction(Actions.MACRO, MacroAction())
// FIND_EVERYWHERE
ActionManager.getInstance().addAction(Actions.FIND_EVERYWHERE, object : AnAction(
I18n.getString("termora.find-everywhere"),
Icons.find
) {
override fun actionPerformed(evt: ActionEvent) {
if (this.isEnabled) {
val focusWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
val frame = this@TermoraFrame
if (focusWindow == frame) {
val dialog = FindEverywhere(frame)
dialog.setLocationRelativeTo(frame)
dialog.isVisible = true
}
}
}
})
// Key manager
ActionManager.getInstance().addAction(Actions.KEY_MANAGER, object : AnAction(
I18n.getString("termora.keymgr.title"),
Icons.greyKey
) {
override fun actionPerformed(evt: ActionEvent) {
if (this.isEnabled) {
KeyManagerDialog(this@TermoraFrame).isVisible = true
}
}
})
}
private fun initView() {
if (isWindowDecorationsSupported) {
titleBar.height = UIManager.getInt("TabbedPane.tabHeight").toFloat()
@@ -267,10 +101,7 @@ class TermoraFrame : JFrame() {
}
minimumSize = Dimension(640, 400)
terminalTabbed = TerminalTabbed(toolbar, tabbedPane).apply {
Application.registerService(TerminalTabbedManager::class, this)
}
terminalTabbed.addTab(WelcomePanel())
terminalTabbed.addTerminalTab(welcomePanel)
// macOS 要避开左边的控制栏
if (SystemInfo.isMacOS) {
@@ -282,89 +113,14 @@ class TermoraFrame : JFrame() {
}
}
Disposer.register(disposable, terminalTabbed)
Disposer.register(windowScope, terminalTabbed)
add(terminalTabbed)
dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane)
dataProviderSupport.addData(DataProviders.TermoraFrame, this)
dataProviderSupport.addData(DataProviders.WindowScope, windowScope)
}
private fun showUpdateDialog() {
val lastVersion = updaterManager.lastVersion
val editorPane = JXEditorPane()
editorPane.contentType = "text/html"
editorPane.text = lastVersion.htmlBody
editorPane.isEditable = false
editorPane.addHyperlinkListener {
if (it.eventType == HyperlinkEvent.EventType.ACTIVATED) {
Application.browse(it.url.toURI())
}
}
editorPane.background = DynamicColor("window")
val scrollPane = JScrollPane(editorPane)
scrollPane.border = BorderFactory.createEmptyBorder()
scrollPane.preferredSize = Dimension(
UIManager.getInt("Dialog.width") - 100,
UIManager.getInt("Dialog.height") - 100
)
val option = OptionPane.showConfirmDialog(
this,
scrollPane,
title = I18n.getString("termora.update.title"),
messageType = JOptionPane.PLAIN_MESSAGE,
optionType = JOptionPane.YES_NO_CANCEL_OPTION,
options = arrayOf(
I18n.getString("termora.update.update"),
I18n.getString("termora.update.ignore"),
I18n.getString("termora.cancel")
),
initialValue = I18n.getString("termora.update.update")
)
if (option == JOptionPane.CANCEL_OPTION) {
return
} else if (option == JOptionPane.NO_OPTION) {
ActionManager.getInstance().setEnabled(Actions.APP_UPDATE, false)
updaterManager.ignore(updaterManager.lastVersion.version)
} else if (option == JOptionPane.YES_OPTION) {
ActionManager.getInstance()
.setEnabled(Actions.APP_UPDATE, false)
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${lastVersion.version}"))
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun scheduleUpdate() {
fixedRateTimer(
name = "check-update-timer",
initialDelay = 3.minutes.inWholeMilliseconds,
period = 5.hours.inWholeMilliseconds, daemon = true
) {
GlobalScope.launch(Dispatchers.IO) { supervisorScope { launch { checkUpdate() } } }
}
}
private suspend fun checkUpdate() {
val latestVersion = updaterManager.fetchLatestVersion()
if (latestVersion.isSelf) {
return
}
val newVersion = Version(latestVersion.version)
val version = Version(Application.getVersion())
if (newVersion <= version) {
return
}
if (updaterManager.isIgnored(latestVersion.version)) {
return
}
withContext(Dispatchers.Swing) {
ActionManager.getInstance()
.setEnabled(Actions.APP_UPDATE, true)
}
}
private fun forceHitTest() {
val mouseAdapter = object : MouseAdapter() {
@@ -423,11 +179,25 @@ class TermoraFrame : JFrame() {
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
}
private fun initDesktopHandler() {
if (SystemInfo.isMacOS) {
FlatDesktop.setPreferencesHandler {
preferencesHandler.run()
}
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey)
?: terminalTabbed.getData(dataKey)
?: welcomePanel.getData(dataKey)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TermoraFrame
return id == other.id
}
override fun hashCode(): Int {
return id.hashCode()
}
}

View File

@@ -0,0 +1,76 @@
package app.termora
import com.formdev.flatlaf.util.SystemInfo
import org.slf4j.LoggerFactory
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.JOptionPane
import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
import kotlin.system.exitProcess
class TermoraFrameManager {
companion object {
private val log = LoggerFactory.getLogger(TermoraFrameManager::class.java)
fun getInstance(): TermoraFrameManager {
return ApplicationScope.forApplicationScope()
.getOrCreate(TermoraFrameManager::class) { TermoraFrameManager() }
}
}
fun createWindow(): TermoraFrame {
val frame = TermoraFrame()
registerCloseCallback(frame)
frame.title = if (SystemInfo.isLinux) null else Application.getName()
frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
frame.setSize(1280, 800)
frame.setLocationRelativeTo(null)
return frame
}
private fun registerCloseCallback(window: TermoraFrame) {
window.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
// dispose windowScope
Disposer.dispose(ApplicationScope.forWindowScope(e.window))
val windowScopes = ApplicationScope.windowScopes()
// 如果已经没有 Window 域了,那么就可以退出程序了
if (windowScopes.isEmpty()) {
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()
}
}
})
}
private fun dispose() {
Disposer.dispose(ApplicationScope.forApplicationScope())
try {
Disposer.getTree().assertIsEmpty(true)
} catch (e: Exception) {
log.error(e.message)
}
exitProcess(0)
}
}

View File

@@ -1,16 +1,18 @@
package app.termora
import app.termora.Application.ohMyJson
import app.termora.db.Database
import app.termora.actions.ActionManager
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import app.termora.findeverywhere.FindEverywhereAction
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.WindowDecorations
import kotlinx.serialization.Serializable
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionContainerFactory
import org.jdesktop.swingx.action.ActionManager
import java.awt.Insets
import java.awt.event.ActionEvent
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import javax.swing.Box
@@ -27,7 +29,7 @@ class TermoraToolBar(
private val titleBar: WindowDecorations.CustomTitleBar,
private val tabbedPane: FlatTabbedPane
) {
private val properties by lazy { Database.instance.properties }
private val properties by lazy { Database.getDatabase().properties }
private val toolbar by lazy { MyToolBar().apply { rebuild(this) } }
@@ -46,8 +48,8 @@ class TermoraToolBar(
ToolBarAction(Actions.KEYWORD_HIGHLIGHT, true),
ToolBarAction(Actions.KEY_MANAGER, true),
ToolBarAction(Actions.MULTIPLE, true),
ToolBarAction(Actions.FIND_EVERYWHERE, true),
ToolBarAction(Actions.SETTING, true),
ToolBarAction(FindEverywhereAction.FIND_EVERYWHERE, true),
ToolBarAction(SettingsAction.SETTING, true),
)
}
@@ -96,12 +98,12 @@ class TermoraToolBar(
toolbar.removeAll()
toolbar.add(actionContainerFactory.createButton(object : AnAction(StringUtils.EMPTY, Icons.add) {
override fun actionPerformed(e: ActionEvent?) {
actionManager.getAction(Actions.FIND_EVERYWHERE)?.actionPerformed(e)
override fun actionPerformed(evt: AnActionEvent) {
actionManager.getAction(FindEverywhereAction.FIND_EVERYWHERE)?.actionPerformed(evt)
}
override fun isEnabled(): Boolean {
return actionManager.getAction(Actions.FIND_EVERYWHERE)?.isEnabled ?: false
return actionManager.getAction(FindEverywhereAction.FIND_EVERYWHERE)?.isEnabled ?: false
}
}))

View File

@@ -1,13 +1,11 @@
package app.termora
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.*
import com.formdev.flatlaf.ui.FlatTextBorder
import org.apache.commons.lang3.StringUtils
import java.awt.Component
import java.awt.event.FocusAdapter
import java.awt.event.FocusEvent
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import java.awt.event.*
import java.text.ParseException
import javax.swing.DefaultListCellRenderer
import javax.swing.JComboBox
@@ -53,6 +51,15 @@ class OutlineTextArea : FlatTextArea() {
}
}
class OutlineComboBox<T> : JComboBox<T>() {
init {
addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
putClientProperty(FlatClientProperties.OUTLINE, null)
}
}
}
}
class FixedLengthTextArea(var maxLength: Int = Int.MAX_VALUE) : FlatTextArea() {
init {
@@ -92,6 +99,8 @@ class OutlinePasswordField(
styleMap = mapOf(
"showRevealButton" to true
)
putClientProperty("JPasswordField.cutCopyAllowed", true)
}
}

View File

@@ -1,6 +1,5 @@
package app.termora
import app.termora.db.Database
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatAnimatedLafChange
import com.jthemedetecor.OsThemeDetector
@@ -24,9 +23,12 @@ class ThemeManager private constructor() {
companion object {
private val log = LoggerFactory.getLogger(ThemeManager::class.java)
val instance by lazy { ThemeManager() }
fun getInstance(): ThemeManager {
return ApplicationScope.forApplicationScope().getOrCreate(ThemeManager::class) { ThemeManager() }
}
}
val appearance by lazy { Database.getDatabase().appearance }
val themes = mapOf(
"Light" to LightLaf::class.java.name,
"Dark" to DarkLaf::class.java.name,
@@ -78,18 +80,16 @@ class ThemeManager private constructor() {
GlobalScope.launch(Dispatchers.IO) {
OsThemeDetector.getDetector().registerListener(object : Consumer<Boolean> {
override fun accept(isDark: Boolean) {
if (!Database.instance.appearance.followSystem) {
if (!appearance.followSystem) {
return
}
if (FlatLaf.isLafDark() && isDark) {
return
}
if (isDark) {
SwingUtilities.invokeLater { change("Dark") }
} else {
SwingUtilities.invokeLater { change("Light") }
SwingUtilities.invokeLater {
if (isDark) {
change(appearance.darkTheme)
} else {
change(appearance.lightTheme)
}
}
}
})

View File

@@ -1,10 +1,10 @@
package app.termora
import app.termora.Application.ohMyJson
import app.termora.db.Database
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
@@ -19,7 +19,9 @@ import java.util.*
class UpdaterManager private constructor() {
companion object {
private val log = LoggerFactory.getLogger(UpdaterManager::class.java)
val instance by lazy { UpdaterManager() }
fun getInstance(): UpdaterManager {
return ApplicationScope.forApplicationScope().getOrCreate(UpdaterManager::class) { UpdaterManager() }
}
}
data class Asset(
@@ -58,7 +60,7 @@ class UpdaterManager private constructor() {
val isSelf get() = this == self
}
private val properties get() = Database.instance.properties
private val properties get() = Database.getDatabase().properties
var lastVersion = LatestVersion.self
fun fetchLatestVersion(): LatestVersion {
@@ -96,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 ->
@@ -105,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;"
}
}

View File

@@ -1,10 +1,11 @@
package app.termora
import app.termora.db.Database
import app.termora.actions.*
import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhere
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.terminal.DataKey
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatSVGIcon
@@ -19,17 +20,18 @@ import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import javax.swing.*
import javax.swing.event.DocumentEvent
import javax.swing.tree.TreePath
import kotlin.math.max
class WelcomePanel : JPanel(BorderLayout()), Disposable, TerminalTab {
private val properties get() = Database.instance.properties
class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()), Disposable, TerminalTab,
DataProvider {
private val properties get() = Database.getDatabase().properties
private val rootPanel = JPanel(BorderLayout())
private val searchTextField = FlatTextField()
private val hostTree = HostTree()
private val bannerPanel = BannerPanel()
private val toggle = FlatButton()
private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean()
private val dataProviderSupport = DataProviderSupport()
init {
initView()
@@ -51,6 +53,7 @@ class WelcomePanel : JPanel(BorderLayout()), Disposable, TerminalTab {
rootPanel.add(panel, BorderLayout.CENTER)
add(rootPanel, BorderLayout.CENTER)
dataProviderSupport.addData(DataProviders.Welcome.HostTree, hostTree)
}
@@ -73,7 +76,7 @@ class WelcomePanel : JPanel(BorderLayout()), Disposable, TerminalTab {
newHost.isFocusable = false
newHost.buttonType = FlatButton.ButtonType.toolBarButton
newHost.addActionListener { e ->
ActionManager.getInstance().getAction(Actions.ADD_HOST)?.actionPerformed(e)
ActionManager.getInstance().getAction(NewHostAction.NEW_HOST)?.actionPerformed(e)
}
@@ -117,7 +120,7 @@ class WelcomePanel : JPanel(BorderLayout()), Disposable, TerminalTab {
private fun createHostPanel(): JComponent {
val panel = JPanel(BorderLayout())
hostTree.actionMap.put("find", object : AnAction() {
override fun actionPerformed(e: ActionEvent) {
override fun actionPerformed(evt: AnActionEvent) {
searchTextField.requestFocusInWindow()
}
})
@@ -160,31 +163,23 @@ class WelcomePanel : JPanel(BorderLayout()), Disposable, TerminalTab {
})
ActionManager.getInstance().addAction(Actions.ADD_HOST, object : AnAction() {
override fun actionPerformed(e: ActionEvent) {
if (hostTree.selectionCount < 1) {
hostTree.selectionPath = TreePath(hostTree.model.root)
FindEverywhereProvider.getFindEverywhereProviders(windowScope)
.add(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider {
override fun find(pattern: String): List<FindEverywhereResult> {
return TreeUtils.children(hostTree.model, hostTree.model.root)
.filterIsInstance<Host>()
.filter { it.protocol != Protocol.Folder }
.map { HostFindEverywhereResult(it) }
}
hostTree.showAddHostDialog()
}
})
FindEverywhere.registerProvider(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider {
override fun find(pattern: String): List<FindEverywhereResult> {
return TreeUtils.children(hostTree.model, hostTree.model.root)
.filterIsInstance<Host>()
.filter { it.protocol != Protocol.Folder }
.map { HostFindEverywhereResult(it) }
}
override fun group(): String {
return I18n.getString("termora.find-everywhere.groups.open-new-hosts")
}
override fun group(): String {
return I18n.getString("termora.find-everywhere.groups.open-new-hosts")
}
override fun order(): Int {
return Integer.MIN_VALUE + 2
}
}))
override fun order(): Int {
return Integer.MIN_VALUE + 2
}
}))
searchTextField.document.addDocumentListener(object : DocumentAdaptor() {
private var state = StringUtils.EMPTY
@@ -233,15 +228,28 @@ class WelcomePanel : JPanel(BorderLayout()), Disposable, TerminalTab {
return this
}
override fun canReconnect(): Boolean {
return false
}
override fun canClose(): Boolean {
return false
}
override fun canClone(): Boolean {
return false
}
override fun dispose() {
hostTree.setModel(null)
properties.putString("WelcomeFullContent", fullContent.toString())
}
private class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
override fun actionPerformed(e: ActionEvent) {
ActionManager.getInstance()
.getAction(Actions.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(this, host))
.getAction(OpenHostAction.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(e.source, host, e))
}
override fun getIcon(isSelected: Boolean): Icon {
@@ -258,5 +266,9 @@ class WelcomePanel : JPanel(BorderLayout()), Disposable, TerminalTab {
}
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey)
}
}

View File

@@ -0,0 +1,72 @@
package app.termora.actions
import app.termora.Actions
import app.termora.ApplicationScope
import app.termora.findeverywhere.FindEverywhereAction
import app.termora.highlight.KeywordHighlightAction
import app.termora.keymgr.KeyManagerAction
import app.termora.macro.MacroAction
import app.termora.tlog.TerminalLoggerAction
import app.termora.transport.SFTPAction
import javax.swing.Action
class ActionManager : org.jdesktop.swingx.action.ActionManager() {
companion object {
fun getInstance(): ActionManager {
return ApplicationScope.forApplicationScope().getOrCreate(ActionManager::class) { ActionManager() }
}
}
init {
setInstance(this)
registerActions()
}
private fun registerActions() {
addAction(NewWindowAction.NEW_WINDOW, NewWindowAction())
addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction())
addAction(Actions.MULTIPLE, MultipleAction())
addAction(Actions.APP_UPDATE, AppUpdateAction())
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
addAction(Actions.SFTP, SFTPAction())
addAction(Actions.MACRO, MacroAction())
addAction(Actions.KEY_MANAGER, KeyManagerAction())
addAction(SwitchTabAction.SWITCH_TAB, SwitchTabAction())
addAction(TabReconnectAction.RECONNECT_TAB, TabReconnectAction())
addAction(SettingsAction.SETTING, SettingsAction())
addAction(NewHostAction.NEW_HOST, NewHostAction())
addAction(OpenHostAction.OPEN_HOST, OpenHostAction())
addAction(TerminalCopyAction.COPY, TerminalCopyAction())
addAction(TerminalPasteAction.PASTE, TerminalPasteAction())
addAction(TerminalFindAction.FIND, TerminalFindAction())
addAction(TerminalCloseAction.CLOSE, TerminalCloseAction())
addAction(TerminalClearScreenAction.CLEAR_SCREEN, TerminalClearScreenAction())
addAction(OpenLocalTerminalAction.LOCAL_TERMINAL, OpenLocalTerminalAction())
addAction(TerminalSelectAllAction.SELECT_ALL, TerminalSelectAllAction())
addAction(TerminalZoomInAction.ZOOM_IN, TerminalZoomInAction())
addAction(TerminalZoomOutAction.ZOOM_OUT, TerminalZoomOutAction())
addAction(TerminalZoomResetAction.ZOOM_RESET, TerminalZoomResetAction())
}
override fun addAction(action: Action): Action {
val actionId = action.getValue(Action.ACTION_COMMAND_KEY) ?: throw IllegalArgumentException("Invalid action ID")
return addAction(actionId, action)
}
override fun addAction(id: Any, action: Action): Action {
if (getAction(id) != null) {
throw IllegalArgumentException("Action already exists")
}
return super.addAction(id, action)
}
}

View File

@@ -0,0 +1,30 @@
package app.termora.actions
import org.jdesktop.swingx.action.BoundAction
import java.awt.event.ActionEvent
import javax.swing.Icon
abstract class AnAction : BoundAction {
constructor() : super()
constructor(icon: Icon) : super() {
super.putValue(SMALL_ICON, icon)
}
constructor(name: String?) : super(name)
constructor(name: String?, icon: Icon?) : super(name, icon)
final override fun actionPerformed(evt: ActionEvent) {
if (evt is AnActionEvent) {
actionPerformed(evt)
} else {
actionPerformed(AnActionEvent(evt.source, evt.actionCommand, evt))
}
}
protected abstract fun actionPerformed(evt: AnActionEvent)
}

View File

@@ -0,0 +1,61 @@
package app.termora.actions
import app.termora.terminal.DataKey
import java.awt.Component
import java.awt.KeyboardFocusManager
import java.awt.Window
import java.awt.event.ActionEvent
import java.util.*
import javax.swing.JPopupMenu
open class AnActionEvent(
source: Any, command: String,
val event: EventObject
) : ActionEvent(source, AN_ACTION_PERFORMED, command), DataProvider {
companion object {
const val AN_ACTION_PERFORMED = ACTION_PERFORMED + 1
}
val window: Window
get() = getData(DataProviders.TermoraFrame)
?: KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
public override fun consume() {
super.consumed = true
}
public override fun isConsumed(): Boolean {
return super.isConsumed()
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
val source = getSource()
if (source !is Component) {
if (source is DataProvider) {
return source.getData(dataKey)
}
return null
} else {
var c = source as Component?
while (c != null) {
if (c is DataProvider) {
val data = c.getData(dataKey)
if (data != null) {
return data
}
}
val p = c.parent
c = if (p == null && c is JPopupMenu) {
c.invoker
} else {
p
}
}
return null
}
}
}

View File

@@ -0,0 +1,117 @@
package app.termora.actions
import app.termora.*
import io.github.g00fy2.versioncompare.Version
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXEditorPane
import java.awt.Dimension
import java.awt.KeyboardFocusManager
import java.net.URI
import javax.swing.BorderFactory
import javax.swing.JOptionPane
import javax.swing.JScrollPane
import javax.swing.UIManager
import javax.swing.event.HyperlinkEvent
import kotlin.concurrent.fixedRateTimer
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
class AppUpdateAction : AnAction(
StringUtils.EMPTY,
Icons.ideUpdate
) {
private val updaterManager get() = UpdaterManager.getInstance()
init {
isEnabled = false
scheduleUpdate()
}
override fun actionPerformed(evt: AnActionEvent) {
showUpdateDialog()
}
@OptIn(DelicateCoroutinesApi::class)
private fun scheduleUpdate() {
fixedRateTimer(
name = "check-update-timer",
initialDelay = 3.minutes.inWholeMilliseconds,
period = 5.hours.inWholeMilliseconds, daemon = true
) {
GlobalScope.launch(Dispatchers.IO) { supervisorScope { launch { checkUpdate() } } }
}
}
private suspend fun checkUpdate() {
val latestVersion = updaterManager.fetchLatestVersion()
if (latestVersion.isSelf) {
return
}
val newVersion = Version(latestVersion.version)
val version = Version(Application.getVersion())
if (newVersion <= version) {
return
}
if (updaterManager.isIgnored(latestVersion.version)) {
return
}
withContext(Dispatchers.Swing) {
ActionManager.getInstance()
.setEnabled(Actions.APP_UPDATE, true)
}
}
private fun showUpdateDialog() {
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
val lastVersion = updaterManager.lastVersion
val editorPane = JXEditorPane()
editorPane.contentType = "text/html"
editorPane.text = lastVersion.htmlBody
editorPane.isEditable = false
editorPane.addHyperlinkListener {
if (it.eventType == HyperlinkEvent.EventType.ACTIVATED) {
Application.browse(it.url.toURI())
}
}
editorPane.background = DynamicColor("window")
val scrollPane = JScrollPane(editorPane)
scrollPane.border = BorderFactory.createEmptyBorder()
scrollPane.preferredSize = Dimension(
UIManager.getInt("Dialog.width") - 100,
UIManager.getInt("Dialog.height") - 100
)
val option = OptionPane.showConfirmDialog(
owner,
scrollPane,
title = I18n.getString("termora.update.title"),
messageType = JOptionPane.PLAIN_MESSAGE,
optionType = JOptionPane.YES_NO_CANCEL_OPTION,
options = arrayOf(
I18n.getString("termora.update.update"),
I18n.getString("termora.update.ignore"),
I18n.getString("termora.cancel")
),
initialValue = I18n.getString("termora.update.update")
)
if (option == JOptionPane.CANCEL_OPTION) {
return
} else if (option == JOptionPane.NO_OPTION) {
ActionManager.getInstance().setEnabled(Actions.APP_UPDATE, false)
updaterManager.ignore(updaterManager.lastVersion.version)
} else if (option == JOptionPane.YES_OPTION) {
ActionManager.getInstance()
.setEnabled(Actions.APP_UPDATE, false)
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${lastVersion.version}"))
}
}
}

View File

@@ -0,0 +1,21 @@
package app.termora.actions
import app.termora.terminal.DataKey
/**
* 数据提供者,从 [AnActionEvent.source] 开始搜索然后依次 [getData] 获取数据
*/
interface DataProvider {
companion object {
val EMPTY = object : DataProvider {
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return null
}
}
}
/**
* 数据提供
*/
fun <T : Any> getData(dataKey: DataKey<T>): T?
}

View File

@@ -0,0 +1,24 @@
package app.termora.actions
import app.termora.terminal.DataKey
class DataProviderSupport : DataProvider {
private val map = mutableMapOf<DataKey<*>, Any>()
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (map.containsKey(dataKey)) {
@Suppress("UNCHECKED_CAST")
return map[dataKey] as T
}
return null
}
fun <T : Any> addData(key: DataKey<T>, data: T) {
map[key] = data
}
fun removeData(key: DataKey<*>) {
map.remove(key)
}
}

View File

@@ -0,0 +1,22 @@
package app.termora.actions
import app.termora.terminal.DataKey
object DataProviders {
val TerminalPanel = DataKey(app.termora.terminal.panel.TerminalPanel::class)
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)
val TermoraFrame = DataKey(app.termora.TermoraFrame::class)
val WindowScope = DataKey(app.termora.WindowScope::class)
object Welcome {
val HostTree = DataKey(app.termora.HostTree::class)
}
}

View File

@@ -0,0 +1,17 @@
package app.termora.actions
import app.termora.*
class MultipleAction : AnAction(
I18n.getString("termora.tools.multiple"),
Icons.vcs
) {
init {
setStateAction()
}
override fun actionPerformed(evt: AnActionEvent) {
ApplicationScope.windowScopes().map { TerminalPanelFactory.getInstance(it) }
.forEach { it.repaintAll() }
}
}

View File

@@ -0,0 +1,46 @@
package app.termora.actions
import app.termora.Host
import app.termora.HostDialog
import app.termora.HostManager
import app.termora.Protocol
import javax.swing.tree.TreePath
class NewHostAction : AnAction() {
companion object {
/**
* 添加主机对话框
*/
const val NEW_HOST = "NewHostAction"
}
private val hostManager get() = HostManager.getInstance()
override fun actionPerformed(evt: AnActionEvent) {
val tree = evt.getData(DataProviders.Welcome.HostTree) ?: return
val model = tree.model
var lastHost = tree.lastSelectedPathComponent ?: model.root
if (lastHost !is Host) {
return
}
if (lastHost.protocol != Protocol.Folder) {
val p = model.getParent(lastHost) ?: return
lastHost = p
}
val dialog = HostDialog(evt.window)
dialog.setLocationRelativeTo(evt.window)
dialog.isVisible = true
val host = (dialog.host ?: return).copy(parentId = lastHost.id)
hostManager.addHost(host)
tree.expandNode(lastHost)
tree.selectionPath = TreePath(model.getPathToRoot(host))
}
}

View File

@@ -0,0 +1,27 @@
package app.termora.actions
import app.termora.I18n
import app.termora.TermoraFrameManager
import java.awt.KeyboardFocusManager
class NewWindowAction : AnAction() {
companion object {
/**
* 打开一个新的窗口
*/
const val NEW_WINDOW = "NewWindowAction"
}
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.open-new-window"))
putValue(ACTION_COMMAND_KEY, NEW_WINDOW)
}
override fun actionPerformed(evt: AnActionEvent) {
val focusedWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
if (focusedWindow == evt.window) {
TermoraFrameManager.getInstance().createWindow().isVisible = true
}
}
}

View File

@@ -0,0 +1,27 @@
package app.termora.actions
import app.termora.*
class OpenHostAction : AnAction() {
companion object {
/**
* 打开一个主机
*/
const val OPEN_HOST = "OpenHostAction"
}
override fun actionPerformed(evt: AnActionEvent) {
if (evt !is OpenHostActionEvent) return
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
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()
}
}

View File

@@ -0,0 +1,35 @@
package app.termora.actions
import app.termora.*
class OpenLocalTerminalAction : AnAction(
I18n.getString("termora.find-everywhere.quick-command.local-terminal"),
Icons.terminal
) {
companion object {
const val LOCAL_TERMINAL = "OpenLocalTerminal"
}
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.open-local-terminal"))
putValue(ACTION_COMMAND_KEY, LOCAL_TERMINAL)
}
override fun actionPerformed(evt: AnActionEvent) {
ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)?.actionPerformed(
OpenHostActionEvent(
evt.source,
Host(
name = name,
protocol = Protocol.Local
),
evt
)
)
evt.consume()
}
}

View File

@@ -0,0 +1,55 @@
package app.termora.actions
import app.termora.ApplicationScope
import app.termora.I18n
import app.termora.Icons
import app.termora.SettingsDialog
import com.formdev.flatlaf.extras.FlatDesktop
import org.apache.commons.lang3.StringUtils
import java.awt.KeyboardFocusManager
import java.awt.event.ActionEvent
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
class SettingsAction : AnAction(
I18n.getString("termora.setting"),
Icons.settings
) {
companion object {
/**
* 打开设置
*/
const val SETTING = "SettingAction"
}
private var isShowing = false
init {
FlatDesktop.setPreferencesHandler {
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
// Doorman 的情况下不允许打开
if (owner != null && ApplicationScope.windowScopes().isNotEmpty()) {
actionPerformed(ActionEvent(owner, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))
}
}
}
override fun actionPerformed(evt: AnActionEvent) {
if (isShowing) {
return
}
isShowing = true
val owner = evt.window
val dialog = SettingsDialog(owner)
dialog.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
this@SettingsAction.isShowing = false
}
})
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
}
}

View File

@@ -0,0 +1,36 @@
package app.termora.actions
import app.termora.I18n
import java.awt.event.KeyEvent
class SwitchTabAction : AnAction() {
companion object {
const val SWITCH_TAB = "SwitchTabAction"
}
init {
putValue(ACTION_COMMAND_KEY, SWITCH_TAB)
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.switch-tab"))
}
override fun actionPerformed(evt: AnActionEvent) {
val original = evt.event
if (original !is KeyEvent) return
if (original.keyCode !in KeyEvent.VK_1..KeyEvent.VK_9) return
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val tabs = terminalTabbedManager.getTerminalTabs()
if (tabs.isEmpty()) return
val tabIndex = original.keyCode - KeyEvent.VK_1
if (tabIndex >= tabs.size) {
terminalTabbedManager.setSelectedTerminalTab(tabs.last())
} else {
terminalTabbedManager.setSelectedTerminalTab(tabs[tabIndex])
}
evt.consume()
}
}

View File

@@ -0,0 +1,21 @@
package app.termora.actions
import app.termora.I18n
class TabReconnectAction : AnAction() {
companion object {
const val RECONNECT_TAB = "TabReconnectAction"
}
init {
putValue(ACTION_COMMAND_KEY, RECONNECT_TAB)
putValue(SHORT_DESCRIPTION, I18n.getString("termora.tabbed.contextmenu.reconnect"))
}
override fun actionPerformed(evt: AnActionEvent) {
val tab = evt.getData(DataProviders.TerminalTab) ?: return
if (tab.canReconnect()) {
tab.reconnect()
}
}
}

View File

@@ -0,0 +1,20 @@
package app.termora.actions
class TerminalClearScreenAction : AnAction() {
companion object {
const val CLEAR_SCREEN = "ClearScreen"
}
init {
putValue(SHORT_DESCRIPTION, "Clear Terminal Buffer")
putValue(ACTION_COMMAND_KEY, CLEAR_SCREEN)
}
override fun actionPerformed(evt: AnActionEvent) {
val terminal = evt.getData(DataProviders.Terminal) ?: return
terminal.getDocument().eraseInDisplay(3)
evt.consume()
}
}

View File

@@ -0,0 +1,24 @@
package app.termora.actions
import app.termora.I18n
class TerminalCloseAction : AnAction() {
companion object {
const val CLOSE = "Close"
}
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.close-tab"))
putValue(ACTION_COMMAND_KEY, CLOSE)
}
override fun actionPerformed(evt: AnActionEvent) {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
terminalTabbedManager.getSelectedTerminalTab()?.let {
terminalTabbedManager.closeTerminalTab(it)
evt.consume()
}
}
}

View File

@@ -1,25 +1,29 @@
package app.termora.terminal.panel
package app.termora.actions
import app.termora.I18n
import com.formdev.flatlaf.util.SystemInfo
import org.slf4j.LoggerFactory
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
class TerminalCopyAction(private val terminalPanel: TerminalPanel) : TerminalPredicateAction {
class TerminalCopyAction : AnAction() {
companion object {
const val COPY = "TerminalCopy"
private val log = LoggerFactory.getLogger(TerminalCopyAction::class.java)
}
private val systemClipboard get() = terminalPanel.toolkit.systemClipboard
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.copy-from-terminal"))
putValue(ACTION_COMMAND_KEY, COPY)
}
override fun actionPerformed(e: KeyEvent) {
override fun actionPerformed(evt: AnActionEvent) {
val terminalPanel = evt.getData(DataProviders.TerminalPanel) ?: return
val text = terminalPanel.copy()
val systemClipboard = terminalPanel.toolkit.systemClipboard
evt.consume()
// 如果文本为空,那么清空剪切板
if (text.isEmpty()) {
@@ -30,22 +34,10 @@ class TerminalCopyAction(private val terminalPanel: TerminalPanel) : TerminalPre
systemClipboard.setContents(StringSelection(text), null)
terminalPanel.toast(I18n.getString("termora.terminal.copied"))
if (log.isTraceEnabled) {
log.info("Copy to clipboard. {}", text)
log.trace("Copy to clipboard. {}", text)
}
}
override fun test(keyStroke: KeyStroke, e: KeyEvent): Boolean {
if (SystemInfo.isMacOS) {
return KeyStroke.getKeyStroke(KeyEvent.VK_C, terminalPanel.toolkit.menuShortcutKeyMaskEx) == keyStroke
}
// Ctrl + Insert
val keyStroke1 = KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, InputEvent.CTRL_DOWN_MASK)
// Ctrl + Shift + C
val keyStroke2 = KeyStroke.getKeyStroke(KeyEvent.VK_C, InputEvent.CTRL_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK)
return keyStroke == keyStroke1 || keyStroke == keyStroke2
}
private class EmptyTransferable : Transferable {
override fun getTransferDataFlavors(): Array<DataFlavor> {
@@ -61,4 +53,5 @@ class TerminalCopyAction(private val terminalPanel: TerminalPanel) : TerminalPre
}
}
}

View File

@@ -0,0 +1,23 @@
package app.termora.actions
import app.termora.I18n
class TerminalFindAction : AnAction() {
companion object {
const val FIND = "TerminalFind"
}
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.open-terminal-find"))
putValue(ACTION_COMMAND_KEY, FIND)
}
override fun actionPerformed(evt: AnActionEvent) {
val terminalPanel = evt.getData(DataProviders.TerminalPanel) ?: return
terminalPanel.showFind()
evt.consume()
}
}

View File

@@ -0,0 +1,35 @@
package app.termora.actions
import app.termora.I18n
import org.slf4j.LoggerFactory
import java.awt.datatransfer.DataFlavor
class TerminalPasteAction : AnAction() {
companion object {
const val PASTE = "TerminalPaste"
private val log = LoggerFactory.getLogger(TerminalPasteAction::class.java)
}
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.paste-to-terminal"))
putValue(ACTION_COMMAND_KEY, PASTE)
}
override fun actionPerformed(evt: AnActionEvent) {
val terminalPanel = evt.getData(DataProviders.TerminalPanel) ?: return
val systemClipboard = terminalPanel.toolkit.systemClipboard
if (systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
val text = systemClipboard.getData(DataFlavor.stringFlavor)
if (text is String) {
terminalPanel.paste(text)
if (log.isTraceEnabled) {
log.info("Paste {}", text)
}
}
}
evt.consume()
}
}

View File

@@ -0,0 +1,25 @@
package app.termora.actions
import app.termora.I18n
import app.termora.terminal.Position
class TerminalSelectAllAction : AnAction() {
companion object {
const val SELECT_ALL = "TerminalSelectAll"
}
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.select-all-in-terminal"))
putValue(ACTION_COMMAND_KEY, SELECT_ALL)
}
override fun actionPerformed(evt: AnActionEvent) {
val terminal = evt.getData(DataProviders.Terminal) ?: return
terminal.getSelectionModel().setSelection(
Position(y = 1, x = 1),
Position(y = terminal.getDocument().getLineCount(), x = terminal.getTerminalModel().getCols())
)
evt.consume()
}
}

View File

@@ -0,0 +1,23 @@
package app.termora.actions
import app.termora.ApplicationScope
import app.termora.Database
import app.termora.TerminalPanelFactory
abstract class TerminalZoomAction : AnAction() {
protected val fontSize get() = Database.getDatabase().terminal.fontSize
abstract fun zoom(): Boolean
override fun actionPerformed(evt: AnActionEvent) {
evt.getData(DataProviders.TerminalPanel) ?: return
if (zoom()) {
ApplicationScope.windowScopes().forEach {
TerminalPanelFactory.getInstance(it)
.fireResize()
}
evt.consume()
}
}
}

View File

@@ -0,0 +1,20 @@
package app.termora.actions
import app.termora.Database
import app.termora.I18n
class TerminalZoomInAction : TerminalZoomAction() {
companion object {
const val ZOOM_IN = "TerminalZoomInAction"
}
init {
putValue(ACTION_COMMAND_KEY, ZOOM_IN)
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.zoom-in-terminal"))
}
override fun zoom(): Boolean {
Database.getDatabase().terminal.fontSize += 2
return true
}
}

View File

@@ -0,0 +1,22 @@
package app.termora.actions
import app.termora.Database
import app.termora.I18n
import kotlin.math.max
class TerminalZoomOutAction : TerminalZoomAction() {
companion object {
const val ZOOM_OUT = "TerminalZoomOutAction"
}
init {
putValue(ACTION_COMMAND_KEY, ZOOM_OUT)
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.zoom-out-terminal"))
}
override fun zoom(): Boolean {
val oldFontSize = fontSize
Database.getDatabase().terminal.fontSize = max(fontSize - 2, 9)
return oldFontSize != fontSize
}
}

View File

@@ -0,0 +1,25 @@
package app.termora.actions
import app.termora.Database
import app.termora.I18n
class TerminalZoomResetAction : TerminalZoomAction() {
companion object {
const val ZOOM_RESET = "TerminalZoomResetAction"
}
init {
putValue(ACTION_COMMAND_KEY, ZOOM_RESET)
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.zoom-reset-terminal"))
}
private val defaultFontSize = 14
override fun zoom(): Boolean {
if (fontSize == defaultFontSize) {
return false
}
Database.getDatabase().terminal.fontSize = defaultFontSize
return true
}
}

View File

@@ -1,11 +1,14 @@
package app.termora.findeverywhere
import app.termora.*
import app.termora.DialogWrapper
import app.termora.DynamicColor
import app.termora.I18n
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.macro.MacroFindEverywhereProvider
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextField
import com.jetbrains.JBR
import org.jdesktop.swingx.action.ActionManager
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Insets
@@ -20,24 +23,13 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
private val model = DefaultListModel<FindEverywhereResult>()
private val resultList = FindEverywhereXList(model)
private val centerPanel = JPanel(BorderLayout())
private val providers = mutableListOf<FindEverywhereProvider>(
BasicFilterFindEverywhereProvider(QuickCommandFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(SettingsFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(QuickActionsFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(MacroFindEverywhereProvider()),
)
companion object {
private val providers = mutableListOf<FindEverywhereProvider>(
BasicFilterFindEverywhereProvider(QuickCommandFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(SettingsFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(QuickActionsFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(MacroFindEverywhereProvider()),
)
fun registerProvider(provider: FindEverywhereProvider) {
providers.add(provider)
providers.sortBy { it.order() }
}
fun unregisterProvider(provider: FindEverywhereProvider) {
providers.remove(provider)
}
}
init {
initView()
@@ -154,7 +146,7 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
action =
if (resultList.selectedIndex + 1 == resultList.elementCount) {
object : AnAction() {
override fun actionPerformed(e: ActionEvent) {
override fun actionPerformed(evt: AnActionEvent) {
resultList.selectedIndex = 1
}
}
@@ -175,12 +167,12 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
resultList.actionMap.put("action", object : AnAction() {
override fun actionPerformed(e: ActionEvent) {
override fun actionPerformed(evt: AnActionEvent) {
if (resultList.selectedIndex < 0) {
return
}
val event = ActionEvent(e.source, ActionEvent.ACTION_PERFORMED, String())
val event = ActionEvent(evt.source, ActionEvent.ACTION_PERFORMED, String())
// fire
SwingUtilities.invokeLater { model.get(resultList.selectedIndex).actionPerformed(event) }
@@ -203,22 +195,15 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
}
})
}
fun registerProvider(provider: FindEverywhereProvider) {
providers.add(provider)
providers.sortBy { it.order() }
}
addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
ActionManager.getInstance()
.getAction(Actions.FIND_EVERYWHERE)
.isEnabled = true
}
override fun windowOpened(e: WindowEvent) {
ActionManager.getInstance()
.getAction(Actions.FIND_EVERYWHERE)
.isEnabled = false
}
})
fun unregisterProvider(provider: FindEverywhereProvider) {
providers.remove(provider)
}
override fun createCenterPanel(): JComponent {

View File

@@ -0,0 +1,63 @@
package app.termora.findeverywhere
import app.termora.I18n
import app.termora.Icons
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import org.apache.commons.lang3.StringUtils
import java.awt.Component
import java.awt.KeyboardFocusManager
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
class FindEverywhereAction : AnAction(StringUtils.EMPTY, Icons.find) {
companion object {
/**
* 查找
*/
const val FIND_EVERYWHERE = "FindEverywhereAction"
}
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.open-find-everywhere"))
putValue(NAME, I18n.getString("termora.find-everywhere"))
putValue(ACTION_COMMAND_KEY, FIND_EVERYWHERE)
}
override fun actionPerformed(evt: AnActionEvent) {
val scope = evt.getData(DataProviders.WindowScope) ?: return
if (scope.getBoolean("FindEverywhereShown", false)) {
return
}
val source = evt.source
if (source !is Component) {
return
}
val owner = evt.window
val focusedWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
if (owner != focusedWindow) {
return
}
val dialog = FindEverywhere(owner)
for (provider in FindEverywhereProvider.getFindEverywhereProviders(scope)) {
dialog.registerProvider(provider)
}
dialog.setLocationRelativeTo(owner)
dialog.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
scope.putBoolean("FindEverywhereShown", false)
}
})
dialog.isVisible = true
scope.putBoolean("FindEverywhereShown", true)
}
}

View File

@@ -1,7 +1,21 @@
package app.termora.findeverywhere
import app.termora.Scope
interface FindEverywhereProvider {
companion object {
@Suppress("UNCHECKED_CAST")
fun getFindEverywhereProviders(scope: Scope): MutableList<FindEverywhereProvider> {
var list = scope.getAnyOrNull("FindEverywhereProviders")
if (list == null) {
list = mutableListOf<FindEverywhereProvider>()
scope.putAny("FindEverywhereProviders", list)
}
return list as MutableList<FindEverywhereProvider>
}
}
/**
* 搜索
*/

View File

@@ -2,6 +2,7 @@ package app.termora.findeverywhere
import app.termora.Actions
import app.termora.I18n
import org.jdesktop.swingx.action.ActionManager
class QuickActionsFindEverywhereProvider : FindEverywhereProvider {

Some files were not shown because too many files have changed in this diff Show More