Compare commits

...

122 Commits
1.0.2 ... 1.0.8

Author SHA1 Message Date
hstyi
b332bada95 release: 1.0.8 2025-02-18 11:42:19 +08:00
hstyi
63a12c2ec8 docs: README 2025-02-18 11:42:01 +08:00
hstyi
743f242805 feat: support system fonts (#260) 2025-02-18 08:31:33 +08:00
hstyi
5bead0b27d fix: high CPU 2025-02-17 09:41:35 +08:00
hstyi
73e3c7016b feat: SFTP command icon 2025-02-17 08:24:59 +08:00
hstyi
3829dcd0f9 feat: sftp HostKeyAlgorithms (#255) 2025-02-16 19:18:00 +08:00
hstyi
b2047044fe chore: apple.awt.application.name 2025-02-16 11:22:30 +08:00
hstyi
47d1a13189 chore: improve contextmenu (#251) 2025-02-16 11:14:37 +08:00
hstyi
309909cbd7 fix: key shortcut also triggers when Popup is available (#250) 2025-02-15 19:52:57 +08:00
hstyi
b5cebb4cea chore: remove double Shift key shortcut (#249) 2025-02-15 19:27:44 +08:00
hstyi
b6dd2693cd fix: hostConfigEntry NPE 2025-02-15 19:22:20 +08:00
hstyi
5fdfe98f26 feat: OSC 1337 (#244) 2025-02-15 17:38:06 +08:00
hstyi
0c768aa1ca chore: osx github actions 2025-02-15 16:20:22 +08:00
hstyi
d493e6dc9e chore: description 2025-02-15 15:09:47 +08:00
hstyi
7e0c7d8891 fix: sftp1 to sftp 2025-02-15 14:43:46 +08:00
hstyi
3510c6600d feat: detecting SFTP program (#241) 2025-02-15 14:38:51 +08:00
hstyi
32d91150bd fix: dialog edge detection (#240) 2025-02-15 14:15:17 +08:00
hstyi
bbf2d50e3f feat: clear terminal screen shortcut (#239) 2025-02-15 14:14:49 +08:00
hstyi
39725f9828 chore: linux-aarch64.yml 2025-02-15 13:39:23 +08:00
hstyi
1e8c617a85 feat: SFTP command support for Jump Hosts and Proxy (#236) 2025-02-15 13:15:02 +08:00
hstyi
7f8573ec4c fix: frequent fingerprint saving on the jump server 2025-02-15 12:42:18 +08:00
hstyi
d8e629917e feat: SFTP command (#234) 2025-02-15 11:23:06 +08:00
hstyi
bdc0a15439 fix: HostDialog title 2025-02-14 20:54:51 +08:00
hstyi
a25b97614f feat: Floating Toolbar (#231) 2025-02-14 20:38:46 +08:00
hstyi
4e12c32566 chore: stop listening if the file does not exist (#230) 2025-02-14 15:36:26 +08:00
hstyi
ea9c0f1225 chore: optimising SFTP for Linux edit (#229) 2025-02-14 15:00:19 +08:00
hstyi
ff865f13a2 fix: AppImage not working 2025-02-14 14:41:52 +08:00
hstyi
9875200912 chore: toolbar strut (#227) 2025-02-14 13:58:11 +08:00
hstyi
9f218d004e fix: tab drag (#226) 2025-02-14 13:54:39 +08:00
hstyi
ab727f66f4 fix: windows action cache 2025-02-14 12:46:37 +08:00
hstyi
efbc0302e4 chore: wget quiet 2025-02-14 12:34:27 +08:00
hstyi
ab2367d670 chore: linux AppImage and actions/cache (#222) 2025-02-14 12:27:14 +08:00
hstyi
045e4f81d6 feat: export configuration file support encryption (#221) 2025-02-14 12:18:37 +08:00
hstyi
160cfee947 chore: linux logo 2025-02-13 20:00:44 +08:00
hstyi
0e40b5ecce feat: open with SFTP (#217) 2025-02-13 17:04:14 +08:00
hstyi
fcaddcee80 feat: open SFTP directly to the current SSH server (#216) 2025-02-13 16:46:52 +08:00
hstyi
8d6295fd3b fix: auto wrap mode (#215) 2025-02-13 15:50:50 +08:00
hstyi
d0d51b3e6f fix: authentication dialog 2025-02-12 17:32:31 +08:00
hstyi
b8d612f1d5 feat: supports one-time authorised connection (#211) 2025-02-12 17:13:30 +08:00
hstyi
f7c49cde0c feat: supports custom editing of SFTP command (#210) 2025-02-12 16:33:37 +08:00
hstyi
189f8fb3ba feat: SFTP file editing support (#209) 2025-02-12 15:55:51 +08:00
hstyi
2a64bd28a8 chore: HostTree.showMoreInfo 2025-02-12 14:30:01 +08:00
hstyi
8a733379a3 feat: known_hosts (#206) 2025-02-12 11:45:55 +08:00
hstyi
e5f854dfcd feat: HostTree shows more information (#203) 2025-02-12 11:45:39 +08:00
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
221 changed files with 8584 additions and 1959 deletions

47
.github/workflows/linux-aarch64.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Linux aarch64
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-aarch64-b825.69.tar.gz
# appimagetool
- run: sudo apt install libfuse2
# 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
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# dist
- run: |
./gradlew dist --no-daemon
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-linux-aarch64
path: |
build/distributions/*.tar.gz
build/distributions/*.AppImage

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

@@ -0,0 +1,47 @@
name: Linux x86-64
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b825.69.tar.gz
# appimagetool
- run: sudo apt install libfuse2
# 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
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# 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
build/distributions/*.AppImage

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

@@ -0,0 +1,69 @@
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' && github.repository == 'TermoraDev/termora'
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 -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz
# 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
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# 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

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

@@ -0,0 +1,71 @@
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' && github.repository == 'TermoraDev/termora'
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 -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz
# 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
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# 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

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

@@ -0,0 +1,39 @@
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'
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# dist
- run: |
.\gradlew.bat dist --no-daemon
.\gradlew.bat --stop
- 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) & [Command](./docs/sftp-command.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) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- 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) & [命令行](./docs/sftp-command.png?raw=1) 文件传输
- 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议
- 支持 SSH 端口转发和跳板机
- 终端日志记录
- 支持配置同步到 [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- 支持宏(录制脚本并回放)
- 支持关键词高亮
- 支持密钥管理器
- 支持将命令发送到多个会话
- 支持 [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
@@ -7,6 +8,7 @@ import java.nio.file.Files
plugins {
java
idea
application
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlinx.serialization)
@@ -14,10 +16,10 @@ plugins {
group = "app.termora"
version = "1.0.2"
version = "1.0.8"
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 +39,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 +106,17 @@ 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",
)
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,28 @@ 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:+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}"))
@@ -241,7 +273,17 @@ tasks.register<Exec>("jpackage") {
arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE)))
arguments.addAll(listOf("--vendor", "TermoraDev"))
arguments.addAll(listOf("--copyright", "TermoraDev"))
arguments.addAll(listOf("--description", "A terminal emulator and SSH client."))
if (os.isWindows) {
arguments.addAll(
listOf(
"--description",
"${project.name.uppercaseFirstChar()}: A terminal emulator and SSH client"
)
)
} else {
arguments.addAll(listOf("--description", "A terminal emulator and SSH client."))
}
if (os.isMacOsX) {
@@ -259,6 +301,10 @@ tasks.register<Exec>("jpackage") {
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.ico"))
}
if (os.isLinux) {
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.png"))
}
arguments.add("--type")
if (os.isMacOsX) {
@@ -355,6 +401,56 @@ tasks.register("dist") {
throw GradleException("${os.name} is not supported")
}
// AppImage
if (os.isLinux) {
// Download AppImageKit
val appimagetool = FileUtils.getFile(projectDir, ".gradle", "appimagetool")
if (!appimagetool.exists()) {
exec {
commandLine(
"wget",
"-O", appimagetool.absolutePath,
"https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-${if (arch.isArm) "aarch64" else "x86_64"}.AppImage"
)
workingDir = distributionDir.asFile
}
// AppImageKit chmod
exec { commandLine("chmod", "+x", appimagetool.absolutePath) }
}
// Desktop file
val termoraName = project.name.uppercaseFirstChar()
val desktopFile = distributionDir.file(termoraName + File.separator + termoraName + ".desktop").asFile
desktopFile.writeText(
"""[Desktop Entry]
Type=Application
Name=${termoraName}
Comment=Terminal emulator and SSH client
Icon=/lib/${termoraName}
Categories=Development;
Terminal=false
""".trimIndent()
)
// AppRun file
val appRun = File(desktopFile.parentFile, "AppRun")
val sb = StringBuilder()
sb.append("#!/bin/sh").appendLine()
sb.append("SELF=$(readlink -f \"$0\")").appendLine()
sb.append("HERE=\${SELF%/*}").appendLine()
sb.append("exec \"\${HERE}/bin/${termoraName}\" \"$@\"")
appRun.writeText(sb.toString())
appRun.setExecutable(true)
exec {
commandLine(appimagetool.absolutePath, termoraName, "${finalFilenameWithoutExtension}.AppImage")
workingDir = distributionDir.asFile
}
}
// sign dmg
if (os.isMacOsX && macOSSign) {
@@ -372,6 +468,14 @@ tasks.register("dist") {
"--wait",
)
}
// 绑定公证信息
exec {
commandLine(
"/usr/bin/xcrun",
"stapler", "staple", macOSFinalFilePath,
)
}
}
}
}
@@ -396,6 +500,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")
}
}
}
}
}
@@ -425,4 +541,11 @@ kotlin {
@Suppress("UnstableApiUsage")
vendor = JvmVendorSpec.JETBRAINS
}
}
idea {
module {
isDownloadJavadoc = true
isDownloadSources = true
}
}

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

BIN
docs/sftp-command.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 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

@@ -15,15 +15,14 @@ import org.slf4j.LoggerFactory
import java.awt.Desktop
import java.io.File
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
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
@@ -63,6 +62,16 @@ object Application {
return "/bin/bash"
}
fun getTemporaryDir(): File {
val temporaryDir = File(getBaseDataDir(), "temporary")
FileUtils.forceMkdir(temporaryDir)
return temporaryDir
}
fun createSubTemporaryDir(prefix: String = getName()): Path {
return Files.createTempDirectory(getTemporaryDir().toPath(), prefix)
}
fun getBaseDataDir(): File {
if (::baseDataDir.isInitialized) {
return baseDataDir
@@ -114,33 +123,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,105 @@ 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() }
// clear temporary
clearTemporary()
// 启动主窗口
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)
}
}
}
@Suppress("OPT_IN_USAGE")
private fun clearTemporary() {
GlobalScope.launch(Dispatchers.IO) {
// 启动时清除
FileUtils.cleanDirectory(Application.getTemporaryDir())
// 关闭时清除
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
override fun dispose() {
FileUtils.cleanDirectory(Application.getTemporaryDir())
}
})
}
}
private fun openDoor() {
if (Doorman.instance.isWorking()) {
if (Doorman.getInstance().isWorking()) {
if (!DoormanDialog(null).open()) {
exitProcess(1)
}
@@ -81,17 +123,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 +146,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 +209,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 +242,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 +259,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 +309,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
@@ -14,36 +14,25 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.io.File
import java.util.*
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
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 +40,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()) }
}
}
@@ -60,9 +53,44 @@ class Database private constructor(private val env: Environment) : Disposable {
val safetyProperties by lazy { SafetyProperties("Setting.SafetyProperties") }
val terminal by lazy { Terminal() }
val appearance by lazy { Appearance() }
val sftp by lazy { SFTP() }
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 +441,7 @@ class Database private constructor(private val env: Environment) : Disposable {
/**
* 字体大小
*/
var fontSize by IntPropertyDelegate(16)
var fontSize by IntPropertyDelegate(14)
/**
* 最大行数
@@ -425,6 +453,11 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var debug by BooleanPropertyDelegate(false)
/**
* 蜂鸣声
*/
var beep by BooleanPropertyDelegate(true)
/**
* 选中复制
*/
@@ -434,6 +467,16 @@ class Database private constructor(private val env: Environment) : Disposable {
* 光标样式
*/
var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
/**
* 终端断开连接时自动关闭Tab
*/
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
/**
* 是否显示悬浮工具栏
*/
var floatingToolbar by BooleanPropertyDelegate(true)
}
/**
@@ -459,7 +502,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 +565,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")
/**
* 语言
@@ -532,6 +577,19 @@ class Database private constructor(private val env: Environment) : Disposable {
}
/**
* SFTP
*/
inner class SFTP : Property("Setting.SFTP") {
/**
* 编辑命令
*/
var editCommand by StringPropertyDelegate(StringUtils.EMPTY)
}
/**
* 同步配置
*/
@@ -548,6 +606,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

@@ -5,6 +5,17 @@ import org.apache.commons.lang3.StringUtils
import java.util.*
fun Map<*, *>.toPropertiesString(): String {
val env = StringBuilder()
for ((i, e) in entries.withIndex()) {
env.append(e.key).append('=').append(e.value)
if (i != size - 1) {
env.appendLine()
}
}
return env.toString()
}
fun UUID.toSimpleString(): String {
return toString().replace("-", StringUtils.EMPTY)
}
@@ -13,6 +24,13 @@ enum class Protocol {
Folder,
SSH,
Local,
Serial,
/**
* 交互式的 SFTP此协议只在系统内部交互不应该暴露给用户也不应该持久化
*/
@Transient
SFTPPty
}
@@ -39,6 +57,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 +126,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

@@ -1,5 +1,7 @@
package app.termora
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders
import app.termora.terminal.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -9,9 +11,10 @@ import java.beans.PropertyChangeEvent
import javax.swing.Icon
abstract class HostTerminalTab(
val windowScope: WindowScope,
val host: Host,
protected val terminal: Terminal = TerminalFactory.instance.createTerminal()
) : PropertyTerminalTab() {
protected val terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal()
) : PropertyTerminalTab(), DataProvider {
companion object {
val Host = DataKey(app.termora.Host::class)
}
@@ -68,4 +71,11 @@ abstract class HostTerminalTab(
unread = false
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == DataProviders.Terminal) {
return terminal as T?
}
return null
}
}

View File

@@ -1,9 +1,14 @@
package app.termora
import app.termora.db.Database
import app.termora.actions.AnActionEvent
import app.termora.actions.NewHostAction
import app.termora.actions.OpenHostAction
import app.termora.transport.SFTPAction
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeOpenIcon
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
import java.awt.Component
@@ -24,7 +29,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
@@ -52,6 +57,7 @@ class HostTree : JTree(), Disposable {
editor.preferredSize = Dimension(220, 0)
setCellRenderer(object : DefaultXTreeCellRenderer() {
private val properties get() = Database.getDatabase().properties
override fun getTreeCellRendererComponent(
tree: JTree,
value: Any,
@@ -62,11 +68,41 @@ class HostTree : JTree(), Disposable {
hasFocus: Boolean
): Component {
val host = value as Host
val c = super.getTreeCellRendererComponent(tree, host, sel, expanded, leaf, row, hasFocus)
if (host.protocol == Protocol.Folder) {
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
var text = host.name
// 是否显示更多信息
if (properties.getString("HostTree.showMoreInfo", "false").toBoolean()) {
val color = if (sel) {
if (this@HostTree.hasFocus()) {
UIManager.getColor("textHighlightText")
} else {
this.foreground
}
} else {
UIManager.getColor("textInactiveText")
}
if (host.protocol == Protocol.SSH) {
text = """
<html>${host.name}
&nbsp;&nbsp;
<font color=rgb(${color.red},${color.green},${color.blue})>${host.username}@${host.host}</font></html>
""".trimIndent()
} else if (host.protocol == Protocol.Serial) {
text = """
<html>${host.name}
&nbsp;&nbsp;
<font color=rgb(${color.red},${color.green},${color.blue})>${host.options.serialComm.port}</font></html>
""".trimIndent()
}
}
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
icon = when (host.protocol) {
Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
Protocol.Serial -> if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin
else -> if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal
}
return c
}
@@ -83,7 +119,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 +168,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))
}
}
}
@@ -310,12 +346,17 @@ class HostTree : JTree(), Disposable {
return
}
val properties = Database.getDatabase().properties
val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new"))
val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder"))
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host"))
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open"))
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect"))
val openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu
val openWithSFTP = openWith.add("SFTP")
val openWithSFTPCommand = openWith.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
val openInNewWindow = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open-in-new-window"))
popupMenu.addSeparator()
val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy"))
val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove"))
@@ -326,17 +367,28 @@ class HostTree : JTree(), Disposable {
popupMenu.addSeparator()
popupMenu.add(newMenu)
popupMenu.addSeparator()
val showMoreInfo = JCheckBoxMenuItem(I18n.getString("termora.welcome.contextmenu.show-more-info"))
showMoreInfo.isSelected = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
showMoreInfo.addActionListener {
properties.putString(
"HostTree.showMoreInfo",
showMoreInfo.isSelected.toString()
)
SwingUtilities.updateComponentTreeUI(this)
}
popupMenu.add(showMoreInfo)
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
open.addActionListener {
getSelectionNodes()
.filter { it.protocol != Protocol.Folder }
.forEach {
ActionManager.getInstance()
.getAction(Actions.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(this, it))
}
}
open.addActionListener { openHosts(it, false) }
openWithSFTP.addActionListener { openWithSFTP(it) }
openWithSFTPCommand.addActionListener { openWithSFTPCommand(it) }
openInNewWindow.addActionListener { openHosts(it, true) }
// 如果选中了 SSH 服务器,那么才启用
openWithSFTP.isEnabled = getSelectionNodes().any { it.protocol == Protocol.SSH }
openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled
openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled }
rename.addActionListener {
startEditingAtPath(TreePath(model.getPathToRoot(lastHost)))
@@ -412,13 +464,15 @@ class HostTree : JTree(), Disposable {
newHost.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
showAddHostDialog()
ActionManager.getInstance().getAction(NewHostAction.NEW_HOST)
?.actionPerformed(e)
}
})
property.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = HostDialog(SwingUtilities.getWindowAncestor(this@HostTree), lastHost)
dialog.title = lastHost.name
dialog.isVisible = true
val host = dialog.host ?: return
runCatchingHost(host)
@@ -451,30 +505,39 @@ 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 openWithSFTP(evt: EventObject) {
val nodes = getSelectionNodes().filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return
private fun expandNode(node: Host, including: Boolean = false) {
val sftpAction = ActionManager.getInstance().getAction(Actions.SFTP) as SFTPAction? ?: return
val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return
for (node in nodes) {
sftpAction.connectHost(node, tab)
}
}
private fun openWithSFTPCommand(evt: EventObject) {
val nodes = getSelectionNodes().filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return
val action = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return
for (host in nodes) {
action.actionPerformed(OpenHostActionEvent(this, host.copy(protocol = Protocol.SFTPPty), evt))
}
}
fun expandNode(node: Host, including: Boolean = false) {
expandPath(TreePath(model.getPathToRoot(node)))
if (including) {
model.getChildren(node).forEach { expandNode(it, true) }
@@ -541,18 +604,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,25 @@ 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 closeSmall by lazy { DynamicIcon("icons/closeSmall.svg", "icons/closeSmall_dark.svg") }
val closeSmallHovered by lazy { DynamicIcon("icons/closeSmallHovered.svg", "icons/closeSmallHovered_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") }
@@ -42,6 +50,7 @@ object Icons {
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") }
val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_dark.svg") }
val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_dark.svg") }
val azure by lazy { DynamicIcon("icons/azure.svg", "icons/azure_dark.svg") }
val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") }
val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") }
@@ -62,6 +71,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

@@ -12,6 +12,10 @@ fun main() {
setupNativeLibraries()
}
if (SystemUtils.IS_OS_MAC_OSX) {
System.setProperty("apple.awt.application.name", Application.getName())
}
ApplicationRunner().run()
}
@@ -41,4 +45,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,14 @@
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.FloatingToolbarPanel
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
@@ -31,13 +33,25 @@ class MultipleTerminalListener : TerminalPaintListener {
// 正在搜索那么需要下移
val finding = terminal.getTerminalModel().getData(TerminalPanel.Finding, false)
// 如果悬浮窗正在显示,那么需要下移
val floatingToolBar = terminalPanel.getData(FloatingToolbarPanel.FloatingToolbar)?.isVisible == true
var y = g.fontMetrics.ascent
if (finding) {
y += g.fontMetrics.height + g.fontMetrics.ascent / 2
}
if (floatingToolBar) {
y += g.fontMetrics.height + g.fontMetrics.ascent / 2
}
g.font = font
g.color = Color(colorPalette.getColor(TerminalColor.Normal.RED))
g.drawString(
text,
terminalDisplay.width - width - terminalPanel.getAverageCharWidth() / 2,
g.fontMetrics.ascent + if (finding)
g.fontMetrics.height + g.fontMetrics.ascent / 2 else 0
y
)
g.font = oldFont
}

View File

@@ -1,11 +1,266 @@
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)) {
tabIndex = -1
mousePressedPoint = Point()
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

@@ -6,8 +6,6 @@ import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXLabel
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Desktop
@@ -57,6 +55,7 @@ object OptionPane {
pane.selectInitialValue()
}
})
dialog.setLocationRelativeTo(parentComponent)
dialog.isVisible = true
dialog.dispose()
val selectedValue = pane.value

View File

@@ -1,7 +1,11 @@
package app.termora
import app.termora.actions.AnActionEvent
import app.termora.terminal.panel.FloatingToolbarPanel
import org.apache.commons.lang3.StringUtils
import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener
import java.util.*
abstract class PropertyTerminalTab : TerminalTab {
protected val listeners = mutableListOf<PropertyChangeListener>()
@@ -26,6 +30,10 @@ abstract class PropertyTerminalTab : TerminalTab {
override fun onLostFocus() {
hasFocus = false
// 切换标签时,尝试隐藏悬浮工具栏
val evt = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
evt.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerHide()
}

View File

@@ -1,41 +1,68 @@
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(
rows: Int = 24, cols: Int = 80,
env: Map<String, String> = emptyMap(),
charset: Charset = StandardCharsets.UTF_8
): PtyConnector {
val command = database.terminal.localShell
val commands = mutableListOf(command)
if (SystemUtils.IS_OS_UNIX) {
commands.add("-l")
}
return createPtyConnector(commands.toTypedArray(), rows, cols, env, charset)
}
fun createPtyConnector(
commands: Array<String>,
rows: Int = 24, cols: Int = 80,
env: Map<String, String> = emptyMap(),
charset: Charset = StandardCharsets.UTF_8
): PtyConnector {
val envs = mutableMapOf<String, String>()
envs.putAll(System.getenv())
envs["TERM"] = "xterm-256color"
envs.putAll(env)
val command = database.terminal.localShell
val commands = mutableListOf(command)
if (SystemUtils.IS_OS_UNIX) {
commands.add("-l")
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 ptyProcess = PtyProcessBuilder(commands.toTypedArray())
if (log.isDebugEnabled) {
log.debug("command: {} , envs: {}", commands.joinToString(" "), envs)
}
val ptyProcess = PtyProcessBuilder(commands)
.setEnvironment(envs)
.setInitialRows(rows)
.setInitialColumns(cols)

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

@@ -1,5 +1,6 @@
package app.termora
import app.termora.actions.DataProviders
import app.termora.terminal.*
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
@@ -10,9 +11,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 +24,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) {
@@ -43,12 +51,16 @@ abstract class PtyHostTerminalTab(
startPtyConnectorReader()
// 启动命令
if (host.options.startupCommand.isNotBlank()) {
if (host.options.startupCommand.isNotBlank() && host.protocol != Protocol.SFTPPty) {
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 +122,7 @@ abstract class PtyHostTerminalTab(
override fun dispose() {
stop()
terminalPanel
super.dispose()
@@ -123,4 +136,12 @@ abstract class PtyHostTerminalTab(
}
abstract suspend fun openPtyConnector(): PtyConnector
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == DataProviders.TerminalPanel) {
return terminalPanel as T?
}
return super.getData(dataKey)
}
}

View File

@@ -0,0 +1,146 @@
package app.termora
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.Window
import java.awt.event.ItemEvent
import javax.swing.*
import kotlin.math.max
class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
private val rememberCheckBox = JCheckBox("Remember")
private val passwordPanel = JPanel(BorderLayout())
private val passwordPasswordField = OutlinePasswordField()
private val publicKeyComboBox = OutlineComboBox<OhKeyPair>()
private val keyManager get() = KeyManager.getInstance()
private var authentication = Authentication.No
init {
isModal = true
title = "SSH User Authentication"
controlsVisible = false
init()
pack()
size = Dimension(max(380, size.width), size.height)
setLocationRelativeTo(null)
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
return super.getListCellRendererComponent(
list,
if (value is OhKeyPair) value.name else value,
index,
isSelected,
cellHasFocus
)
}
}
for (keyPair in keyManager.getOhKeyPairs()) {
publicKeyComboBox.addItem(keyPair)
}
authenticationTypeComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
switchPasswordComponent()
}
}
}
override fun createCenterPanel(): JComponent {
authenticationTypeComboBox.addItem(AuthenticationType.Password)
authenticationTypeComboBox.addItem(AuthenticationType.PublicKey)
val formMargin = "7dlu"
val layout = FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref"
)
switchPasswordComponent()
return FormBuilder.create().padding("$formMargin, $formMargin, $formMargin, $formMargin")
.layout(layout)
.add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, 1)
.add(authenticationTypeComboBox).xy(3, 1)
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 3)
.add(passwordPanel).xy(3, 3)
.build()
}
private fun switchPasswordComponent() {
passwordPanel.removeAll()
if (authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
passwordPanel.add(passwordPasswordField, BorderLayout.CENTER)
} else if (authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
passwordPanel.add(publicKeyComboBox, BorderLayout.CENTER)
}
passwordPanel.revalidate()
passwordPanel.repaint()
}
override fun createSouthPanel(): JComponent? {
val box = super.createSouthPanel() ?: return null
rememberCheckBox.isFocusable = false
box.add(rememberCheckBox, 0)
return box
}
override fun doCancelAction() {
authentication = Authentication.No
super.doCancelAction()
}
override fun doOKAction() {
val type = authenticationTypeComboBox.selectedItem as AuthenticationType
if (type == AuthenticationType.Password) {
if (passwordPasswordField.password.isEmpty()) {
passwordPasswordField.requestFocusInWindow()
return
}
} else if (type == AuthenticationType.PublicKey) {
if (publicKeyComboBox.selectedItem == null) {
publicKeyComboBox.requestFocusInWindow()
return
}
}
authentication = authentication.copy(
type = type,
password = if (type == AuthenticationType.Password) String(passwordPasswordField.password)
else (publicKeyComboBox.selectedItem as OhKeyPair).id
)
super.doOKAction()
}
fun getAuthentication(): Authentication {
isModal = true
SwingUtilities.invokeLater { passwordPasswordField.requestFocusInWindow() }
isVisible = true
return authentication
}
fun isRemembered(): Boolean {
return rememberCheckBox.isSelected
}
}

View File

@@ -0,0 +1,196 @@
package app.termora
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.*
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.Charsets
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter
import org.apache.sshd.common.util.net.SshdSocketAddress
import java.awt.event.KeyEvent
import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import javax.swing.Icon
import javax.swing.SwingUtilities
class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
private val keyManager by lazy { KeyManager.getInstance() }
private val tempFiles = mutableListOf<Path>()
private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
companion object {
val canSupports by lazy {
val process = if (SystemInfo.isWindows) {
ProcessBuilder("cmd.exe", "/c", "where", "sftp").start()
} else {
ProcessBuilder("which", "sftp").start()
}
process.waitFor()
return@lazy process.exitValue() == 0
}
}
override suspend fun openPtyConnector(): PtyConnector {
val useJumpHosts = host.options.jumpHosts.isNotEmpty() || host.proxy.type != ProxyType.No
val commands = mutableListOf("sftp")
var host = this.host
// 如果配置了跳板机或者代理,那么通过 SSH 的端口转发到本地
if (useJumpHosts) {
host = host.copy(
tunnelings = listOf(
Tunneling(
type = TunnelingType.Local,
sourceHost = SshdSocketAddress.LOCALHOST_NAME,
destinationHost = SshdSocketAddress.LOCALHOST_NAME,
destinationPort = host.port,
)
)
)
val sshClient = SshClients.openClient(host).apply { sshClient = this }
val sshSession = SshClients.openSession(host, sshClient).apply { sshSession = this }
// 打开通道
for (tunneling in host.tunnelings) {
val address = SshClients.openTunneling(sshSession, host, tunneling)
host = host.copy(host = address.hostName, port = address.port)
}
}
if (useJumpHosts) {
// 打开通道后忽略 key 检查
commands.add("-o")
commands.add("StrictHostKeyChecking=no")
// 不保存 known_hosts
commands.add("-o")
commands.add("UserKnownHostsFile=${if (SystemInfo.isWindows) "NUL" else "/dev/null"}")
} else {
// known_hosts
commands.add("-o")
commands.add("UserKnownHostsFile=${File(Application.getBaseDataDir(), "known_hosts").absolutePath}")
}
// Compression
commands.add("-o")
commands.add("Compression=yes")
// HostKeyAlgorithms 让 SFTP 命令的顺序和 sshd 的一致 这样可以避免 known_hosts 文件不一致问题
val hostKeyAlgorithms = ClientBuilder.setUpDefaultSignatureFactories(true).joinToString(",") { it.name }
commands.add("-o")
commands.add("HostKeyAlgorithms=${hostKeyAlgorithms}")
// 不使用配置文件
commands.add("-F")
commands.add("/dev/null")
// port
commands.add("-P")
commands.add(host.port.toString())
// 设置认证信息
setAuthentication(commands, host)
val envs = host.options.envs()
if (envs.containsKey("CurrentDir")) {
val currentDir = envs.getValue("CurrentDir")
commands.add("${host.username}@${host.host}:${currentDir}")
} else {
commands.add("${host.username}@${host.host}")
}
val winSize = terminalPanel.winSize()
val ptyConnector = ptyConnectorFactory.createPtyConnector(
commands.toTypedArray(),
winSize.rows, winSize.cols,
host.options.envs(),
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
)
return ptyConnector
}
private fun setAuthentication(commands: MutableList<String>, host: Host) {
// 如果通过公钥连接
if (host.authentication.type == AuthenticationType.PublicKey) {
val keyPair = keyManager.getOhKeyPair(host.authentication.password)
if (keyPair != null) {
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(keyPair)
val privateKeyPath = Application.createSubTemporaryDir()
val privateKeyFile = Files.createTempFile(privateKeyPath, Application.getName(), StringUtils.EMPTY)
Files.newOutputStream(privateKeyFile)
.use { OpenSSHKeyPairResourceWriter.INSTANCE.writePrivateKey(keyPair, null, null, it) }
commands.add("-i")
commands.add(privateKeyFile.toFile().absolutePath)
tempFiles.add(privateKeyPath)
}
} else if (host.authentication.type == AuthenticationType.Password) {
terminal.getTerminalModel().addDataListener(PasswordReporterDataListener(host).apply {
lastPasswordReporterDataListener = this
})
}
}
override fun stop() {
// 删除密码监听
lastPasswordReporterDataListener?.let { listener ->
SwingUtilities.invokeLater { terminal.getTerminalModel().removeDataListener(listener) }
}
IOUtils.closeQuietly(sshSession)
IOUtils.closeQuietly(sshClient)
tempFiles.removeIf {
FileUtils.deleteQuietly(it.toFile())
true
}
super.stop()
}
override fun getIcon(): Icon {
return Icons.fileFormat
}
private inner class PasswordReporterDataListener(private val host: Host) : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
if (key == VisualTerminal.Written && data is String) {
// 要求输入密码
val line = terminal.getDocument().getScreenLine(terminal.getCursorModel().getPosition().y)
if (line.getText().trim().trimIndent().startsWith("${host.username}@${host.host}'s password:")) {
// 删除密码监听
terminal.getTerminalModel().removeDataListener(this)
val ptyConnector = getPtyConnector()
// password
ptyConnector.write(host.authentication.password.toByteArray(ptyConnector.getCharset()))
// enter
ptyConnector.write(
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
.toByteArray(ptyConnector.getCharset())
)
}
}
}
}
}

View File

@@ -1,5 +1,8 @@
package app.termora
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import app.termora.transport.TransportDataProviders
import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener
import javax.swing.Icon
@@ -7,7 +10,7 @@ import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
class SFTPTerminalTab : Disposable, TerminalTab {
class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
private val transportPanel by lazy {
TransportPanel().apply {
@@ -40,8 +43,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
}
@@ -53,4 +56,12 @@ class SFTPTerminalTab : Disposable, TerminalTab {
) == JOptionPane.OK_OPTION
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == TransportDataProviders.TransportPanel) {
return transportPanel as T
}
return null
}
}

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
@@ -21,14 +26,15 @@ import org.apache.sshd.common.channel.ChannelListener
import org.apache.sshd.common.session.Session
import org.apache.sshd.common.session.SessionListener
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.*
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 +44,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
@@ -77,9 +86,24 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
terminal.write("SSH client is opening...\r\n")
}
var host = this.host.copy(authentication = this.host.authentication.copy())
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
val client = SshClients.openClient(host).also { sshClient = it }
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// keyboard interactive
client.userInteraction = TerminalUserInteraction(SwingUtilities.getWindowAncestor(terminalPanel))
client.userInteraction = TerminalUserInteraction(owner)
if (host.authentication.type == AuthenticationType.No) {
withContext(Dispatchers.Swing) {
val dialog = RequestAuthenticationDialog(owner)
val authentication = dialog.getAuthentication()
host = host.copy(authentication = authentication)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(this@SSHTerminalTab.host.copy(authentication = authentication))
}
}
}
val sessionListener = MySessionListener()
val channelListener = MyChannelListener()
@@ -109,12 +133,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)
}
}
}
}
}
})
@@ -148,28 +192,8 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
}
for (tunneling in host.tunnelings) {
if (tunneling.type == TunnelingType.Local) {
session.startLocalPortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
)
} else if (tunneling.type == TunnelingType.Remote) {
session.startRemotePortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
)
} else if (tunneling.type == TunnelingType.Dynamic) {
session.startDynamicPortForwarding(
SshdSocketAddress(
tunneling.sourceHost,
tunneling.sourcePort
)
)
}
if (log.isInfoEnabled) {
log.info("SSH [{}] started {} port forwarding.", host.name, tunneling.name)
}
SshClients.openTunneling(session, host, tunneling)
withContext(Dispatchers.Swing) {
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,28 +2,50 @@ package app.termora
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.config.hosts.HostConfigEntry
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
import org.apache.sshd.client.config.hosts.KnownHostEntry
import org.apache.sshd.client.kex.DHGClient
import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier
import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
import org.apache.sshd.client.keyverifier.ServerKeyVerifier
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.config.keys.KeyUtils
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
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
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.awt.Window
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.SocketAddress
import java.nio.file.Path
import java.nio.file.Paths
import java.security.PublicKey
import java.time.Duration
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
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,7 +79,74 @@ object SshClients {
* 打开一个会话
*/
fun openSession(host: Host, client: SshClient): ClientSession {
val session = client.connect(host.username, host.host, host.port)
// 如果没有跳板机直接连接
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, i != 0))
// 如果有下一跳
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()
}
fun isMiddleware(session: ClientSession): Boolean {
if (session is JGitClientSession) {
if (session.hostConfigEntry.properties["Middleware"]?.toBoolean() == true) {
return true
}
}
return false
}
/**
* @param middleware 如果为 true 表示是跳板
*/
private fun doOpenSession(host: Host, client: SshClient, middleware: Boolean = false): ClientSession {
val entry = HostConfigEntry()
entry.port = host.port
entry.username = host.username
entry.hostName = host.host
entry.setProperty("Middleware", middleware.toString())
val session = client.connect(entry)
.verify(timeout).session
if (host.authentication.type == AuthenticationType.Password) {
session.addPasswordIdentity(host.authentication.password)
@@ -73,6 +162,42 @@ object SshClients {
return session
}
fun openTunneling(session: ClientSession, host: Host, tunneling: Tunneling): SshdSocketAddress {
val sshdSocketAddress = if (tunneling.type == TunnelingType.Local) {
session.startLocalPortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
)
} else if (tunneling.type == TunnelingType.Remote) {
session.startRemotePortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
)
} else if (tunneling.type == TunnelingType.Dynamic) {
session.startDynamicPortForwarding(
SshdSocketAddress(
tunneling.sourceHost,
tunneling.sourcePort
)
)
} else {
SshdSocketAddress.LOCALHOST_ADDRESS
}
if (log.isInfoEnabled) {
log.info(
"SSH [{}] started {} port forwarding. host: {} , port: {}",
host.name,
tunneling.name,
sshdSocketAddress.hostName,
sshdSocketAddress.port
)
}
return sshdSocketAddress
}
/**
* 打开一个客户端
*/
@@ -81,7 +206,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 +227,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) {
@@ -117,4 +261,94 @@ object SshClients {
sshClient.start()
return sshClient
}
}
}
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
override fun verifyServerKey(
clientSession: ClientSession,
remoteAddress: SocketAddress,
serverKey: PublicKey
): Boolean {
if (SshClients.isMiddleware(clientSession)) {
return true
}
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait {
result.set(
OptionPane.showConfirmDialog(
parentComponent = owner,
message = I18n.getString(
"termora.host.verify-server-key",
remoteAddress.toString().replace("/", StringUtils.EMPTY),
KeyUtils.getKeyType(serverKey),
KeyUtils.getFingerPrint(serverKey)
),
optionType = JOptionPane.OK_CANCEL_OPTION,
messageType = JOptionPane.WARNING_MESSAGE,
) == JOptionPane.OK_OPTION
)
}
return result.get()
}
override fun acceptModifiedServerKey(
clientSession: ClientSession?,
remoteAddress: SocketAddress?,
entry: KnownHostEntry?,
expected: PublicKey?,
actual: PublicKey?
): Boolean {
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait {
result.set(
OptionPane.showConfirmDialog(
parentComponent = owner,
message = I18n.getString(
"termora.host.modified-server-key",
remoteAddress.toString().replace("/", StringUtils.EMPTY),
KeyUtils.getKeyType(expected),
KeyUtils.getFingerPrint(expected),
KeyUtils.getKeyType(actual),
KeyUtils.getFingerPrint(actual),
),
optionType = JOptionPane.OK_CANCEL_OPTION,
messageType = JOptionPane.WARNING_MESSAGE,
) == JOptionPane.OK_OPTION
)
}
return result.get()
}
}
class DialogServerKeyVerifier(
owner: Window,
) : KnownHostsServerKeyVerifier(
MyDialogServerKeyVerifier(owner),
Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts")
) {
init {
modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor
}
override fun updateKnownHostsFile(
clientSession: ClientSession?,
remoteAddress: SocketAddress?,
serverKey: PublicKey?,
file: Path?,
knownHosts: Collection<HostEntryPair?>?
): KnownHostEntry? {
if (clientSession is JGitClientSession) {
if (SshClients.isMiddleware(clientSession)) {
return null
}
}
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts)
}
}

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,27 @@ 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 getAllTerminalPanel(): List<TerminalPanel> {
return ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }
.flatMap { it.getTerminalPanels() }
}
}
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 +58,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,38 @@
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 java.util.*
import javax.swing.*
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
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 +54,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 +89,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 +117,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 +180,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 +189,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,30 +206,43 @@ 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
}
}
})
if (tab is HostTerminalTab) {
val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST)
if (openHostAction != null) {
if (tab.host.protocol == Protocol.SSH || tab.host.protocol == Protocol.SFTPPty) {
popupMenu.addSeparator()
val sftpCommand = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
sftpCommand.addActionListener { openSFTPPtyTab(tab, openHostAction, it) }
}
}
}
@@ -309,9 +286,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,21 +298,57 @@ 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)
}
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
if (!SFTPPtyTerminalTab.canSupports) {
OptionPane.showMessageDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.tabbed.contextmenu.sftp-not-install"),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
var host = tab.host
if (host.protocol == Protocol.SSH) {
val envs = tab.host.options.envs().toMutableMap()
val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel()
?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY
if (currentDir.isNotBlank()) {
envs["CurrentDir"] = currentDir
}
host = host.copy(
protocol = Protocol.SFTPPty,
options = host.options.copy(env = envs.toPropertiesString())
)
}
openHostAction.actionPerformed(OpenHostActionEvent(this, host, evt))
}
/**
* 对着 ToolBar 右键
*/
@@ -426,7 +438,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 +467,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,17 +98,21 @@ 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
}
}))
toolbar.add(Box.createHorizontalGlue())
if (SystemInfo.isLinux || SystemInfo.isWindows) {
toolbar.add(Box.createHorizontalStrut(16))
}
// update btn
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))

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,35 @@
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
// 如果不支持 SFTP 那么不处理这个响应
if (evt.host.protocol == Protocol.SFTPPty) {
if (!SFTPPtyTerminalTab.canSupports) {
return
}
}
val tab = when (evt.host.protocol) {
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
Protocol.SFTPPty -> SFTPPtyTerminalTab(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,22 @@
package app.termora.actions
import app.termora.I18n
class TerminalClearScreenAction : AnAction() {
companion object {
const val CLEAR_SCREEN = "ClearScreen"
}
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.clear-screen"))
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
}
}

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