Compare commits

...

344 Commits
1.0.1 ... main

Author SHA1 Message Date
Srar
b499667cbb fix: copy hotkey conflicts with ctrlc 2025-12-12 09:28:29 +08:00
hstyi
1d596e18df chore: disable opengl 2025-07-03 08:48:48 +08:00
hstyi
6f95033009 release: 1.0.17 2025-06-17 09:24:56 +08:00
hstyi
1f08af6575 fix: mixpanel endpoint 2025-06-16 10:16:49 +08:00
hstyi
071a091347 fix: title not showing on Linux 2025-06-16 09:23:10 +08:00
hstyi
ca484618c7 chore: upgrade jdk 21.0.7b1034.51 2025-06-12 17:38:48 +08:00
hstyi
1f68f8a112 fix: text cursor not working (#637) 2025-06-11 10:52:43 +08:00
hstyi
0cd5670bd3 chore: winget.yml 2025-06-11 08:47:47 +08:00
hstyi
8e9c6bcb68 fix: macOS background running (#633) 2025-06-10 17:19:28 +08:00
hstyi
6c1fa0fc53 fix: custom toolbar action missing (#630) 2025-06-10 11:34:31 +08:00
hstyi
5145cfa8a5 release: 1.0.16 2025-06-10 08:34:03 +08:00
hstyi
87b1a5e315 fix: snippet \ character escape (#625) 2025-06-09 14:17:37 +08:00
hstyi
fa59869f2c fix: authentication username not being saved (#622) 2025-06-09 09:47:00 +08:00
kanoshiou
1ae64fe0db perf: lazy loading OptionsPane and Fonts (#619) 2025-06-07 12:07:55 +08:00
hstyi
f8d363836e chore: improve the host text field (#617) 2025-06-05 23:40:20 +08:00
dependabot[bot]
38dccb1d22 chore(deps): bump org.jetbrains.pty4j:pty4j from 0.13.5 to 0.13.6 (#613)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 11:11:50 +08:00
hstyi
3e31a89b92 chore: SFTP edit command supports manual file selection (#612) 2025-06-03 16:55:39 +08:00
kanoshiou
d8f892cc02 fix: missing remark when importing keys (#611) 2025-06-03 13:42:09 +08:00
hstyi
873deb55aa fix: SSH authentication causing IP and port changes (#610) 2025-06-03 12:55:41 +08:00
hstyi
c08712d79b fix: Xterm Send Device Attributes (Primary DA) (#607) 2025-05-30 10:44:53 +08:00
dependabot[bot]
61bc905727 chore(deps): bump org.testcontainers:testcontainers-bom from 1.21.0 to 1.21.1 (#606)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-30 10:28:44 +08:00
hstyi
17859be3c5 feat: confirm tab close (#605) 2025-05-30 09:48:48 +08:00
hstyi
7a24e34695 fix: delete leftover files before installing Windows (#604) 2025-05-30 09:11:57 +08:00
dependabot[bot]
58638eaad8 chore(deps): bump org.jetbrains.pty4j:pty4j from 0.13.4 to 0.13.5 (#603)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 18:26:50 +08:00
hstyi
09d2f2d193 chore: dialog location (#602) 2025-05-28 13:16:42 +08:00
hstyi
9121eff8d8 feat: support importing RDP protocol from CSV (#600) 2025-05-27 09:57:12 +08:00
dependabot[bot]
8b090b0526 chore(deps): bump org.gradle.toolchains.foojay-resolver-convention from 0.10.0 to 1.0.0 (#595) 2025-05-21 12:45:45 +08:00
hstyi
15a0d642ff feat: support block selection (#594) 2025-05-19 18:31:51 +08:00
hstyi
dc4333da21 release: 1.0.15 2025-05-19 11:34:23 +08:00
hstyi
184f6d46dc fix: snippet scroll (#587) 2025-05-16 13:17:02 +08:00
hstyi
68788905fe chore: improve sftp tab (#583) 2025-05-14 23:24:52 +08:00
dependabot[bot]
fc46216a3f chore(deps): bump kotlin from 2.1.20 to 2.1.21 (#580)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 11:43:24 +08:00
hstyi
563143645e fix: SFTP drag and drop upload (#578) 2025-05-13 13:50:08 +08:00
hstyi
891ccb901b chore: maven-publish 2025-05-12 16:56:01 +08:00
hstyi
928a866fe7 feat: improve SFTP (#572) 2025-05-12 15:37:39 +08:00
hstyi
ea25b5b46f feat: modify permissions to support recursion (#571) 2025-05-12 15:26:29 +08:00
hstyi
1de10e6129 fix: process Device Status Report (DSR) (#570) 2025-05-12 11:33:39 +08:00
hstyi
aaf9c2e8d2 feat: support for disabling hyperlink (#568) 2025-05-12 11:05:39 +08:00
hstyi
b8196b5730 fix: snippet unescape (#567) 2025-05-12 10:50:48 +08:00
hstyi
0a83e8beb4 fix: double-click to open the host (#566) 2025-05-12 10:49:35 +08:00
hstyi
bdf29b27e7 release: 1.0.14 2025-05-07 12:01:46 +08:00
hstyi
96da7eac41 chore: scroll to the bottom after pressed any key (#553) 2025-05-01 08:36:51 +08:00
hstyi
71c0751692 fix: test connect (#551) 2025-04-30 15:13:11 +08:00
dependabot[bot]
442f334af2 chore(deps): bump com.github.mwiede:jsch from 0.2.25 to 0.2.26 (#546)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-30 09:15:06 +08:00
hstyi
48302a519f fix: snippet i18n (#549) 2025-04-29 15:10:01 +08:00
hstyi
c00f759f15 fix: xterm CBT (#543) 2025-04-28 15:47:55 +08:00
hstyi
1736dd909e chore: folder count (#542) 2025-04-28 09:11:07 +08:00
hstyi
1f01e368dd feat: support for signature algorithms (#539) 2025-04-27 09:54:22 +08:00
hstyi
bfba958b7e feat: support for compression algorithms (#538) 2025-04-26 10:00:54 +08:00
dependabot[bot]
758121b523 chore(deps): bump org.testcontainers:testcontainers-bom from 1.20.6 to 1.21.0 (#528)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-26 09:46:37 +08:00
hstyi
06e9a89e82 fix: double-click to open the host (#529) 2025-04-26 09:45:42 +08:00
hstyi
0ba6ac3305 chore: correct typos (#537) 2025-04-26 09:44:57 +08:00
hstyi
993f220b8b feat: support RDP protocol (#524) 2025-04-20 15:33:09 +08:00
hstyi
8755c4ad23 chore: tmux 2025-04-16 16:35:03 +08:00
dependabot[bot]
77cb102dd6 chore(deps): bump com.github.oshi:oshi-core from 6.6.5 to 6.8.1 (#517)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-16 11:08:23 +08:00
hstyi
89cfb0b451 fix: snippet \ characters (#513) 2025-04-15 17:17:24 +08:00
hstyi
6bdd83f208 fix: highlighter CJK characters (#511) 2025-04-15 15:51:45 +08:00
hstyi
8f86057dcc chore: KeyShortcut toHuman text (#510) 2025-04-15 09:19:16 +08:00
hstyi
a7d7ffa2cc chore: improve dialog 2025-04-15 08:52:02 +08:00
hstyi
d51cbeee13 feat: Highlighter keywords support regex (#507) 2025-04-14 14:29:00 +08:00
hstyi
deb2a0151e fix: Linux moving window jitter 2025-04-14 13:22:25 +08:00
dependabot[bot]
e1c4e9312d chore(deps): bump org.jetbrains.pty4j:pty4j from 0.13.3 to 0.13.4
Bumps [org.jetbrains.pty4j:pty4j](https://github.com/JetBrains/pty4j) from 0.13.3 to 0.13.4.
- [Commits](https://github.com/JetBrains/pty4j/commits)

---
updated-dependencies:
- dependency-name: org.jetbrains.pty4j:pty4j
  dependency-version: 0.13.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-14 10:44:08 +08:00
dependabot[bot]
c7233357bd chore(deps): bump commons-io:commons-io from 2.18.0 to 2.19.0
Bumps commons-io:commons-io from 2.18.0 to 2.19.0.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  dependency-version: 2.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-14 10:43:58 +08:00
hstyi
eff8d565d0 chore: upgrade flatlaf version 2025-04-14 10:42:30 +08:00
hstyi
932db49868 release: 1.0.13 2025-04-14 09:34:55 +08:00
hstyi
4d71c6cd05 chore: improve exit 2025-04-12 16:43:03 +08:00
hstyi
96133e5abf fix: default directory for SFTP Windows (#496) 2025-04-12 08:56:26 +08:00
hstyi
f06e5d7dc1 fix: Keymap sync override (#493) 2025-04-11 11:37:29 +08:00
dependabot[bot]
d4b96edccf chore(deps): bump org.gradle.toolchains.foojay-resolver-convention from 0.9.0 to 0.10.0 (#491)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-11 10:22:18 +08:00
dependabot[bot]
e9876d5b91 chore(deps): bump org.apache.commons:commons-text from 1.13.0 to 1.13.1 (#490)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-11 10:22:06 +08:00
hstyi
8b9a78a7bd chore: sync icon 2025-04-11 08:25:57 +08:00
hstyi
6b48f577e9 feat: macOS supports running in the background (#487) 2025-04-10 14:47:01 +08:00
hstyi
da9b6c21d6 fix: windows sftp path (#486) 2025-04-10 13:23:44 +08:00
hstyi
f1f889df14 chore: improve terminal close (#484) 2025-04-10 11:45:27 +08:00
hstyi
ed65853ebe fix: SFTP path not jumping on Windows (#483) 2025-04-10 11:08:51 +08:00
hstyi
5ffdd219d9 fix: Escape key (#482) 2025-04-10 09:37:05 +08:00
hstyi
4f84d6741c fix: JPopupMenu overlapping with background 2025-04-10 08:59:55 +08:00
hstyi
2568e7fcc8 fix: background image selection failure 2025-04-09 17:32:27 +08:00
hstyi
dddbb49084 feat: support setting background image (#475) 2025-04-09 16:03:38 +08:00
hstyi
95846ab135 fix: snippet unescape (#474) 2025-04-09 13:30:57 +08:00
dependabot[bot]
b5207e56c1 chore(deps): bump org.jetbrains.pty4j:pty4j from 0.13.2 to 0.13.3 (#471)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-09 10:42:50 +08:00
dependabot[bot]
160771e912 chore(deps): bump com.github.mwiede:jsch from 0.2.21 to 0.2.25 (#472)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-09 10:42:03 +08:00
dependabot[bot]
0fbe180f3f chore(deps): bump kotlinx-coroutines from 1.10.1 to 1.10.2 (#470)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-09 10:41:40 +08:00
hstyi
41a0409e9e fix: return to parent folder failure (#468) 2025-04-08 14:43:58 +08:00
hstyi
79e59143fb fix: last sync time (#467) 2025-04-08 14:40:20 +08:00
hstyi
54e0f621ce feat: support for restoring virtual windows 2025-04-07 11:51:55 +08:00
hstyi
4c8944d248 release: 1.0.12 2025-04-07 09:52:57 +08:00
hstyi
64bd95d8a8 chore: improve tree icon 2025-04-03 15:44:41 +08:00
hstyi
1d88942e8e feat: support automatic sync (#455) 2025-04-03 15:33:30 +08:00
hstyi
129e1b149a fix: vfs2 cache memory leaks 2025-04-03 00:55:26 +08:00
hstyi
01aac98437 feat: vfs2 2025-04-03 00:49:18 +08:00
dependabot[bot]
f9aaf7143f chore(deps): bump cn.hutool:hutool-all from 5.8.34 to 5.8.37
Bumps [cn.hutool:hutool-all](https://github.com/looly/hutool) from 5.8.34 to 5.8.37.
- [Release notes](https://github.com/looly/hutool/releases)
- [Changelog](https://github.com/chinabugotech/hutool/blob/v5-master/CHANGELOG.md)
- [Commits](https://github.com/looly/hutool/commits)

---
updated-dependencies:
- dependency-name: cn.hutool:hutool-all
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-02 11:54:17 +08:00
dependabot[bot]
28174483f4 chore(deps): bump org.jetbrains.kotlinx:kotlinx-serialization-json
Bumps [org.jetbrains.kotlinx:kotlinx-serialization-json](https://github.com/Kotlin/kotlinx.serialization) from 1.8.0 to 1.8.1.
- [Release notes](https://github.com/Kotlin/kotlinx.serialization/releases)
- [Changelog](https://github.com/Kotlin/kotlinx.serialization/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Kotlin/kotlinx.serialization/compare/v1.8.0...v1.8.1)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlinx:kotlinx-serialization-json
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-02 11:54:06 +08:00
hstyi
46412054c4 chore: improve x11 2025-04-01 17:53:13 +08:00
hstyi
1ab0d26bab chore: coroutine SupervisorJob 2025-04-01 15:57:32 +08:00
hstyi
d90fb9aa35 chore: swing CoroutineScope 2025-04-01 15:55:20 +08:00
hstyi
744e64b359 feat: support to set transparency (#446) 2025-04-01 14:57:57 +08:00
hstyi
2c5442f1f3 chore: improve x11 2025-04-01 10:38:33 +08:00
hstyi
054c4701d2 feat: support X11 forwarding (#443) 2025-04-01 00:54:02 +08:00
hstyi
54044625ea chore: remove proxy when session closes 2025-03-31 10:54:53 +08:00
hstyi
ca82704738 chore: improve proxy 2025-03-30 17:57:31 +08:00
hstyi
e98ec3fa8e chore: upgrade sshd version 2025-03-30 16:35:17 +08:00
hstyi
6a4abf7e50 fix: SSH proxy not working in jump hosts (#435) 2025-03-30 16:34:51 +08:00
hstyi
e2a6cceafd chore: upgrade jgit version 2025-03-30 15:58:42 +08:00
hstyi
283404b6b9 feat: SSH support ssh-agent (#433) 2025-03-30 12:48:14 +08:00
hstyi
c714f33a44 chore: telnetd Dockerfile 2025-03-29 22:45:44 +08:00
hstyi
30fe047e5c feat: authentication support fallback (#431) 2025-03-29 20:37:19 +08:00
hstyi
827d814c7b feat: improve sync (#429) 2025-03-29 19:09:04 +08:00
hstyi
ccb2c6daa0 fix: SFTP transport file 2025-03-29 14:40:42 +08:00
hstyi
1516d6d81e fix: SFTP add transport NPE 2025-03-29 14:34:50 +08:00
hstyi
09b3655c4e feat: SFTP file exists and prompts to overwrite (#426) 2025-03-29 13:41:02 +08:00
dependabot[bot]
614514c87e chore(deps): bump cash.z.ecc.android:kotlin-bip39 from 1.0.8 to 1.0.9
Bumps [cash.z.ecc.android:kotlin-bip39](https://github.com/zcash/kotlin-bip39) from 1.0.8 to 1.0.9.
- [Changelog](https://github.com/Electric-Coin-Company/kotlin-bip39/blob/main/CHANGELOG.md)
- [Commits](https://github.com/zcash/kotlin-bip39/compare/v1.0.8...v1.0.9)

---
updated-dependencies:
- dependency-name: cash.z.ecc.android:kotlin-bip39
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-28 11:39:38 +08:00
dependabot[bot]
30cba6720d chore(deps): bump org.mozilla:rhino from 1.7.15 to 1.8.0
Bumps [org.mozilla:rhino](https://github.com/mozilla/rhino) from 1.7.15 to 1.8.0.
- [Release notes](https://github.com/mozilla/rhino/releases)
- [Changelog](https://github.com/mozilla/rhino/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/mozilla/rhino/commits)

---
updated-dependencies:
- dependency-name: org.mozilla:rhino
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-28 10:16:37 +08:00
dependabot[bot]
dce6551de2 chore(deps): bump org.testcontainers:testcontainers-bom
Bumps [org.testcontainers:testcontainers-bom](https://github.com/testcontainers/testcontainers-java) from 1.20.4 to 1.20.6.
- [Release notes](https://github.com/testcontainers/testcontainers-java/releases)
- [Changelog](https://github.com/testcontainers/testcontainers-java/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testcontainers/testcontainers-java/compare/1.20.4...1.20.6)

---
updated-dependencies:
- dependency-name: org.testcontainers:testcontainers-bom
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-28 10:16:27 +08:00
dependabot[bot]
95943cdeec chore(deps): bump org.apache.commons:commons-csv from 1.13.0 to 1.14.0
Bumps [org.apache.commons:commons-csv](https://github.com/apache/commons-csv) from 1.13.0 to 1.14.0.
- [Changelog](https://github.com/apache/commons-csv/blob/master/RELEASE-NOTES.txt)
- [Commits](https://github.com/apache/commons-csv/compare/rel/commons-csv-1.13.0...rel/commons-csv-1.14.0)

---
updated-dependencies:
- dependency-name: org.apache.commons:commons-csv
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-28 10:13:50 +08:00
dependabot[bot]
18a26ee6bf chore(deps): bump jna from 5.16.0 to 5.17.0
Bumps `jna` from 5.16.0 to 5.17.0.

Updates `net.java.dev.jna:jna` from 5.16.0 to 5.17.0
- [Changelog](https://github.com/java-native-access/jna/blob/master/CHANGES.md)
- [Commits](https://github.com/java-native-access/jna/compare/5.16.0...5.17.0)

Updates `net.java.dev.jna:jna-platform` from 5.16.0 to 5.17.0
- [Changelog](https://github.com/java-native-access/jna/blob/master/CHANGES.md)
- [Commits](https://github.com/java-native-access/jna/compare/5.16.0...5.17.0)

---
updated-dependencies:
- dependency-name: net.java.dev.jna:jna
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: net.java.dev.jna:jna-platform
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-28 10:13:42 +08:00
dependabot[bot]
f23aae371a chore(deps): bump org.slf4j:slf4j-api from 2.0.16 to 2.0.17
Bumps org.slf4j:slf4j-api from 2.0.16 to 2.0.17.

---
updated-dependencies:
- dependency-name: org.slf4j:slf4j-api
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-28 10:12:10 +08:00
hstyi
757bc1c001 chore: improve dependencies 2025-03-28 10:10:48 +08:00
hstyi
a19222dc60 chore: add dependabot 2025-03-27 17:40:18 +08:00
hstyi
24677ca4a6 feat: Welcome search supports keyboard navigation (#404) 2025-03-27 17:33:24 +08:00
hstyi
0c5b6f8112 feat: support system tray (#403) 2025-03-27 17:22:13 +08:00
hstyi
7c26e3d08a release: 1.0.11 2025-03-27 12:03:11 +08:00
hstyi
9b84fb4ec8 chore: ignore verify server key (#398) 2025-03-27 11:52:36 +08:00
hstyi
d8ec7b6d4a chore: automatically jump to the bottom (#397) 2025-03-27 11:44:10 +08:00
hstyi
769c0d990b fix: max row selection 2025-03-17 08:48:46 +08:00
hstyi
3f1ae38b61 chore: improve tick 2025-03-17 08:48:34 +08:00
hstyi
e10fce21a2 fix: flat inspector key shortcut 2025-03-16 17:04:12 +08:00
hstyi
a00557bb9d feat: process lock (#380) 2025-03-16 17:02:40 +08:00
hstyi
e478535ae5 chore: visual window stick 2025-03-16 10:21:33 +08:00
hstyi
7756758738 fix: SFTP path not working 2025-03-16 10:05:32 +08:00
hstyi
e0ea42faee feat: floating window supports stick (#374) 2025-03-16 08:42:25 +08:00
hstyi
e72c6b77b5 chore: Dockerfile x11 2025-03-15 23:15:09 +08:00
hstyi
bcd3aacd6f fix: emacs alt x 2025-03-15 20:49:55 +08:00
hstyi
570b0e08ad fix: AWTEventListener memory leaks 2025-03-15 15:11:50 +08:00
hstyi
d703850e87 chore: sftp failed message 2025-03-15 14:57:25 +08:00
hstyi
4bb1a411e8 feat: without jbr 2025-03-15 13:20:08 +08:00
hstyi
9884ed19fa chore: macOS dispatch_async 2025-03-15 08:29:42 +08:00
hstyi
1ffaed3f36 fix: sftp ui 2025-03-14 12:25:25 +08:00
hstyi
4cb42953ad feat: sftp contextmenu (#366) 2025-03-14 11:47:41 +08:00
hstyi
0248992dc3 chore: Command + Q will not trigger a popup 2025-03-14 09:36:17 +08:00
hstyi
9bab9db875 chore: hide copied toast 2025-03-14 09:28:45 +08:00
hstyi
b283a3ea38 feat: supports importing hosts from SSH config (#359) 2025-03-14 00:03:02 +08:00
hstyi
98ac2928b4 fix: xterm-256 foreground & background color (#358) 2025-03-13 23:39:18 +08:00
hstyi
a0a6f43c10 fix: arrow keys 2025-03-13 23:39:01 +08:00
hstyi
0c158acbe0 fix: sftp symbolic link 2025-03-13 22:21:39 +08:00
hstyi
9a97b3a304 feat: send command to the current window sessions 2025-03-13 22:17:01 +08:00
hstyi
aef44bd0da chore: improve factories 2025-03-13 20:45:49 +08:00
hstyi
75c65d9ba8 feat: support edit host (#352) 2025-03-13 20:45:31 +08:00
hstyi
93755db77f fix: nano bg color 2025-03-13 17:10:17 +08:00
hstyi
79d0a9a348 refactor: SFTP (#351) 2025-03-13 16:33:57 +08:00
hstyi
422e9aac84 release: 1.0.10 2025-03-05 11:11:41 +08:00
hstyi
9915c373b7 chore: remind me next time 2025-02-28 12:43:12 +08:00
hstyi
eba85e6348 fix: emacs shift key 2025-02-27 20:43:51 +08:00
hstyi
483a7772f4 feat: support snippet (#321) 2025-02-27 16:48:25 +08:00
hstyi
dcc96358f6 chore: remind me next time 2025-02-26 16:05:19 +08:00
hstyi
b5c30d505b feat: improve FlatTabbedPaneUI (#314) 2025-02-25 15:45:48 +08:00
hstyi
1f3ef5f3f0 chore: upgrade jdk 21.0.6b895.91 2025-02-25 13:27:41 +08:00
hstyi
d388bcfc92 chore: improve floating toolbar 2025-02-24 18:38:33 +08:00
hstyi
562c1f98fe feat: support to open host by enter 2025-02-24 17:11:12 +08:00
hstyi
f3c5009a45 feat: supports remembering window positions 2025-02-24 16:27:53 +08:00
hstyi
09a1d9f51e chore: osx GitHub actions 2025-02-24 14:31:09 +08:00
hstyi
84b48278ad feat: support sftp status (#307) 2025-02-24 14:14:44 +08:00
hstyi
ef9caf2578 release: 1.0.9 2025-02-24 13:23:13 +08:00
hstyi
b85bdf840e feat: support automatic download of update packages (#305) 2025-02-24 12:38:30 +08:00
hstyi
a2d7f3b5bb chore: Inno Setup 2025-02-24 00:51:53 +08:00
hstyi
02a96d73c8 fix: linux won't restart 2025-02-23 22:33:15 +08:00
hstyi
9fb12c7a71 feat: SFTP command add key shortcut 2025-02-23 21:32:25 +08:00
hstyi
145d8fc802 chore: automatically notarise macOS releases when released 2025-02-23 15:00:42 +08:00
hstyi
72c9dba806 feat: support restart (#299) 2025-02-23 11:32:44 +08:00
hstyi
de20bd654c feat: supports importing hosts from PuTTY (#297) 2025-02-22 16:47:50 +08:00
hstyi
35b3a10746 feat: supports importing hosts from electerm (#296) 2025-02-22 15:59:43 +08:00
hstyi
05fe6a0eb1 feat: supports importing hosts from FinalShell (#295) 2025-02-22 15:32:48 +08:00
hstyi
0552917c26 feat: supports importing hosts from SecureCRT (#294) 2025-02-22 14:51:32 +08:00
hstyi
51c355c113 feat: supports importing hosts from MobaXterm (#293) 2025-02-22 14:04:31 +08:00
hstyi
034ee3791d feat: supports importing hosts from Xshell (#292) 2025-02-22 13:15:45 +08:00
hstyi
adabaf8f2d feat: supports importing hosts from CSV (#291) 2025-02-22 12:23:31 +08:00
hstyi
1f392c52a1 chore: win 7z 2025-02-21 22:31:40 +08:00
hstyi
28fe4c725f feat: supports importing hosts from WindTerm (#289) 2025-02-21 21:44:51 +08:00
hstyi
18fe92cb11 chore: upgrade dependency versions 2025-02-21 19:42:08 +08:00
hstyi
c49acf7b51 feat: support fixed SFTP tab (#286) 2025-02-21 17:04:50 +08:00
hstyi
7df317a1b9 feat: refactoring HostTree & support sorting (#285) 2025-02-21 16:24:45 +08:00
hstyi
219e5420f5 fix: memory parsing error (#284) 2025-02-20 21:24:38 +08:00
hstyi
aefb7c3014 chore: exclude sshd-osgi 2025-02-20 20:53:12 +08:00
hstyi
f0c7f06ff5 chore: optimize package size 2025-02-20 20:41:34 +08:00
hstyi
604e07b43a fix: memory leaks 2025-02-20 17:17:23 +08:00
hstyi
0000e4610a feat: nvidia smi (#280) 2025-02-20 16:45:53 +08:00
hstyi
510324d7c4 fix: tunnels causes connection failure (#279) 2025-02-20 12:13:27 +08:00
hstyi
33a359fcbf feat: system information (#278) 2025-02-20 12:05:45 +08:00
hstyi
0b84d3271c feat: FindEverywhere show more info 2025-02-19 15:24:28 +08:00
hstyi
57547c95cb feat: blink (#273) 2025-02-19 13:17:59 +08:00
hstyi
503cfa9a4e fix: terminal cursor error (#272) 2025-02-19 10:29:31 +08:00
hstyi
af1f979e31 feat: ⌘ + Q to exit prompt 2025-02-18 23:29:04 +08:00
hstyi
3cd9f92ea9 feat: support setting sftp path (#267) 2025-02-18 18:09:33 +08:00
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
hstyi
a71493e52c release: 1.0.2 2025-01-15 13:59:35 +08:00
hstyi
cb327f218c fix: 修复 macOS 没有对二进制依赖进行签名的问题 2025-01-15 13:57:47 +08:00
hstyi
6881b6376f feat: 支持 macOS 签名以及 Windows MSI 安装包 (#71) 2025-01-14 19:03:41 +08:00
hstyi
5027fd9dfb fix: 修复 SFTP 拖拽单个文件上传失败的问题 2025-01-10 18:42:58 +08:00
hstyi
49cef39b8b fix: 修复在极端情况下可能导致部分样式错乱问题 2025-01-10 18:28:38 +08:00
hstyi
5c4acf85e8 chore: unix shell login 2025-01-10 16:36:26 +08:00
hstyi
07bee64b7f chore: 修改 SFTP 图标 2025-01-10 15:08:52 +08:00
hstyi
923afb7e99 feat: 弹窗位置以父窗口为中心 (#55) 2025-01-10 15:08:01 +08:00
hstyi
68df52bfc0 fix: 修复 Windows 切换标签页导致误输入的问题 (#54) 2025-01-10 14:40:26 +08:00
hstyi
c2ee6fc8ac feat: analytics 2025-01-10 13:28:37 +08:00
hstyi
9d4562e7e3 fix: 修复 SFTP 格式化进度可能报错的问题 2025-01-10 12:32:36 +08:00
hstyi
5733b5f485 fix: 修复在同步配置时 “宏” 没有禁用的问题 (#49) 2025-01-10 11:24:32 +08:00
hstyi
9dbdb5fd7a fix: 修复 ESC[?xm 私有模式导致不正常渲染的问题 2025-01-10 10:57:24 +08:00
hstyi
a1d1821553 feat: vim 支持鼠标滚动 2025-01-10 10:57:05 +08:00
hstyi
4a8faea8c5 chore: 在新窗口中打开时标题跟随之前的 2025-01-09 20:53:22 +08:00
hstyi
cfb841db00 feat: support Dracula 2025-01-09 20:53:07 +08:00
hstyi
a87d4ddf82 fix: 修复在 Linux 环境下无法移动窗口的问题 2025-01-09 16:07:39 +08:00
hstyi
6071b251a4 fix: 修复终端日志重复记录的问题 2025-01-09 14:58:04 +08:00
hstyi
950ff517bb fix: 修复自定义工具栏排序无效的问题 2025-01-09 14:45:40 +08:00
hstyi
70008978d8 feat: 在工具栏添加 SFTP 快速打开 2025-01-09 14:15:49 +08:00
hstyi
7c445bdadb feat: 优化自定义工具栏的存储结构 2025-01-09 14:05:53 +08:00
hstyi
f24151f6d8 feat: 支持自定义工具栏 2025-01-09 13:11:46 +08:00
hstyi
7d65a88d63 fix: 修复在开发环境 “设置 - 关于” 页面地址 404 问题 2025-01-08 17:45:46 +08:00
hstyi
ed57c3e5b4 chore: 改进 SFTP 传输进度 2025-01-08 17:43:57 +08:00
hstyi
00f11c9ed5 feat: 添加非 macOS 系统下的 复制/粘贴 快捷键 (#31) 2025-01-08 15:10:02 +08:00
hstyi
5ebea06a95 feat: 当停止记录终端日志的时候立即关闭文件流 2025-01-08 11:46:40 +08:00
hstyi
3e5df2161b feat: support keyboard-interactive 2025-01-07 23:12:39 +08:00
hstyi
ffcb4d028e feat: 支持 OSC 52 指令 2025-01-07 23:12:07 +08:00
hstyi
022ae402cc feat: 支持终端日志记录 (#7) 2025-01-07 17:43:59 +08:00
379 changed files with 24378 additions and 6150 deletions

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 25

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.7-linux-aarch64-b1034.51.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.7'
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.7-linux-x64-b1034.51.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.7'
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

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

@@ -0,0 +1,84 @@
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
- name: Setup the Notary information
if: "startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora'"
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
STORE_CREDENTIALS: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-osx-aarch64-b1034.51.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.7'
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' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
# 只有发布版本时才需要公证
TERMORA_MAC_NOTARY: "${{ startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' }}"
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
./gradlew dist --no-daemon
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-osx-aarch64
path: |
build/distributions/*.zip
build/distributions/*.dmg

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

@@ -0,0 +1,86 @@
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
- name: Setup the Notary information
if: "startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora'"
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
STORE_CREDENTIALS: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-osx-x64-b1034.51.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.7'
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' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
# 只有发布版本时才需要公证
TERMORA_MAC_NOTARY: "${{ startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' }}"
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
./gradlew dist --no-daemon
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-osx-x86-64
path: |
build/distributions/*.zip
build/distributions/*.dmg

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

@@ -0,0 +1,48 @@
name: Windows x86-64
on: [ push, pull_request ]
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install zip
run: |
$system32 = [System.Environment]::GetEnvironmentVariable("WINDIR") + "\System32"
Invoke-WebRequest -Uri "http://stahlworks.com/dev/zip.exe" -OutFile "$system32\zip.exe"
Invoke-WebRequest -Uri "http://stahlworks.com/dev/unzip.exe" -OutFile "$system32\unzip.exe"
- name: Install 7z
uses: milliewalky/setup-7-zip@v2
- name: Installing Java
run: |
curl -s --output ${{ runner.temp }}\java_package.zip -L https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-windows-x64-b1034.51.zip
unzip -q ${{ runner.temp }}\java_package.zip -d ${{ runner.temp }}\jbr
echo "JAVA_HOME=${{ runner.temp }}\jbr\jbrsdk-21.0.7-windows-x64-b1034.51" >> $env:GITHUB_ENV
- 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/*.exe

14
.github/workflows/winget.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
name: Publish to WinGet
on:
release:
types: [ released ]
jobs:
publish:
runs-on: windows-latest
steps:
- uses: vedantmgoyal9/winget-releaser@main
if: github.repository == 'TermoraDev/termora'
with:
identifier: TermoraDev.Termora
installers-regex: '\.exe$'
token: ${{ secrets.WINGET_TOKEN }}

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ certs/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
.vs
### IntelliJ IDEA ###
.idea

View File

@@ -1,46 +1,52 @@
<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
- Support for X11 and SSH-Agent
- 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.

47
README.zh_CN.md Normal file
View File

@@ -0,0 +1,47 @@
# 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 端口转发和跳板机
- 支持 X11 和 SSH-Agent
- 终端日志记录
- 支持配置同步到 [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

@@ -1,231 +1,263 @@
annotations 24.0.1
annotations
Apache License 2.0
https://github.com/JetBrains/java-annotations/blob/master/LICENSE.txt
bip39-lib-jvm 1.0.8
kotlin-bip39
MIT License
https://github.com/Electric-Coin-Company/kotlin-bip39/blob/main/LICENSE
colorpicker 2.0.1
colorpicker
BSD 3-Clause "New" or "Revised" License
https://github.com/dheid/colorpicker/blob/main/LICENSE
commonmark 0.24.0
commonmark
BSD 2-Clause "Simplified" License
https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt
commons-codec 1.17.1
commons-codec
Apache License 2.0
https://github.com/apache/commons-codec/blob/master/LICENSE.txt
commons-compress 1.27.1
commons-compress
Apache License 2.0
https://github.com/apache/commons-compress/blob/master/LICENSE.txt
commons-io 2.18.0
commons-vfs2
Apache License 2.0
https://github.com/apache/commons-vfs/blob/master/LICENSE.txt
commons-io
Apache License 2.0
https://github.com/apache/commons-io/blob/master/LICENSE.txt
commons-lang3 3.17.0
commons-lang3
Apache License 2.0
https://github.com/apache/commons-lang/blob/master/LICENSE.txt
commons-net 3.11.1
commons-net
Apache License 2.0
https://github.com/apache/commons-net/blob/master/LICENSE.txt
commons-text 1.12.0
commons-text
Apache License 2.0
https://github.com/apache/commons-text/blob/master/LICENSE.txt
eddsa 0.3.0
commons-csv
Apache License 2.0
https://github.com/apache/commons-csv/blob/master/LICENSE.txt
ini4j
Apache License 2.0
http://www.apache.org/licenses/LICENSE-2.0.txt
eddsa
Creative Commons Zero v1.0 Universal
https://github.com/str4d/ed25519-java/blob/master/LICENSE.txt
flatlaf 3.5.4
flatlaf
Apache License 2.0
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
flatlaf-extras 3.5.4
flatlaf-no-natives
Apache License 2.0
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
flatlaf-swingx 3.5.4
flatlaf-extras
Apache License 2.0
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
JavaEWAH 1.2.3
flatlaf-swingx
Apache License 2.0
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
JavaEWAH
Apache License 2.0
https://github.com/lemire/javaewah/blob/master/LICENSE
jbr-api 17.1.10.1
jbr-api
Apache License 2.0
https://github.com/JetBrains/JetBrainsRuntimeApi/blob/main/LICENSE
jcl-over-slf4j 1.7.36
jcl-over-slf4j
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0.txt
jfa 1.2.0
jfa
Apache License 2.0
https://github.com/0x4a616e/jfa/blob/main/LICENSE
jgoodies-common 1.8.1
jgoodies-common
BSD-2-Clause License
http://www.opensource.org/licenses/bsd-license.html
jgoodies-forms 1.9.0
jgoodies-forms
BSD-2-Clause License
http://www.opensource.org/licenses/bsd-license.html
jna 5.16.0
jna
Apache License 2.0
https://github.com/java-native-access/jna/blob/master/AL2.0
jna-platform 5.16.0
jna-platform
Apache License 2.0
https://github.com/java-native-access/jna/blob/master/AL2.0
jnafilechooser-api 1.1.2
jnafilechooser-api
BSD 3-Clause "New" or "Revised" License
https://github.com/steos/jnafilechooser/blob/master/LICENSE
jnafilechooser-win32 1.1.2
jnafilechooser-win32
BSD 3-Clause "New" or "Revised" License
https://github.com/steos/jnafilechooser/blob/master/LICENSE
jsvg 1.4.0
jsvg
MIT License
https://github.com/weisJ/jsvg/blob/master/LICENSE
jSystemThemeDetector 3.9.1
jSystemThemeDetector
Apache License 2.0
https://github.com/Dansoftowner/jSystemThemeDetector/blob/master/LICENSE
kotlin-logging 1.7.9
kotlin-logging
Apache License 2.0
https://github.com/oshai/kotlin-logging/blob/master/LICENSE
kotlin-stdlib 2.1.0
kotlin-stdlib
Apache License 2.0
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
kotlin-stdlib-jdk7 1.9.10
kotlin-stdlib-jdk7
Apache License 2.0
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
kotlin-stdlib-jdk8 1.9.10
kotlin-stdlib-jdk8
Apache License 2.0
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
kotlin-stdlib-jdk8 1.9.10
kotlin-stdlib-jdk8
Apache License 2.0
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
kotlinx-coroutines-core-jvm 1.10.1
restart4j
Apache License 2.0
https://github.com/hstyi/restart4j/blob/main/LICENSE
kotlinx-coroutines-core
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
kotlinx-coroutines-swing 1.10.1
kotlinx-coroutines-swing
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
kotlinx-serialization-core-jvm 1.7.3
kotlinx-serialization-json
Apache License 2.0
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
kotlinx-serialization-json-jvm 1.7.3
Apache License 2.0
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
logging-interceptor 4.12.0
logging-interceptor
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
okhttp 4.12.0
okhttp
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
okio-jvm 3.6.0
okio-jvm
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
org.eclipse.jgit.ssh.apache 7.1.0.202411261347-r
org.eclipse.jgit.ssh.apache
Eclipse Distribution License
https://www.eclipse.org/org/documents/edl-v10.php
org.eclipse.jgit 7.1.0.202411261347-r
org.eclipse.jgit.ssh.apache.agent
Eclipse Distribution License
https://www.eclipse.org/org/documents/edl-v10.php
oshi-core 6.6.5
org.eclipse.jgit
Eclipse Distribution License
https://www.eclipse.org/org/documents/edl-v10.php
oshi-core
MIT License
https://github.com/oshi/oshi/blob/master/LICENSE
pty4j 0.13.2
pty4j
Eclipse Public License 1.0
https://github.com/JetBrains/pty4j/blob/master/LICENSE
slf4j-api 2.0.16
slf4j-api
MIT License
https://github.com/qos-ch/slf4j/blob/master/LICENSE.txt
slf4j-tinylog 2.7.0
slf4j-tinylog
Apache License 2.0
https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt
sshd-common 2.14.0
sshd-common
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
sshd-core 2.14.0
sshd-core
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
sshd-osgi 2.14.0
sshd-osgi
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
sshd-sftp 2.14.0
sshd-sftp
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
swingx-all 1.6.5-1
swingx-all
GNU LESSER GENERAL PUBLIC LICENSE v3
https://www.gnu.org/licenses/lgpl-3.0
tinylog-api 2.7.0
tinylog-api
Apache License 2.0
https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt
tinylog-impl 2.7.0
tinylog-impl
Apache License 2.0
https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt
versioncompare 1.4.1
versioncompare
Apache License 2.0
https://github.com/G00fY2/version-compare/blob/main/LICENSE
xodus-compress 2.0.1
xodus-compress
Apache License 2.0
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
xodus-environment 2.0.1
xodus-environment
Apache License 2.0
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
xodus-openAPI 2.0.1
xodus-openAPI
Apache License 2.0
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
xodus-utils 2.0.1
xodus-utils
Apache License 2.0
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
xodus-vfs 2.0.1
xodus-vfs
Apache License 2.0
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
jediterm
Apache License 2.0
https://github.com/JetBrains/jediterm/blob/master/LICENSE-APACHE-2.0.txt
https://github.com/JetBrains/jediterm/blob/master/LICENSE-APACHE-2.0.txt
mixpanel-java
Apache License 2.0
https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
json-20231013
Public Domain.
https://github.com/stleary/JSON-java/blob/master/LICENSE
jSerialComm
Apache License 2.0
https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0

View File

@@ -2,23 +2,39 @@ import org.gradle.internal.jvm.Jvm
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import org.gradle.nativeplatform.platform.internal.DefaultOperatingSystem
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils
import org.jetbrains.kotlin.org.apache.commons.io.filefilter.FileFilterUtils
import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
import java.io.FileNotFoundException
import java.nio.file.Files
import java.util.concurrent.Executors
import java.util.concurrent.Future
plugins {
java
idea
application
`maven-publish`
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlinx.serialization)
}
group = "app.termora"
version = "1.0.1"
version = "1.0.17"
val os: DefaultOperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
var arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
// macOS 签名信息
val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY
val macOSSign = os.isMacOsX && macOSSignUsername.isNotBlank()
&& System.getenv("TERMORA_MAC_SIGN").toBoolean()
// macOS 公证信息
val macOSNotaryKeychainProfile = System.getenv("TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE") ?: StringUtils.EMPTY
val macOSNotary = macOSSign && macOSNotaryKeychainProfile.isNotBlank()
&& System.getenv("TERMORA_MAC_NOTARY").toBoolean()
repositories {
mavenCentral()
@@ -27,84 +43,293 @@ repositories {
}
dependencies {
// 由于签名和公证macOS 不携带 natives
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(libs.slf4j.api)
implementation(libs.pty4j)
implementation(libs.slf4j.tinylog)
implementation(libs.tinylog.impl)
implementation(libs.commons.codec)
implementation(libs.commons.io)
implementation(libs.commons.lang3)
implementation(libs.commons.net)
implementation(libs.commons.text)
implementation(libs.commons.compress)
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.flatlaf)
implementation(libs.flatlaf.extras)
implementation(libs.flatlaf.swingx)
implementation(libs.kotlinx.serialization.json)
implementation(libs.swingx)
implementation(libs.jgoodies.forms)
implementation(libs.jna)
implementation(libs.jna.platform)
implementation(libs.versioncompare)
implementation(libs.oshi.core)
implementation(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") }
implementation(libs.jfa) { exclude(group = "*", module = "*") }
implementation(libs.jbr.api)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.sshd.core)
implementation(libs.commonmark)
implementation(libs.jgit)
implementation(libs.jgit.sshd)
implementation(libs.jnafilechooser)
implementation(libs.xodus.vfs)
implementation(libs.xodus.openAPI)
implementation(libs.xodus.environment)
implementation(libs.bip39)
implementation(libs.colorpicker)
// implementation(platform(libs.koin.bom))
// implementation(libs.koin.core)
api(libs.slf4j.api)
api(libs.pty4j)
api(libs.slf4j.tinylog)
api(libs.tinylog.impl)
api(libs.commons.codec)
api(libs.commons.io)
api(libs.commons.lang3)
api(libs.commons.csv)
api(libs.commons.net)
api(libs.commons.text)
api(libs.commons.compress)
api(libs.commons.vfs2) { exclude(group = "*", module = "*") }
api(libs.kotlinx.coroutines.swing)
api(libs.kotlinx.coroutines.core)
api(libs.flatlaf) {
artifact {
if (useNoNativesFlatLaf) {
classifier = "no-natives"
}
}
}
api(libs.flatlaf.extras) {
if (useNoNativesFlatLaf) {
exclude(group = "com.formdev", module = "flatlaf")
}
}
api(libs.flatlaf.swingx) {
if (useNoNativesFlatLaf) {
exclude(group = "com.formdev", module = "flatlaf")
}
}
api(libs.kotlinx.serialization.json)
api(libs.swingx)
api(libs.jgoodies.forms)
api(libs.jna)
api(libs.jna.platform)
api(libs.versioncompare)
api(libs.oshi.core)
api(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") }
api(libs.jfa) { exclude(group = "*", module = "*") }
api(libs.jbr.api)
api(libs.okhttp)
api(libs.okhttp.logging)
api(libs.sshd.core)
api(libs.commonmark)
api(libs.jgit)
api(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
api(libs.jgit.agent) { exclude(group = "*", module = "sshd-osgi") }
api(libs.eddsa)
api(libs.jnafilechooser)
api(libs.xodus.vfs)
api(libs.xodus.openAPI)
api(libs.xodus.environment)
api(libs.bip39)
api(libs.colorpicker)
api(libs.mixpanel)
api(libs.jSerialComm)
api(libs.ini4j)
api(libs.restart4j)
}
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) {
args.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
// macOS NSWindow
args.add("--add-opens java.desktop/java.awt=ALL-UNNAMED")
args.add("--add-opens java.desktop/sun.lwawt=ALL-UNNAMED")
args.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
args.add("--add-exports java.desktop/com.apple.eawt=ALL-UNNAMED")
args.add("-Dsun.java2d.metal=true")
args.add("-Dapple.awt.application.appearance=system")
}
args.add("-Dapp-version=${project.version}")
if (os.isLinux) {
args.add("-Dsun.java2d.opengl=true")
}
applicationDefaultJvmArgs = args
mainClass = "app.termora.MainKt"
}
publishing {
publications {
create<MavenPublication>("mavenJava") {
from(components["java"])
pom {
name = project.name
description = "Termora is a terminal emulator and SSH client for Windows, macOS and Linux"
url = "https://github.com/TermoraDev/termora"
licenses {
license {
name = "AGPL-3.0"
url = "https://opensource.org/license/agpl-v3"
}
}
developers {
developer {
name = "hstyi"
url = "https://github.com/hstyi"
}
}
scm {
url = "https://github.com/TermoraDev/termora"
}
}
}
}
}
tasks.test {
useJUnitPlatform()
}
tasks.register<Copy>("copy-dependencies") {
from(configurations.runtimeClasspath)
.into("${layout.buildDirectory.get()}/libs")
val dir = layout.buildDirectory.dir("libs")
from(configurations.runtimeClasspath).into(dir)
val jna = libs.jna.asProvider().get()
val pty4j = libs.pty4j.get()
val jSerialComm = libs.jSerialComm.get()
val restart4j = libs.restart4j.get()
// 对 JNA 和 PTY4J 的本地库提取
// 提取出来是为了单独签名,不然无法通过公证
if (os.isMacOsX && macOSSign) {
doLast {
val archName = if (arch.isArm) "aarch64" else "x86_64"
val dylib = dir.get().dir("dylib").asFile
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
val targetDir = File(dylib, jna.name)
FileUtils.forceMkdir(targetDir)
// @formatter:off
exec { commandLine("unzip","-j","-o", file.absolutePath, "com/sun/jna/darwin-${arch.name}/*", "-d", targetDir.absolutePath) }
// @formatter:on
// 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/darwin-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/sunos-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/openbsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/linux-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/freebsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/dragonflybsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/aix-*") }
} else if ("${pty4j.name}-${pty4j.version}" == file.nameWithoutExtension) {
val targetDir = FileUtils.getFile(dylib, pty4j.name, "darwin")
FileUtils.forceMkdir(targetDir)
// @formatter:off
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "resources/com/pty4j/native/darwin*", "-d", targetDir.absolutePath) }
// @formatter:on
// 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "resources/*") }
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
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/*") }
} else if ("${restart4j.name}-${restart4j.version}" == file.nameWithoutExtension) {
val targetDir = FileUtils.getFile(dylib, restart4j.name)
FileUtils.forceMkdir(targetDir)
// @formatter:off
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "darwin/${archName}/*", "-d", targetDir.absolutePath) }
// @formatter:on
// 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "win32/*") }
exec { commandLine("zip", "-d", file.absolutePath, "darwin/*") }
exec { commandLine("zip", "-d", file.absolutePath, "linux/*") }
// 设置可执行权限
for (e in FileUtils.listFiles(
targetDir,
FileFilterUtils.trueFileFilter(),
FileFilterUtils.falseFileFilter()
)) {
e.setExecutable(true)
}
}
}
// 对二进制签名
Files.walk(dylib.toPath()).use { paths ->
for (path in paths) {
if (Files.isRegularFile(path)) {
signMacOSLocalFile(path.toFile())
}
}
}
}
} else if (os.isLinux || os.isWindows) { // 缩减安装包
doLast {
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/darwin-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/sunos-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/openbsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/freebsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/dragonflybsd-*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/aix-*") }
if (os.isWindows) {
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/linux-*") }
if (arch.isArm) {
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-x86*") }
} else {
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-aarch64/*") }
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-x86/*") }
}
} else if (os.isLinux) {
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-*") }
}
} else if ("${pty4j.name}-${pty4j.version}" == file.nameWithoutExtension) {
exec { commandLine("zip", "-d", file.absolutePath, "resources/*darwin*") }
exec { commandLine("zip", "-d", file.absolutePath, "resources/*freebsd*") }
if (os.isWindows) {
exec { commandLine("zip", "-d", file.absolutePath, "resources/*linux*") }
if (arch.isArm) {
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86/*") }
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86-64*") }
} else {
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86/*") }
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/aarch64/*") }
}
} else if (os.isLinux) {
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win*") }
}
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
exec { commandLine("zip", "-d", file.absolutePath, "Android/*") }
exec { commandLine("zip", "-d", file.absolutePath, "FreeBSD/*") }
exec { commandLine("zip", "-d", file.absolutePath, "OpenBSD/*") }
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
if (os.isWindows) {
exec { commandLine("zip", "-d", file.absolutePath, "Linux/*") }
} else if (os.isLinux) {
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
}
} else if ("${restart4j.name}-${restart4j.version}" == file.nameWithoutExtension) {
exec { commandLine("zip", "-d", file.absolutePath, "darwin/*") }
if (os.isWindows) {
exec { commandLine("zip", "-d", file.absolutePath, "linux/*") }
if (arch.isArm) {
exec { commandLine("zip", "-d", file.absolutePath, "win32/x86_64/*") }
} else {
exec { commandLine("zip", "-d", file.absolutePath, "win32/aarch64/*") }
}
} else if (os.isLinux) {
exec { commandLine("zip", "-d", file.absolutePath, "win32/*") }
if (arch.isArm) {
exec { commandLine("zip", "-d", file.absolutePath, "linux/x86_64/*") }
} else {
exec { commandLine("zip", "-d", file.absolutePath, "linux/aarch64/*") }
}
}
}
}
}
}
}
tasks.register<Exec>("jlink") {
@@ -136,25 +361,34 @@ tasks.register<Exec>("jlink") {
}
tasks.register<Exec>("jpackage") {
val buildDir = layout.buildDirectory.get()
val options = mutableListOf(
"--add-exports java.base/sun.nio.ch=ALL-UNNAMED",
"-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")
// NSWindow
options.add("--add-opens java.desktop/java.awt=ALL-UNNAMED")
options.add("--add-opens java.desktop/sun.lwawt=ALL-UNNAMED")
options.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
options.add("-Dapple.awt.application.appearance=system")
options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
} else {
options.add("-Dsun.java2d.opengl=true")
options.add("--add-exports java.desktop/com.apple.eawt=ALL-UNNAMED")
}
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}"))
@@ -164,6 +398,19 @@ tasks.register<Exec>("jpackage") {
arguments.addAll(listOf("--temp", "$buildDir/jpackage"))
arguments.addAll(listOf("--dest", "$buildDir/distributions"))
arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE)))
arguments.addAll(listOf("--vendor", "TermoraDev"))
arguments.addAll(listOf("--copyright", "TermoraDev"))
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) {
@@ -181,6 +428,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) {
@@ -193,26 +444,29 @@ tasks.register<Exec>("jpackage") {
throw UnsupportedOperationException()
}
if (os.isMacOsX && macOSSign) {
arguments.add("--mac-sign")
arguments.add("--mac-signing-key-user-name")
arguments.add(macOSSignUsername)
}
commandLine(arguments)
}
tasks.register("dist") {
doLast {
val vendor = Jvm.current().vendor ?: StringUtils.EMPTY
@Suppress("UnstableApiUsage")
if (!JvmVendorSpec.JETBRAINS.matches(vendor)) {
throw GradleException("JVM: $vendor is not supported")
}
val distributionDir = layout.buildDirectory.dir("distributions").get()
val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath
// 清空目录
exec { commandLine(gradlew, "clean") }
// 打包并复制依赖
exec { commandLine(gradlew, "jar", "copy-dependencies") }
exec {
commandLine(gradlew, "jar", "copy-dependencies")
environment("ENABLE_BUILD" to true)
}
// 检查依赖的开源协议
exec { commandLine(gradlew, "check-license") }
@@ -223,73 +477,293 @@ tasks.register("dist") {
// 打包
exec { commandLine(gradlew, "jpackage") }
// pack
exec {
if (os.isWindows) { // zip
commandLine(
"tar", "-vacf",
distributionDir.file("${project.name}-${project.version}-windows-${arch.name}.zip").asFile.absolutePath,
project.name.uppercaseFirstChar()
)
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
} else if (os.isLinux) { // tar.gz
commandLine(
"tar", "-czvf",
distributionDir.file("${project.name}-${project.version}-linux-${arch.name}.tar.gz").asFile.absolutePath,
project.name.uppercaseFirstChar()
)
workingDir = distributionDir.asFile
} else if (os.isMacOsX) { // rename
commandLine(
"mv",
distributionDir.file("${project.name.uppercaseFirstChar()}-${project.version}.dmg").asFile.absolutePath,
distributionDir.file("${project.name}-${project.version}-osx-${arch.name}.dmg").asFile.absolutePath,
)
} else {
throw GradleException("${os.name} is not supported")
}
}
// 根据不同的系统构建不同的二进制包
pack()
}
}
tasks.register("check-license") {
doLast {
val thirdParty = mutableMapOf<String, String>()
val iterator = File(projectDir, "THIRDPARTY").readLines().iterator()
val thirdPartyNames = mutableSetOf<String>()
while (iterator.hasNext()) {
val nameWithVersion = iterator.next()
if (nameWithVersion.isBlank()) {
val name = iterator.next()
if (name.isBlank()) {
continue
}
// ignore license name
iterator.next()
// ignore license url
iterator.next()
val license = iterator.next()
thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license
thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first())
thirdPartyNames.add(name)
}
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")
}
for (dependency in configurations.runtimeClasspath.get().allDependencies) {
if (!thirdPartyNames.contains(dependency.name)) {
throw GradleException("${dependency.name} No license found")
}
}
}
}
/**
* 构建包
*/
fun pack() {
val osName = if (os.isMacOsX) "osx" else if (os.isWindows) "windows" else "linux"
val distributionDir = layout.buildDirectory.dir("distributions").get()
val finalFilenameWithoutExtension = "${project.name}-${project.version}-${osName}-${arch.name}"
val projectName = project.name.uppercaseFirstChar()
if (os.isWindows) {
packOnWindows(distributionDir, finalFilenameWithoutExtension, projectName)
} else if (os.isLinux) {
packOnLinux(distributionDir, finalFilenameWithoutExtension, projectName)
} else if (os.isMacOsX) {
packOnMac(distributionDir, finalFilenameWithoutExtension, projectName)
} else {
throw GradleException("${os.name} is not supported")
}
}
/**
* 创建 zip、7z、msi
*/
fun packOnWindows(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
// zip
exec {
commandLine(
"tar", "-vacf",
distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile.absolutePath,
projectName
)
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
}
// exe
exec {
commandLine(
"iscc",
"/DMyAppId=${projectName}",
"/DMyAppName=${projectName}",
"/DMyAppVersion=${project.version}",
"/DMyOutputDir=${distributionDir.asFile.absolutePath}",
"/DMySetupIconFile=${FileUtils.getFile(projectDir, "src", "main", "resources", "icons", "termora.ico")}",
"/DMySourceDir=${layout.buildDirectory.dir("jpackage/images/win-msi.image/${projectName}").get().asFile}",
"/F${finalFilenameWithoutExtension}",
FileUtils.getFile(projectDir, "src", "main", "resources", "termora.iss")
)
}
// msi
exec {
commandLine(
"cmd", "/c", "move",
"${projectName}-${project.version}.msi",
"${finalFilenameWithoutExtension}.msi"
)
workingDir = distributionDir.asFile
}
}
/**
* 对于 macOS 先对 jpackage 构建的 dmg 重命名 -> 签名 -> 公证,另外还会创建一个 zip 包
*/
fun packOnMac(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
val dmgFile = distributionDir.file("${finalFilenameWithoutExtension}.dmg").asFile
val zipFile = distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile
// rename
// @formatter:off
exec { commandLine("mv", distributionDir.file("${projectName}-${project.version}.dmg").asFile.absolutePath, dmgFile.absolutePath,) }
// @formatter:on
// sign dmg
if (macOSSign) signMacOSLocalFile(dmgFile)
// 找到 .app
val imageFile = layout.buildDirectory.dir("jpackage/images/").get().asFile
val appFile = imageFile.listFiles()?.firstOrNull()?.listFiles()?.firstOrNull()
?: throw FileNotFoundException("${projectName}.app")
// zip
// @formatter:off
exec { commandLine("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", appFile.absolutePath, zipFile.absolutePath) }
// @formatter:on
// sign zip
if (macOSSign) signMacOSLocalFile(zipFile)
// 公证
if (macOSNotary) {
val pool = Executors.newCachedThreadPool()
val jobs = mutableListOf<Future<*>>()
// zip
pool.submit {
// 对 zip 公证
notaryMacOSLocalFile(zipFile)
// 对 .app 盖章
stapleMacOSLocalFile(appFile)
// 删除旧的 zip ,旧的 zip 仅仅是为了公证
FileUtils.deleteQuietly(zipFile)
// 再对盖完章的 app 打成 zip 包
// @formatter:off
exec { commandLine("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", appFile.absolutePath, zipFile.absolutePath) }
// @formatter:on
// 再对 zip 签名
signMacOSLocalFile(zipFile)
}.apply { jobs.add(this) }
// dmg
pool.submit {
// 公证
notaryMacOSLocalFile(dmgFile)
// 盖章
stapleMacOSLocalFile(dmgFile)
}.apply { jobs.add(this) }
// join ...
jobs.forEach { it.get() }
// shutdown
pool.shutdown()
}
}
/**
* 创建 tar.gz 和 AppImage
*/
fun packOnLinux(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
// tar.gz
exec {
commandLine(
"tar", "-czvf",
distributionDir.file("${finalFilenameWithoutExtension}.tar.gz").asFile.absolutePath,
projectName
)
workingDir = distributionDir.asFile
}
// AppImage
// Download AppImageKit
val appimagetool = FileUtils.getFile(projectDir, ".gradle", "appimagetool")
if (!appimagetool.exists()) {
exec {
commandLine(
"wget",
"-O", appimagetool.absolutePath,
"https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-${if (arch.isArm) "aarch64" else "x86_64"}.AppImage"
)
workingDir = distributionDir.asFile
}
// AppImageKit chmod
exec { commandLine("chmod", "+x", appimagetool.absolutePath) }
}
// Desktop file
val termoraName = project.name.uppercaseFirstChar()
val desktopFile = distributionDir.file(termoraName + File.separator + termoraName + ".desktop").asFile
desktopFile.writeText(
"""[Desktop Entry]
Type=Application
Name=${termoraName}
Comment=Terminal emulator and SSH client
Icon=/lib/${termoraName}
Categories=Development;
Terminal=false
""".trimIndent()
)
// AppRun file
val appRun = File(desktopFile.parentFile, "AppRun")
val sb = StringBuilder()
sb.append("#!/bin/sh").appendLine()
sb.append("SELF=$(readlink -f \"$0\")").appendLine()
sb.append("HERE=\${SELF%/*}").appendLine()
sb.append("export LinuxAppImage=true").appendLine()
sb.append("exec \"\${HERE}/bin/${termoraName}\" \"$@\"")
appRun.writeText(sb.toString())
appRun.setExecutable(true)
// AppImage
exec {
commandLine(appimagetool.absolutePath, termoraName, "${finalFilenameWithoutExtension}.AppImage")
workingDir = distributionDir.asFile
}
}
/**
* macOS 对本地文件进行签名
*/
fun signMacOSLocalFile(file: File) {
if (os.isMacOsX && macOSSign) {
if (file.exists() && file.isFile) {
exec {
commandLine(
"/usr/bin/codesign",
"-s", macOSSignUsername,
"--timestamp", "--force",
"-vvvv", "--options", "runtime",
file.absolutePath,
)
}
}
}
}
/**
* macOS 对本地文件进行公证
*/
fun notaryMacOSLocalFile(file: File) {
if (os.isMacOsX && macOSNotary) {
if (file.exists()) {
exec {
commandLine(
"/usr/bin/xcrun", "notarytool",
"submit", file,
"--keychain-profile", macOSNotaryKeychainProfile,
"--wait",
)
}
}
}
}
/**
* 盖章
*/
fun stapleMacOSLocalFile(file: File) {
if (os.isMacOsX && macOSNotary) {
if (file.exists()) {
exec {
commandLine(
"/usr/bin/xcrun",
"stapler", "staple", file,
)
}
}
}
}
kotlin {
jvmToolchain {
languageVersion = JavaLanguageVersion.of(21)
@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

@@ -1,45 +1,46 @@
[versions]
kotlin = "2.1.0"
slf4j = "2.0.16"
pty4j = "0.13.2"
kotlin = "2.1.21"
slf4j = "2.0.17"
pty4j = "0.13.6"
tinylog = "2.7.0"
kotlinx-coroutines = "1.10.1"
flatlaf = "3.5.4"
trove4j = "1.0.20200330"
kotlinx-serialization-json = "1.7.3"
commons-codec = "1.17.1"
kotlinx-coroutines = "1.10.2"
flatlaf = "3.6"
kotlinx-serialization-json = "1.8.1"
commons-codec = "1.18.0"
commons-lang3 = "3.17.0"
commons-csv = "1.14.0"
commons-net = "3.11.1"
commons-text = "1.12.0"
commons-text = "1.13.1"
commons-compress = "1.27.1"
koin-bom = "4.0.0"
commons-vfs2="2.10.0"
swingx = "1.6.5-1"
jgoodies-forms = "1.9.0"
jfa = "1.2.0"
oshi = "6.6.5"
oshi = "6.8.1"
versioncompare = "1.4.1"
jna = "5.16.0"
jna = "5.17.0"
jSystemThemeDetector = "3.9.1"
commons-io = "2.18.0"
commons-io = "2.19.0"
jbr-api = "17.1.10.1"
leveldb = "0.12"
guava = "33.3.1-jre"
credential-secure-storage = "1.0.3"
hutool = "5.8.34"
jsch = "0.2.21"
hutool = "5.8.37"
jsch = "0.2.26"
okhttp = "4.12.0"
bcprov = "1.79"
sshj = "0.39.0"
sshd-core = "2.14.0"
jgit = "7.1.0.202411261347-r"
sshd-core = "2.15.0"
jgit = "7.2.0.202503040940-r"
commonmark = "0.24.0"
jnafilechooser = "1.1.2"
xodus = "2.0.1"
bip39 = "1.0.8"
bip39 = "1.0.9"
colorpicker = "2.0.1"
rhino = "1.7.15"
rhino = "1.8.0"
delight-rhino-sandbox = "0.0.17"
testcontainers = "1.20.4"
testcontainers = "1.21.1"
mixpanel = "1.5.3"
jSerialComm = "2.11.0"
ini4j = "0.5.5-2"
restart4j = "0.0.1"
eddsa = "0.3.0"
[libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
@@ -51,16 +52,16 @@ tinylog-impl = { group = "org.tinylog", name = "tinylog-impl", version.ref = "ti
commons-codec = { group = "commons-codec", name = "commons-codec", version.ref = "commons-codec" }
commons-net = { group = "commons-net", name = "commons-net", version.ref = "commons-net" }
commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version.ref = "commons-lang3" }
commons-csv = { group = "org.apache.commons", name = "commons-csv", version.ref = "commons-csv" }
commons-text = { group = "org.apache.commons", name = "commons-text", version.ref = "commons-text" }
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" }
commons-vfs2 = { group = "org.apache.commons", name = "commons-vfs2", version.ref = "commons-vfs2" }
pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" }
ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" }
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" }
trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" }
testcontainers = { module = "org.testcontainers:testcontainers" }
koin-core = { module = "io.insert-koin:koin-core" }
swingx = { module = "org.swinglabs.swingx:swingx-all", version.ref = "swingx" }
jgoodies-forms = { module = "com.jgoodies:jgoodies-forms", version.ref = "jgoodies-forms" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
@@ -70,31 +71,30 @@ versioncompare = { module = "io.github.g00fy2:versioncompare", version.ref = "ve
jfa = { module = "de.jangassen:jfa", version.ref = "jfa" }
oshi-core = { module = "com.github.oshi:oshi-core", version.ref = "oshi" }
commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" }
restart4j = { module = "com.github.hstyi:restart4j", version.ref = "restart4j" }
jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" }
flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" }
leveldb = { module = "org.iq80.leveldb:leveldb", version.ref = "leveldb" }
guava = { module = "com.google.guava:guava", version.ref = "guava" }
hutool = { module = "cn.hutool:hutool-all", version.ref = "hutool" }
credential-secure-storage = { module = "com.microsoft:credential-secure-storage", version.ref = "credential-secure-storage" }
jsch = { module = "com.github.mwiede:jsch", version.ref = "jsch" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" }
sshj = { module = "com.hierynomus:sshj", version.ref = "sshj" }
sshd-core = { module = "org.apache.sshd:sshd-core", version.ref = "sshd-core" }
jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" }
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
jgit-sshd = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache", version.ref = "jgit" }
jgit-agent = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache.agent", version.ref = "jgit" }
xodus-openAPI = { module = "org.jetbrains.xodus:xodus-openAPI", version.ref = "xodus" }
xodus-entity-store = { module = "org.jetbrains.xodus:xodus-entity-store", version.ref = "xodus" }
xodus-environment = { module = "org.jetbrains.xodus:xodus-environment", version.ref = "xodus" }
xodus-crypto = { module = "org.jetbrains.xodus:xodus-crypto", version.ref = "xodus" }
xodus-vfs = { module = "org.jetbrains.xodus:xodus-vfs", version.ref = "xodus" }
jnafilechooser = { module = "com.github.steos.jnafilechooser:jnafilechooser-api", version.ref = "jnafilechooser" }
bip39 = { module = "cash.z.ecc.android:kotlin-bip39-jvm", version.ref = "bip39" }
bip39 = { module = "cash.z.ecc.android:kotlin-bip39", version.ref = "bip39" }
rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" }
eddsa = { module = "net.i2p.crypto:eddsa", version.ref = "eddsa" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View File

@@ -1,5 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "termora"

View File

@@ -0,0 +1,70 @@
package app.termora;
import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
import org.apache.sshd.common.session.SessionContext;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.util.*;
@Deprecated
public class CombinedKeyIdentityProvider implements KeyIdentityProvider {
private final List<KeyIdentityProvider> providers = new ArrayList<>();
@Override
public Iterable<KeyPair> loadKeys(SessionContext context) {
return () -> new Iterator<>() {
private final Iterator<KeyIdentityProvider> factories = providers
.iterator();
private Iterator<KeyPair> current;
private Boolean hasElement;
@Override
public boolean hasNext() {
if (hasElement != null) {
return hasElement;
}
while (current == null || !current.hasNext()) {
if (factories.hasNext()) {
try {
current = factories.next().loadKeys(context)
.iterator();
} catch (IOException | GeneralSecurityException e) {
throw new RuntimeException(e);
}
} else {
current = null;
hasElement = Boolean.FALSE;
return false;
}
}
hasElement = Boolean.TRUE;
return true;
}
@Override
public KeyPair next() {
if ((hasElement == null && !hasNext()) || !hasElement) {
throw new NoSuchElementException();
}
hasElement = null;
KeyPair result;
try {
result = current.next();
} catch (NoSuchElementException e) {
result = null;
}
return result;
}
};
}
public void addKeyKeyIdentityProvider(KeyIdentityProvider provider) {
providers.add(Objects.requireNonNull(provider));
}
}

View File

@@ -0,0 +1,140 @@
package app.termora;
import com.formdev.flatlaf.ui.FlatTabbedPaneUI;
import com.formdev.flatlaf.ui.FlatUIUtils;
import javax.swing.*;
import java.awt.*;
import java.awt.geom.Path2D;
import java.awt.geom.Rectangle2D;
import static com.formdev.flatlaf.FlatClientProperties.*;
import static com.formdev.flatlaf.util.UIScale.scale;
/**
* 如果要升级 FlatLaf 需要检查是否兼容
*/
@Deprecated
public class MyFlatTabbedPaneUI extends FlatTabbedPaneUI {
@Override
protected void paintContentBorder(Graphics g, int tabPlacement, int selectedIndex) {
if (tabPane.getTabCount() <= 0 ||
contentSeparatorHeight == 0 ||
!clientPropertyBoolean(tabPane, TABBED_PANE_SHOW_CONTENT_SEPARATOR, showContentSeparator))
return;
Insets insets = tabPane.getInsets();
Insets tabAreaInsets = getTabAreaInsets(tabPlacement);
int x = insets.left;
int y = insets.top;
int w = tabPane.getWidth() - insets.right - insets.left;
int h = tabPane.getHeight() - insets.top - insets.bottom;
// remove tabs from bounds
switch (tabPlacement) {
case BOTTOM:
h -= calculateTabAreaHeight(tabPlacement, runCount, maxTabHeight);
h += tabAreaInsets.top;
break;
case LEFT:
x += calculateTabAreaWidth(tabPlacement, runCount, maxTabWidth);
x -= tabAreaInsets.right;
w -= (x - insets.left);
break;
case RIGHT:
w -= calculateTabAreaWidth(tabPlacement, runCount, maxTabWidth);
w += tabAreaInsets.left;
break;
case TOP:
default:
y += calculateTabAreaHeight(tabPlacement, runCount, maxTabHeight);
y -= tabAreaInsets.bottom;
h -= (y - insets.top);
break;
}
// compute insets for separator or full border
boolean hasFullBorder = clientPropertyBoolean(tabPane, TABBED_PANE_HAS_FULL_BORDER, this.hasFullBorder);
int sh = scale(contentSeparatorHeight * 100); // multiply by 100 because rotateInsets() does not use floats
Insets ci = new Insets(0, 0, 0, 0);
rotateInsets(hasFullBorder ? new Insets(sh, sh, sh, sh) : new Insets(sh, 0, 0, 0), ci, tabPlacement);
// create path for content separator or full border
Path2D path = new Path2D.Float(Path2D.WIND_EVEN_ODD);
path.append(new Rectangle2D.Float(x, y, w, h), false);
path.append(new Rectangle2D.Float(x + (ci.left / 100f), y + (ci.top / 100f),
w - (ci.left / 100f) - (ci.right / 100f), h - (ci.top / 100f) - (ci.bottom / 100f)), false);
// add gap for selected tab to path
if (getTabType() == TAB_TYPE_CARD && selectedIndex >= 0) {
float csh = scale((float) contentSeparatorHeight);
Rectangle tabRect = getTabBounds(tabPane, selectedIndex);
boolean componentHasFullBorder = false;
if (tabPane.getComponentAt(selectedIndex) instanceof JComponent c) {
componentHasFullBorder = c.getClientProperty(TABBED_PANE_HAS_FULL_BORDER) == Boolean.TRUE;
}
Rectangle2D.Float innerTabRect = new Rectangle2D.Float(tabRect.x + csh, tabRect.y + csh,
componentHasFullBorder ? 0 : tabRect.width - (csh * 2), tabRect.height - (csh * 2));
// Ensure that the separator outside the tabViewport is present (doesn't get cutoff by the active tab)
// If left unsolved the active tab is "visible" in the separator (the gap) even when outside the viewport
if (tabViewport != null)
Rectangle2D.intersect(tabViewport.getBounds(), innerTabRect, innerTabRect);
Rectangle2D.Float gap = null;
if (isHorizontalTabPlacement(tabPlacement)) {
if (innerTabRect.width > 0) {
float y2 = (tabPlacement == TOP) ? y : y + h - csh;
gap = new Rectangle2D.Float(innerTabRect.x, y2, innerTabRect.width, csh);
}
} else {
if (innerTabRect.height > 0) {
float x2 = (tabPlacement == LEFT) ? x : x + w - csh;
gap = new Rectangle2D.Float(x2, innerTabRect.y, csh, innerTabRect.height);
}
}
if (gap != null) {
path.append(gap, false);
// fill gap in case that the tab is colored (e.g. focused or hover)
Color background = getTabBackground(tabPlacement, selectedIndex, true);
g.setColor(FlatUIUtils.deriveColor(background, tabPane.getBackground()));
((Graphics2D) g).fill(gap);
}
}
// paint content separator or full border
g.setColor(contentAreaColor);
((Graphics2D) g).fill(path);
// repaint selection in scroll-tab-layout because it may be painted before
// the content border was painted (from BasicTabbedPaneUI$ScrollableTabPanel)
if (isScrollTabLayout() && selectedIndex >= 0 && tabViewport != null) {
Rectangle tabRect = getTabBounds(tabPane, selectedIndex);
// clip to "scrolling sides" of viewport
// (left and right if horizontal, top and bottom if vertical)
Shape oldClip = g.getClip();
Rectangle vr = tabViewport.getBounds();
if (isHorizontalTabPlacement(tabPlacement))
g.clipRect(vr.x, 0, vr.width, tabPane.getHeight());
else
g.clipRect(0, vr.y, tabPane.getWidth(), vr.height);
paintTabSelection(g, tabPlacement, selectedIndex, tabRect.x, tabRect.y, tabRect.width, tabRect.height);
g.setClip(oldClip);
}
}
private boolean isScrollTabLayout() {
return tabPane.getTabLayoutPolicy() == JTabbedPane.SCROLL_TAB_LAYOUT;
}
}

View File

@@ -0,0 +1,38 @@
package app.termora;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.WString;
import com.sun.jna.win32.StdCallLibrary;
interface MyKernel32 extends StdCallLibrary {
MyKernel32 INSTANCE = Native.load("Kernel32", MyKernel32.class);
WString INVARIANT_LOCALE = new WString("");
int CompareStringEx(WString lpLocaleName,
int dwCmpFlags,
WString lpString1,
int cchCount1,
WString lpString2,
int cchCount2,
Pointer lpVersionInformation,
Pointer lpReserved,
int lParam);
default int CompareStringEx(int dwCmpFlags,
String str1,
String str2) {
return MyKernel32.INSTANCE
.CompareStringEx(
INVARIANT_LOCALE,
dwCmpFlags,
new WString(str1),
str1.length(),
new WString(str2),
str2.length(),
Pointer.NULL,
Pointer.NULL,
0);
}
}

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,25 +2,10 @@ package app.termora
object Actions {
/**
* 打开设置
*/
const val SETTING = "SettingAction"
/**
* 将命令发送到多个会话
*/
const val MULTIPLE = "MultipleAction"
/**
* 查找
*/
const val FIND_EVERYWHERE = "FindEverywhereAction"
/**
* 关键词高亮
*/
const val KEYWORD_HIGHLIGHT_EVERYWHERE = "KeywordHighlightAction"
const val KEYWORD_HIGHLIGHT = "KeywordHighlightAction"
/**
* Key manager
@@ -38,13 +23,15 @@ object Actions {
*/
const val MACRO = "MacroAction"
/**
* 添加主机对话框
*/
const val ADD_HOST = "AddHostAction"
/**
* 打开一个主机
* 终端日志记录
*/
const val OPEN_HOST = "OpenHostAction"
const val TERMINAL_LOGGER = "TerminalLogAction"
/**
* 打开 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

@@ -3,7 +3,6 @@ package app.termora
import com.formdev.flatlaf.util.SystemInfo
import com.jthemedetecor.util.OsInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
@@ -15,15 +14,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 +61,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
@@ -115,32 +123,22 @@ object Application {
}
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) {
swingCoroutineScope.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) }
swingCoroutineScope.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()
@@ -150,6 +148,16 @@ object Application {
ProcessBuilder("xdg-open", uri.toString()).start()
}
}
fun browseInFolder(file: File) {
if (SystemInfo.isWindows) {
ProcessBuilder("explorer", "/select," + file.absolutePath).start()
} else if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-R", file.absolutePath).start()
} else if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
Desktop.getDesktop().browseFileDirectory(file)
}
}
}
fun formatBytes(bytes: Long): String {
@@ -159,7 +167,7 @@ fun formatBytes(bytes: Long): String {
val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt()
val value = bytes / 1024.0.pow(exp.toDouble())
return String.format("%.2f %s", value, units[exp])
return String.format("%.2f%s", value, units[exp])
}
fun formatSeconds(seconds: Long): String {
@@ -168,11 +176,33 @@ fun formatSeconds(seconds: Long): String {
val minutes = (seconds % 3600) / 60
val remainingSeconds = seconds % 60
return when {
days > 0 -> "${days}${hours}小时${minutes}${remainingSeconds}"
hours > 0 -> "${hours}小时${minutes}${remainingSeconds}"
minutes > 0 -> "${minutes}${remainingSeconds}"
else -> "${remainingSeconds}"
days > 0 -> I18n.getString(
"termora.transport.jobs.table.estimated-time-days-format",
days,
hours,
minutes,
remainingSeconds
)
hours > 0 -> I18n.getString(
"termora.transport.jobs.table.estimated-time-hours-format",
hours,
minutes,
remainingSeconds
)
minutes > 0 -> I18n.getString(
"termora.transport.jobs.table.estimated-time-minutes-format",
minutes,
remainingSeconds
)
else -> I18n.getString(
"termora.transport.jobs.table.estimated-time-seconds-format",
remainingSeconds
)
}
}

View File

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

View File

@@ -0,0 +1,91 @@
package app.termora
import com.formdev.flatlaf.util.SystemInfo
import com.pty4j.util.PtyUtil
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.tinylog.configuration.Configuration
import java.io.File
import kotlin.system.exitProcess
class ApplicationInitializr {
fun run() {
// 由于 macOS 签名和公证问题,依赖二进制依赖会单独在一个文件夹
if (SystemUtils.IS_OS_MAC_OSX) {
setupNativeLibraries()
}
if (SystemUtils.IS_OS_MAC_OSX) {
System.setProperty("apple.awt.application.name", Application.getName())
}
// 设置 tinylog
setupTinylog()
// 检查是否单例
checkSingleton()
// 启动
ApplicationRunner().run()
}
private fun setupNativeLibraries() {
if (!SystemUtils.IS_OS_MAC_OSX) {
return
}
val appPath = Application.getAppPath()
if (StringUtils.isBlank(appPath)) {
return
}
val contents = File(appPath).parentFile?.parentFile ?: return
val dylib = FileUtils.getFile(contents, "app", "dylib")
if (!dylib.exists()) {
return
}
val jna = FileUtils.getFile(dylib, "jna")
if (jna.exists()) {
System.setProperty("jna.boot.library.path", jna.absolutePath)
}
val pty4j = FileUtils.getFile(dylib, "pty4j")
if (pty4j.exists()) {
System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath)
}
val jSerialComm = FileUtils.getFile(dylib, "jSerialComm")
if (jSerialComm.exists()) {
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
}
val restart4j = FileUtils.getFile(dylib, "restart4j", "restarter")
if (restart4j.exists()) {
System.setProperty("restarter.path", restart4j.absolutePath)
}
}
/**
* Windows 情况覆盖
*/
private fun setupTinylog() {
if (SystemInfo.isWindows) {
val dir = File(Application.getBaseDataDir(), "logs")
FileUtils.forceMkdir(dir)
Configuration.set("writer_file.latest", "${dir.absolutePath}/${Application.getName().lowercase()}.log")
Configuration.set("writer_file.file", "${dir.absolutePath}/{date:yyyy}-{date:MM}-{date:dd}.log")
}
}
private fun checkSingleton() {
if (ApplicationSingleton.getInstance().isSingleton()) return
System.err.println("Program is already running")
exitProcess(1)
}
}

View File

@@ -1,70 +1,117 @@
package app.termora
import app.termora.db.Database
import app.termora.actions.ActionManager
import app.termora.keymap.KeymapManager
import app.termora.vfs2.sftp.MySftpFileProvider
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatDesktop
import com.formdev.flatlaf.extras.FlatInspector
import com.formdev.flatlaf.ui.FlatTableCellBorder
import com.formdev.flatlaf.util.SystemInfo
import com.jthemedetecor.OsThemeDetector
import com.sun.jna.platform.WindowUtils
import com.sun.jna.platform.win32.User32
import com.sun.jna.ptr.IntByReference
import com.mixpanel.mixpanelapi.ClientDelivery
import com.mixpanel.mixpanelapi.MessageBuilder
import com.mixpanel.mixpanelapi.MixpanelAPI
import kotlinx.coroutines.Dispatchers
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.apache.commons.vfs2.VFS
import org.apache.commons.vfs2.cache.WeakRefFilesCache
import org.apache.commons.vfs2.impl.DefaultFileSystemManager
import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider
import org.json.JSONObject
import org.slf4j.LoggerFactory
import org.tinylog.configuration.Configuration
import java.io.File
import java.io.RandomAccessFile
import java.nio.channels.FileChannel
import java.nio.channels.FileLock
import java.nio.file.StandardOpenOption
import java.awt.MenuItem
import java.awt.PopupMenu
import java.awt.SystemTray
import java.awt.TrayIcon
import java.awt.desktop.AppReopenedEvent
import java.awt.desktop.AppReopenedListener
import java.awt.desktop.SystemEventListener
import java.awt.event.ActionEvent
import java.awt.event.WindowEvent
import java.util.*
import java.util.concurrent.CountDownLatch
import javax.imageio.ImageIO
import javax.swing.*
import javax.swing.WindowConstants.DISPOSE_ON_CLOSE
import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis
class ApplicationRunner {
private lateinit var singletonLock: FileLock
private val log by lazy {
if (!::singletonLock.isInitialized) {
throw UnsupportedOperationException("Singleton lock is not initialized")
}
LoggerFactory.getLogger("Main")
}
private val log by lazy { LoggerFactory.getLogger(ApplicationRunner::class.java) }
fun run() {
// 覆盖 tinylog 配置
setupTinylog()
measureTimeMillis {
// 是否单例
checkSingleton()
// 打印系统信息
val printSystemInfo = measureTimeMillis { printSystemInfo() }
// 打印系统信息
printSystemInfo()
SwingUtilities.invokeAndWait {
// 打开数据库
openDatabase()
val openDatabase = measureTimeMillis { openDatabase() }
// 加载设置
loadSettings()
val loadSettings = measureTimeMillis { loadSettings() }
// 统计
val enableAnalytics = measureTimeMillis { enableAnalytics() }
// init ActionManager、KeymapManager、VFS
swingCoroutineScope.launch(Dispatchers.IO) {
ActionManager.getInstance()
KeymapManager.getInstance()
val fileSystemManager = DefaultFileSystemManager()
fileSystemManager.addProvider("sftp", MySftpFileProvider())
fileSystemManager.addProvider("file", DefaultLocalFileProvider())
fileSystemManager.filesCache = WeakRefFilesCache()
fileSystemManager.init()
VFS.setManager(fileSystemManager)
// async init
BackgroundManager.getInstance().getBackgroundImage()
}
// 设置 LAF
setupLaf()
val setupLaf = measureTimeMillis { setupLaf() }
// 解密数据
openDoor()
val openDoor = measureTimeMillis { openDoor() }
// clear temporary
clearTemporary()
// 启动主窗口
startMainFrame()
val startMainFrame = measureTimeMillis { startMainFrame() }
if (log.isDebugEnabled) {
log.debug("printSystemInfo: {}ms", printSystemInfo)
log.debug("openDatabase: {}ms", openDatabase)
log.debug("loadSettings: {}ms", loadSettings)
log.debug("enableAnalytics: {}ms", enableAnalytics)
log.debug("setupLaf: {}ms", setupLaf)
log.debug("openDoor: {}ms", openDoor)
log.debug("startMainFrame: {}ms", startMainFrame)
}
}.let {
if (log.isDebugEnabled) {
log.debug("run: {}ms", it)
}
}
}
private fun clearTemporary() {
swingCoroutineScope.launch(Dispatchers.IO) {
// 启动时清除
FileUtils.cleanDirectory(Application.getTemporaryDir())
}
}
private fun openDoor() {
if (Doorman.instance.isWorking()) {
if (Doorman.getInstance().isWorking()) {
if (!DoormanDialog(null).open()) {
exitProcess(1)
}
@@ -72,17 +119,74 @@ 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
if (SystemInfo.isMacOS) {
SwingUtilities.invokeLater {
try {
// 设置 Dock
setupMacOSDock()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
// Command + Q
FlatDesktop.setQuitHandler { quitHandler() }
}
} else if (SystemInfo.isWindows) {
// 设置托盘
SwingUtilities.invokeLater { setupSystemTray() }
}
}
private fun setupSystemTray() {
if (!SystemInfo.isWindows || !SystemTray.isSupported()) return
val tray = SystemTray.getSystemTray()
val image = ImageIO.read(TermoraFrame::class.java.getResourceAsStream("/icons/termora_16x16.png"))
val trayIcon = TrayIcon(image)
val popupMenu = PopupMenu()
trayIcon.popupMenu = popupMenu
trayIcon.toolTip = Application.getName()
// PopupMenu 不支持中文
val exitMenu = MenuItem("Exit")
exitMenu.addActionListener { SwingUtilities.invokeLater { quitHandler() } }
popupMenu.add(exitMenu)
// double click
trayIcon.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
TermoraFrameManager.getInstance().tick()
}
})
tray.add(trayIcon)
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
override fun dispose() {
tray.remove(trayIcon)
}
})
}
private fun quitHandler() {
val windows = TermoraFrameManager.getInstance().getWindows()
for (frame in windows) {
frame.dispatchEvent(WindowEvent(frame, WindowEvent.WINDOW_CLOSED))
}
Disposer.dispose(TermoraFrameManager.getInstance())
}
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)
@@ -93,7 +197,7 @@ class ApplicationRunner {
private fun setupLaf() {
System.setProperty(FlatSystemProperties.USE_WINDOW_DECORATIONS, "${SystemInfo.isLinux}")
System.setProperty(FlatSystemProperties.USE_WINDOW_DECORATIONS, "${SystemInfo.isLinux || SystemInfo.isWindows}")
System.setProperty(FlatSystemProperties.USE_ROUNDED_POPUP_BORDER, "false")
if (SystemInfo.isLinux) {
@@ -101,22 +205,23 @@ 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)
@@ -147,9 +252,8 @@ class ApplicationRunner {
}
UIManager.put("Table.rowHeight", 24)
UIManager.put("Table.cellNoFocusBorder", BorderFactory.createEmptyBorder())
UIManager.put("Table.focusCellHighlightBorder", BorderFactory.createEmptyBorder())
UIManager.put("Table.focusSelectedCellHighlightBorder", BorderFactory.createEmptyBorder())
UIManager.put("Table.focusCellHighlightBorder", FlatTableCellBorder.Default())
UIManager.put("Table.focusSelectedCellHighlightBorder", FlatTableCellBorder.Default())
UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Tree.rowHeight", 24)
@@ -160,85 +264,60 @@ class ApplicationRunner {
UIManager.put("List.selectionArc", UIManager.getInt("Component.arc"))
}
private fun setupMacOSDock() {
val countDownLatch = CountDownLatch(1)
val cls = Class.forName("com.apple.eawt.Application")
val app = cls.getMethod("getApplication").invoke(null)
val addAppEventListener = cls.getMethod("addAppEventListener", SystemEventListener::class.java)
addAppEventListener.invoke(app, object : AppReopenedListener {
override fun appReopened(e: AppReopenedEvent) {
val manager = TermoraFrameManager.getInstance()
if (manager.getWindows().isEmpty()) {
manager.createWindow().isVisible = true
}
}
})
// 当应用程序销毁时,驻守线程也可以退出了
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
override fun dispose() {
countDownLatch.countDown()
}
})
// 驻守线程,不然当所有窗口都关闭时,程序会自动退出
// wait application exit
Thread.ofPlatform().daemon(false)
.priority(Thread.MIN_PRIORITY)
.start { countDownLatch.await() }
}
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}")
}
}
/**
* Windows 情况覆盖
*/
private fun setupTinylog() {
if (SystemInfo.isWindows) {
val dir = File(Application.getBaseDataDir(), "logs")
FileUtils.forceMkdir(dir)
Configuration.set("writer_file.latest", "${dir.absolutePath}/${Application.getName().lowercase()}.log")
Configuration.set("writer_file.file", "${dir.absolutePath}/{date:yyyy}-{date:MM}-{date:dd}.log")
}
}
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
}
}
System.err.println("Program is already running")
exitProcess(1)
}
singletonLock = lock
}
private fun openDatabase() {
val dir = Application.getDatabaseFile()
try {
Database.open(dir)
Database.getDatabase()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
@@ -251,4 +330,67 @@ class ApplicationRunner {
}
}
/**
* 统计 https://mixpanel.com
*/
private fun enableAnalytics() {
if (Application.isUnknownVersion()) {
return
}
swingCoroutineScope.launch(Dispatchers.IO) {
try {
val properties = JSONObject()
properties.put("os", SystemUtils.OS_NAME)
if (SystemInfo.isLinux) {
properties.put("platform", "Linux")
} else if (SystemInfo.isWindows) {
properties.put("platform", "Windows")
} else if (SystemInfo.isMacOS) {
properties.put("platform", "macOS")
}
properties.put("version", Application.getVersion())
properties.put("language", Locale.getDefault().toString())
val message = MessageBuilder("0871335f59ee6d0eb246b008a20f9d1c")
.event(getAnalyticsUserID(), "launch", properties)
val delivery = ClientDelivery()
delivery.addMessage(message)
val endpoints = listOf(
"https://api-eu.mixpanel.com",
"https://api-in.mixpanel.com",
"https://api.mixpanel.com",
"http://api.mixpanel.com",
)
for (endpoint in endpoints) {
try {
MixpanelAPI(
"$endpoint/track",
"$endpoint/engage",
"$endpoint/groups"
).deliver(delivery, true)
break
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
continue
}
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
private fun getAnalyticsUserID(): String {
var id = Database.getDatabase().properties.getString("AnalyticsUserID")
if (id.isNullOrBlank()) {
id = UUID.randomUUID().toSimpleString()
Database.getDatabase().properties.putString("AnalyticsUserID", id)
}
return id
}
}

View File

@@ -0,0 +1,201 @@
package app.termora
import com.formdev.flatlaf.util.SystemInfo
import com.sun.jna.platform.win32.Kernel32
import com.sun.jna.platform.win32.User32
import com.sun.jna.platform.win32.WinDef.*
import com.sun.jna.platform.win32.WinError
import com.sun.jna.platform.win32.WinUser.*
import com.sun.jna.platform.win32.Wtsapi32
import org.slf4j.LoggerFactory
import java.nio.channels.FileChannel
import java.nio.channels.FileLock
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.util.concurrent.atomic.AtomicBoolean
class ApplicationSingleton private constructor() : Disposable {
@Volatile
private var isSingleton = null as Boolean?
companion object {
fun getInstance(): ApplicationSingleton {
return ApplicationScope.forApplicationScope()
.getOrCreate(ApplicationSingleton::class) { ApplicationSingleton() }
}
}
fun isSingleton(): Boolean {
var singleton = this.isSingleton
if (singleton != null) return singleton
try {
synchronized(this) {
singleton = this.isSingleton
if (singleton != null) return singleton as Boolean
if (SystemInfo.isWindows) {
val handle = Kernel32.INSTANCE.CreateMutex(null, false, Application.getName())
singleton = handle != null && Kernel32.INSTANCE.GetLastError() != WinError.ERROR_ALREADY_EXISTS
if (singleton == true) {
// 启动监听器,方便激活窗口
Thread.ofVirtual().start(Win32HelperWindow.getInstance())
} else {
// 尝试激活窗口
Win32HelperWindow.tick()
}
} else {
singleton = FileLocker.getInstance().tryLock()
}
this.isSingleton = singleton == true
}
} catch (e: Exception) {
e.printStackTrace(System.err)
return false
}
return this.isSingleton == true
}
private class FileLocker private constructor() {
companion object {
fun getInstance(): FileLocker {
return ApplicationScope.forApplicationScope()
.getOrCreate(FileLocker::class) { FileLocker() }
}
}
private lateinit var singletonChannel: FileChannel
private lateinit var singletonLock: FileLock
fun tryLock(): Boolean {
singletonChannel = FileChannel.open(
Paths.get(Application.getBaseDataDir().absolutePath, "lock"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
)
val lock = singletonChannel.tryLock() ?: return false
this.singletonLock = lock
return true
}
}
private class Win32HelperWindow private constructor() : Runnable {
companion object {
private val log = LoggerFactory.getLogger(Win32HelperWindow::class.java)
private val WindowClass = "${Application.getName()}HelperWindowClass"
private val WindowName =
"${Application.getName()} hidden helper window, used only to catch the windows events"
private const val TICK: Int = WM_USER + 1
fun getInstance(): Win32HelperWindow {
return ApplicationScope.forApplicationScope()
.getOrCreate(Win32HelperWindow::class) { Win32HelperWindow() }
}
fun tick() {
val hWnd = User32.INSTANCE.FindWindow(WindowClass, WindowName) ?: return
User32.INSTANCE.SendMessage(hWnd, TICK, WPARAM(), LPARAM())
}
}
private val isRunning = AtomicBoolean(false)
override fun run() {
if (SystemInfo.isWindows) {
if (isRunning.compareAndSet(false, true)) {
Win32Window()
}
}
}
private class Win32Window : WindowProc {
/**
* Instantiates a new win32 window test.
*/
init {
// define new window class
val hInst = Kernel32.INSTANCE.GetModuleHandle(null)
val wClass = WNDCLASSEX()
wClass.hInstance = hInst
wClass.lpfnWndProc = this
wClass.lpszClassName = WindowClass
// register window class
User32.INSTANCE.RegisterClassEx(wClass)
// create new window
val hWnd = User32.INSTANCE.CreateWindowEx(
User32.WS_EX_TOPMOST,
WindowClass,
WindowName,
0, 0, 0, 0, 0,
null, // WM_DEVICECHANGE contradicts parent=WinUser.HWND_MESSAGE
null, hInst, null
)
val msg = MSG()
while (User32.INSTANCE.GetMessage(msg, hWnd, 0, 0) > 0) {
User32.INSTANCE.TranslateMessage(msg)
User32.INSTANCE.DispatchMessage(msg)
}
Wtsapi32.INSTANCE.WTSUnRegisterSessionNotification(hWnd)
User32.INSTANCE.UnregisterClass(WindowClass, hInst)
User32.INSTANCE.DestroyWindow(hWnd)
}
override fun callback(hwnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT {
when (uMsg) {
WM_CREATE -> {
if (log.isDebugEnabled) {
log.debug("win32 helper window created")
}
return LRESULT()
}
TICK -> {
if (log.isDebugEnabled) {
log.debug("win32 helper window tick")
}
onTick()
return LRESULT()
}
WM_DESTROY -> {
if (log.isDebugEnabled) {
log.debug("win32 helper window destroyed")
}
User32.INSTANCE.PostQuitMessage(0)
return LRESULT()
}
else -> return User32.INSTANCE.DefWindowProc(hwnd, uMsg, wParam, lParam)
}
}
private fun onTick() {
TermoraFrameManager.getInstance().tick()
}
}
}
}

View File

@@ -0,0 +1,88 @@
package app.termora
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import javax.swing.JPopupMenu
import javax.swing.SwingUtilities
class BackgroundManager private constructor() {
companion object {
private val log = LoggerFactory.getLogger(BackgroundManager::class.java)
fun getInstance(): BackgroundManager {
return ApplicationScope.forApplicationScope().getOrCreate(BackgroundManager::class) { BackgroundManager() }
}
}
private val appearance get() = Database.getDatabase().appearance
private var bufferedImage: BufferedImage? = null
private var imageFilepath = StringUtils.EMPTY
fun setBackgroundImage(file: File) {
synchronized(this) {
try {
bufferedImage = file.inputStream().use { ImageIO.read(it) }
imageFilepath = file.absolutePath
appearance.backgroundImage = file.absolutePath
SwingUtilities.invokeLater {
for (window in TermoraFrameManager.getInstance().getWindows()) {
SwingUtilities.updateComponentTreeUI(window)
}
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
fun getBackgroundImage(): BufferedImage? {
val bg = doGetBackgroundImage()
if (bg == null) {
if (JPopupMenu.getDefaultLightWeightPopupEnabled()) {
return null
} else {
JPopupMenu.setDefaultLightWeightPopupEnabled(true)
}
} else {
if (JPopupMenu.getDefaultLightWeightPopupEnabled()) {
JPopupMenu.setDefaultLightWeightPopupEnabled(false)
}
}
return bg
}
private fun doGetBackgroundImage(): BufferedImage? {
synchronized(this) {
if (bufferedImage == null || imageFilepath.isEmpty()) {
if (appearance.backgroundImage.isBlank()) {
return null
}
val file = File(appearance.backgroundImage)
if (file.exists()) {
setBackgroundImage(file)
}
}
return bufferedImage
}
}
fun clearBackgroundImage() {
synchronized(this) {
bufferedImage = null
imageFilepath = StringUtils.EMPTY
appearance.backgroundImage = StringUtils.EMPTY
SwingUtilities.invokeLater {
for (window in TermoraFrameManager.getInstance().getWindows()) {
SwingUtilities.updateComponentTreeUI(window)
}
}
}
}
}

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

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

View File

@@ -1,49 +1,40 @@
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.snippet.Snippet
import app.termora.sync.SyncManager
import app.termora.sync.SyncType
import app.termora.terminal.CursorStyle
import jetbrains.exodus.bindings.StringBinding
import jetbrains.exodus.env.*
import kotlinx.coroutines.Dispatchers
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 SNIPPET_STORE = "Snippet"
private const val KEYWORD_HIGHLIGHT_STORE = "KeywordHighlight"
private const val MACRO_STORE = "Macro"
private const val KEY_PAIR_STORE = "KeyPair"
private const val DELETED_DATA_STORE = "DeletedData"
private val log = LoggerFactory.getLogger(Database::class.java)
private lateinit var database: Database
val instance by lazy {
if (!::database.isInitialized) {
throw UnsupportedOperationException("Database has not been initialized!")
}
database
}
fun open(dir: File) {
if (::database.isInitialized) {
throw UnsupportedOperationException("Database is already open")
}
private fun open(dir: File): Database {
val config = EnvironmentConfig()
// 32MB
config.setLogFileSize(1024 * 32)
@@ -51,8 +42,12 @@ class Database private constructor(private val env: Environment) : Disposable {
// 5m
config.setGcStartIn(5.minutes.inWholeMilliseconds.toInt())
val environment = Environments.newInstance(dir, config)
database = Database(environment)
Disposer.register(ApplicationDisposable.instance, database)
return Database(environment)
}
fun getDatabase(): Database {
return ApplicationScope.forApplicationScope()
.getOrCreate(Database::class) { open(Application.getDatabaseFile()) }
}
}
@@ -60,9 +55,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> {
@@ -77,17 +107,6 @@ class Database private constructor(private val env: Environment) : Disposable {
}
}
fun removeAllHost() {
env.executeInTransaction { tx ->
val store = env.openStore(HOST_STORE, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
store.openCursor(tx).use {
while (it.next) {
it.deleteCurrent()
}
}
}
}
fun removeAllKeyPair() {
env.executeInTransaction { tx ->
val store = env.openStore(KEY_PAIR_STORE, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
@@ -128,11 +147,67 @@ class Database private constructor(private val env: Environment) : Disposable {
env.executeInTransaction {
delete(it, HOST_STORE, id)
if (log.isDebugEnabled) {
log.debug("Removed Host: $id")
log.debug("Removed host: $id")
}
}
}
fun addDeletedData(deletedData: DeletedData) {
val text = ohMyJson.encodeToString(deletedData)
env.executeInTransaction {
put(it, DELETED_DATA_STORE, deletedData.id, text)
if (log.isDebugEnabled) {
log.debug("Added DeletedData: ${deletedData.id} , $text")
}
}
}
fun getDeletedData(): Collection<DeletedData> {
return env.computeInTransaction { tx ->
openCursor<DeletedData?>(tx, DELETED_DATA_STORE) { _, value ->
try {
ohMyJson.decodeFromString(value)
} catch (e: Exception) {
null
}
}.values.filterNotNull()
}
}
fun addSnippet(snippet: Snippet) {
var text = ohMyJson.encodeToString(snippet)
if (doorman.isWorking()) {
text = doorman.encrypt(text)
}
env.executeInTransaction {
put(it, SNIPPET_STORE, snippet.id, text)
if (log.isDebugEnabled) {
log.debug("Added Snippet: ${snippet.id} , ${snippet.name}")
}
}
}
fun removeSnippet(id: String) {
env.executeInTransaction {
delete(it, SNIPPET_STORE, id)
if (log.isDebugEnabled) {
log.debug("Removed snippet: $id")
}
}
}
fun getSnippets(): Collection<Snippet> {
val isWorking = doorman.isWorking()
return env.computeInTransaction { tx ->
openCursor<Snippet>(tx, SNIPPET_STORE) { _, value ->
if (isWorking)
ohMyJson.decodeFromString(doorman.decrypt(value))
else
ohMyJson.decodeFromString(value)
}.values
}
}
fun getKeywordHighlights(): Collection<KeywordHighlight> {
return env.computeInTransaction { tx ->
openCursor<KeywordHighlight>(tx, KEYWORD_HIGHLIGHT_STORE) { _, value ->
@@ -214,6 +289,18 @@ class Database private constructor(private val env: Environment) : Disposable {
val k = StringBinding.stringToEntry(key)
val v = StringBinding.stringToEntry(value)
store.put(tx, k, v)
// 数据变动时触发一次同步
if (name == HOST_STORE ||
name == KEYMAP_STORE ||
name == SNIPPET_STORE ||
name == KEYWORD_HIGHLIGHT_STORE ||
name == MACRO_STORE ||
name == KEY_PAIR_STORE ||
name == DELETED_DATA_STORE
) {
SyncManager.getInstance().triggerOnChanged()
}
}
private fun delete(tx: Transaction, name: String, key: String) {
@@ -273,8 +360,7 @@ class Database private constructor(private val env: Environment) : Disposable {
private val properties = Collections.synchronizedMap(mutableMapOf<String, String>())
init {
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) { properties.putAll(getProperties()) }
swingCoroutineScope.launch(Dispatchers.IO) { properties.putAll(getProperties()) }
}
protected open fun getString(key: String): String? {
@@ -346,6 +432,13 @@ class Database private constructor(private val env: Environment) : Disposable {
}
}
protected inner class DoublePropertyDelegate(defaultValue: Double) :
PropertyDelegate<Double>(defaultValue) {
override fun convertValue(value: String): Double {
return value.toDoubleOrNull() ?: initializer.invoke()
}
}
protected inner class LongPropertyDelegate(defaultValue: Long) :
PropertyDelegate<Long>(defaultValue) {
@@ -372,10 +465,10 @@ class Database private constructor(private val env: Environment) : Disposable {
protected inner class CursorStylePropertyDelegate(defaultValue: CursorStyle) :
PropertyDelegate<CursorStyle>(defaultValue) {
override fun convertValue(value: String): CursorStyle {
try {
return CursorStyle.valueOf(value)
} catch (e: Exception) {
return initializer.invoke()
return try {
CursorStyle.valueOf(value)
} catch (_: Exception) {
initializer.invoke()
}
}
}
@@ -413,7 +506,7 @@ class Database private constructor(private val env: Environment) : Disposable {
/**
* 字体大小
*/
var fontSize by IntPropertyDelegate(16)
var fontSize by IntPropertyDelegate(14)
/**
* 最大行数
@@ -425,6 +518,21 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var debug by BooleanPropertyDelegate(false)
/**
* 蜂鸣声
*/
var beep by BooleanPropertyDelegate(true)
/**
* 超链接
*/
var hyperlink by BooleanPropertyDelegate(true)
/**
* 光标闪烁
*/
var cursorBlink by BooleanPropertyDelegate(false)
/**
* 选中复制
*/
@@ -434,6 +542,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 +577,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 +640,23 @@ class Database private constructor(private val env: Environment) : Disposable {
* 跟随系统
*/
var followSystem by BooleanPropertyDelegate(true)
var darkTheme by StringPropertyDelegate("Dark")
var lightTheme by StringPropertyDelegate("Light")
/**
* 允许后台运行也就是托盘
*/
var backgroundRunning by BooleanPropertyDelegate(false)
/**
* 标签关闭前确认
*/
var confirmTabClose by BooleanPropertyDelegate(false)
/**
* 背景图片的地址
*/
var backgroundImage by StringPropertyDelegate(StringUtils.EMPTY)
/**
* 语言
@@ -530,6 +665,46 @@ class Database private constructor(private val env: Environment) : Disposable {
I18n.containsLanguage(Locale.getDefault()) ?: Locale.US.toString()
}
/**
* 透明度
*/
var opacity by DoublePropertyDelegate(1.0)
}
/**
* SFTP
*/
inner class SFTP : Property("Setting.SFTP") {
/**
* 编辑命令
*/
var editCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* sftp command
*/
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* defaultDirectory
*/
var defaultDirectory by StringPropertyDelegate(StringUtils.EMPTY)
/**
* 是否固定在标签栏
*/
var pinTab by BooleanPropertyDelegate(false)
/**
* 是否保留原始文件时间
*/
var preserveModificationTime by BooleanPropertyDelegate(false)
}
/**
@@ -546,8 +721,10 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var rangeHosts by BooleanPropertyDelegate(true)
var rangeKeyPairs by BooleanPropertyDelegate(true)
var rangeSnippets by BooleanPropertyDelegate(true)
var rangeKeywordHighlights by BooleanPropertyDelegate(true)
var rangeMacros by BooleanPropertyDelegate(true)
var rangeKeymap by BooleanPropertyDelegate(true)
/**
* Token
@@ -568,6 +745,11 @@ class Database private constructor(private val env: Environment) : Disposable {
* 最后同步时间
*/
var lastSyncTime by LongPropertyDelegate(0L)
/**
* 同步策略为空就是默认手动
*/
var policy by StringPropertyDelegate(StringUtils.EMPTY)
}
override fun dispose() {

View File

@@ -0,0 +1,52 @@
package app.termora
/**
* 仅标记
*/
class DeleteDataManager private constructor() {
companion object {
fun getInstance(): DeleteDataManager {
return ApplicationScope.forApplicationScope().getOrCreate(DeleteDataManager::class) { DeleteDataManager() }
}
}
private val data = mutableMapOf<String, DeletedData>()
private val database get() = Database.getDatabase()
fun removeHost(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "Host", deleteDate))
}
fun removeKeymap(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "Keymap", deleteDate))
}
fun removeKeyPair(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "KeyPair", deleteDate))
}
fun removeKeywordHighlight(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "KeywordHighlight", deleteDate))
}
fun removeMacro(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "Macro", deleteDate))
}
fun removeSnippet(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "Snippet", deleteDate))
}
private fun addDeletedData(deletedData: DeletedData) {
if (data.containsKey(deletedData.id)) return
data[deletedData.id] = deletedData
database.addDeletedData(deletedData)
}
fun getDeletedData(): List<DeletedData> {
if (data.isEmpty()) {
data.putAll(database.getDeletedData().associateBy { it.id })
}
return data.values.sortedBy { it.deleteDate }
}
}

View File

@@ -1,62 +1,124 @@
package app.termora
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.native.osx.NativeMacLibrary
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()
private val titleBar by lazy { LogicCustomTitleBar.createCustomTitleBar(this) }
val disposable = Disposer.newDisposable()
private val customTitleBar = if (SystemInfo.isMacOS && JBR.isWindowDecorationsSupported())
JBR.getWindowDecorations().createCustomTitleBar() else null
companion object {
const val DEFAULT_ACTION = "DEFAULT_ACTION"
private const val PROCESS_GLOBAL_KEYMAP = "PROCESS_GLOBAL_KEYMAP"
}
protected var controlsVisible = true
set(value) {
field = value
titleBar.putProperty("controls.visible", value)
if (SystemInfo.isMacOS) {
if (customTitleBar != null) {
customTitleBar.putProperty("controls.visible", value)
} else {
NativeMacLibrary.setControlsVisible(this, value)
}
} else {
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICONIFFY, value)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_MAXIMIZE, value)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_CLOSE, value)
}
}
protected var titleBarHeight = UIManager.getInt("TabbedPane.tabHeight").toFloat()
protected var fullWindowContent = false
set(value) {
titleBar.height = value
field = value
rootPane.putClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT, value)
}
protected var titleVisible = true
set(value) {
field = value
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, value)
}
protected var titleIconVisible = false
set(value) {
field = value
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICON, value)
}
protected var titleBarHeight = UIManager.getInt("TabbedPane.tabHeight")
set(value) {
field = value
if (SystemInfo.isMacOS) {
customTitleBar?.height = height.toFloat()
} else {
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, value)
}
}
protected var lostFocusDispose = false
protected var escapeDispose = true
protected fun init() {
defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
initTitleBar()
initEvents()
if (JBR.isWindowDecorationsSupported()) {
if (rootPane.getClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE) != false) {
val titlePanel = createTitlePanel()
if (titlePanel != null) {
rootPanel.add(titlePanel, BorderLayout.NORTH)
}
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)
}
init {
super.setDefaultCloseOperation(DISPOSE_ON_CLOSE)
// 使用 FlatLaf 的 TitlePane
if (SystemInfo.isWindows || SystemInfo.isLinux) {
rootPane.windowDecorationStyle = JRootPane.PLAIN_DIALOG
}
}
protected fun init() {
initEvents()
val rootPanel = JPanel(BorderLayout())
rootPanel.add(createCenterPanel(), BorderLayout.CENTER)
if (SystemInfo.isMacOS) {
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
rootPane.putClientProperty(
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING,
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING_MEDIUM
)
val titlePanel = createTitlePanel()
if (titlePanel != null) {
rootPanel.add(titlePanel, BorderLayout.NORTH)
}
val customTitleBar = this.customTitleBar
if (customTitleBar != null) {
customTitleBar.putProperty("controls.visible", controlsVisible)
customTitleBar.height = titleBarHeight.toFloat()
JBR.getWindowDecorations().setCustomTitleBar(this, customTitleBar)
}
}
val southPanel = createSouthPanel()
if (southPanel != null) {
rootPanel.add(southPanel, BorderLayout.SOUTH)
@@ -109,7 +171,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
val panel = JPanel(BorderLayout())
panel.add(titleLabel, BorderLayout.CENTER)
panel.preferredSize = Dimension(-1, titleBar.height.toInt())
panel.preferredSize = Dimension(-1, titleBarHeight)
return panel
@@ -132,8 +194,35 @@ 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) {
doCancelAction()
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 = c as? Window ?: SwingUtilities.windowForComponent(c)
if (window != null) {
val windows = window.ownedWindows
for (w in windows) {
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
openPopup = true
w.dispose()
}
}
}
if (openPopup) {
return
}
SwingUtilities.invokeLater { doCancelAction() }
}
})
@@ -151,30 +240,20 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
}
})
if (SystemInfo.isWindows) {
addWindowListener(object : WindowAdapter(), ThemeChangeListener {
override fun windowClosed(e: WindowEvent) {
ThemeManager.instance.removeThemeChangeListener(this)
}
override fun windowOpened(e: WindowEvent) {
onChanged()
ThemeManager.instance.addThemeChangeListener(this)
}
override fun onChanged() {
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
}
})
}
}
private fun initTitleBar() {
titleBar.height = titleBarHeight
titleBar.putProperty("controls.visible", controlsVisible)
if (JBR.isWindowDecorationsSupported()) {
JBR.getWindowDecorations().setCustomTitleBar(this, titleBar)
override fun addNotify() {
super.addNotify()
// 显示后触发一次重绘制
if (SystemInfo.isWindows || SystemInfo.isLinux) {
this.controlsVisible = controlsVisible
this.titleBarHeight = titleBarHeight
this.titleIconVisible = titleIconVisible
this.titleVisible = titleVisible
this.fullWindowContent = fullWindowContent
}
}
protected open fun doOKAction() {
@@ -190,7 +269,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 +278,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,8 @@
package app.termora
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import org.apache.commons.lang3.StringUtils
@Suppress("CascadeIf")
class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
init {
generalOption.portTextField.value = host.port
@@ -10,15 +10,14 @@ 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
} else if (host.authentication.type == AuthenticationType.SSHAgent) {
generalOption.sshAgentComboBox.selectedItem = host.authentication.password
}
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
@@ -34,6 +33,29 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
tunnelingOption.tunnelings.addAll(host.tunnelings)
tunnelingOption.x11ForwardingCheckBox.isSelected = host.options.enableX11Forwarding
tunnelingOption.x11ServerTextField.text = StringUtils.defaultIfBlank(host.options.x11Forwarding, "localhost:0")
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
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
}
override fun getHost(): Host {

View File

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

View File

@@ -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,14 @@ enum class Protocol {
Folder,
SSH,
Local,
Serial,
RDP,
/**
* 交互式的 SFTP此协议只在系统内部交互不应该暴露给用户也不应该持久化
*/
@Transient
SFTPPty
}
@@ -20,6 +39,7 @@ enum class AuthenticationType {
No,
Password,
PublicKey,
SSHAgent,
KeyboardInteractive,
}
@@ -39,6 +59,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 +128,27 @@ data class Options(
/**
* SSH 心跳间隔
*/
val heartbeatInterval: Int = 30
val heartbeatInterval: Int = 30,
/**
* 串口配置
*/
val serialComm: SerialComm = SerialComm(),
/**
* SFTP 默认目录
*/
val sftpDefaultDirectory: String = StringUtils.EMPTY,
/**
* X11 Forwarding
*/
val enableX11Forwarding: Boolean = false,
/**
* X11 Server,Format: host.port. default: localhost:0
*/
val x11Forwarding: String = StringUtils.EMPTY,
) {
companion object {
val Default = Options()
@@ -139,6 +226,27 @@ data class EncryptedHost(
var updateDate: Long = 0L,
)
/**
* 被删除的数据
*/
@Serializable
data class DeletedData(
/**
* 被删除的 ID
*/
val id: String = StringUtils.EMPTY,
/**
* 数据类型Host、Keymap、KeyPair、KeywordHighlight、Macro、Snippet
*/
val type: String = StringUtils.EMPTY,
/**
* 被删除的时间
*/
val deleteDate: Long,
)
@Serializable
data class Host(
@@ -190,7 +298,7 @@ data class Host(
val tunnelings: List<Tunneling> = emptyList(),
/**
* 排序
* 排序,越小越靠前
*/
val sort: Long = 0,
/**
@@ -237,4 +345,8 @@ data class Host(
result = 31 * result + ownerId.hashCode()
return result
}
override fun toString(): String {
return name
}
}

View File

@@ -1,12 +1,18 @@
package app.termora
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
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 java.util.*
import javax.swing.*
class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
@@ -19,6 +25,7 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
isModal = true
title = I18n.getString("termora.new-host.title")
setLocationRelativeTo(null)
pane.setSelectedIndex(0)
init()
}
@@ -40,44 +47,75 @@ 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
swingCoroutineScope.launch(Dispatchers.IO) {
// 因为测试连接的时候从数据库读取会导致失效所以这里生成随机ID
testConnection(pane.getHost().copy(id = UUID.randomUUID().toSimpleString()))
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 = SshClients.openClient(host, this)
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,54 +1,52 @@
package app.termora
import app.termora.db.Database
import java.util.*
interface HostListener : EventListener {
fun hostAdded(host: Host) {}
fun hostRemoved(id: String) {}
fun hostsChanged() {}
}
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 listeners = mutableListOf<HostListener>()
private val database get() = Database.getDatabase()
private var hosts = mutableMapOf<String, Host>()
fun addHost(host: Host, notify: Boolean = true) {
/**
* 修改缓存并存入数据库
*/
fun addHost(host: Host) {
assertEventDispatchThread()
database.addHost(host)
if (notify) listeners.forEach { it.hostAdded(host) }
if (host.deleted) {
removeHost(host.id)
} else {
database.addHost(host)
hosts[host.id] = host
}
}
fun removeHost(id: String) {
assertEventDispatchThread()
hosts.entries.removeIf { it.value.id == id || it.value.parentId == id }
database.removeHost(id)
listeners.forEach { it.hostRemoved(id) }
DeleteDataManager.getInstance().removeHost(id)
}
/**
* 第一次调用从数据库中获取,后续从缓存中获取
*/
fun hosts(): List<Host> {
return database.getHosts()
if (hosts.isEmpty()) {
database.getHosts().filter { !it.deleted }
.forEach { hosts[it.id] = it }
}
return hosts.values.filter { !it.deleted }
.sortedWith(compareBy<Host> { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort })
}
fun removeAll() {
assertEventDispatchThread()
database.removeAllHost()
listeners.forEach { it.hostsChanged() }
/**
* 从缓存中获取
*/
fun getHost(id: String): Host? {
return hosts[id]
}
fun addHostListener(listener: HostListener) {
listeners.add(listener)
}
fun removeHostListener(listener: HostListener) {
listeners.remove(listener)
}
}

View File

@@ -1,32 +1,50 @@
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.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.RegExUtils
import org.apache.commons.lang3.StringUtils
import org.eclipse.jgit.internal.transport.sshd.agent.connector.PageantConnector
import org.eclipse.jgit.internal.transport.sshd.agent.connector.UnixDomainSocketConnector
import org.eclipse.jgit.internal.transport.sshd.agent.connector.WinPipeConnector
import java.awt.*
import java.awt.datatransfer.DataFlavor
import java.awt.event.*
import java.nio.charset.Charset
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel
@Suppress("CascadeIf")
open class HostOptionsPane : OptionsPane() {
protected val tunnelingOption = TunnelingOption()
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 sftpOption = SFTPOption()
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init {
addOption(generalOption)
addOption(proxyOption)
addOption(tunnelingOption)
addOption(jumpHostsOption)
addOption(terminalOption)
addOption(serialCommOption)
addOption(sftpOption)
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
}
@@ -39,17 +57,22 @@ open class HostOptionsPane : OptionsPane() {
val port = (generalOption.portTextField.value ?: 22) as Int
var authentication = Authentication.No
var proxy = Proxy.No
val authenticationType = generalOption.authenticationTypeComboBox.selectedItem as AuthenticationType
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
if (authenticationType == AuthenticationType.Password) {
authentication = authentication.copy(
type = AuthenticationType.Password,
type = authenticationType,
password = String(generalOption.passwordTextField.password)
)
} else if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
val keyPair = generalOption.publicKeyTextField.getClientProperty(OhKeyPair::class) as OhKeyPair?
} else if (authenticationType == AuthenticationType.PublicKey) {
authentication = authentication.copy(
type = AuthenticationType.PublicKey,
password = keyPair?.id ?: StringUtils.EMPTY
type = authenticationType,
password = generalOption.publicKeyComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
)
} else if (authenticationType == AuthenticationType.SSHAgent) {
authentication = authentication.copy(
type = authenticationType,
password = generalOption.sshAgentComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
)
}
@@ -64,11 +87,26 @@ 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,
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text,
enableX11Forwarding = tunnelingOption.x11ForwardingCheckBox.isSelected,
x11Forwarding = tunnelingOption.x11ServerTextField.text,
)
return Host(
@@ -100,6 +138,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 +151,7 @@ open class HostOptionsPane : OptionsPane() {
return false
}
} else if (host.authentication.type == AuthenticationType.PublicKey) {
if (validateField(generalOption.publicKeyTextField)) {
if (validateField(generalOption.publicKeyComboBox)) {
return false
}
}
@@ -128,6 +172,17 @@ open class HostOptionsPane : OptionsPane() {
}
}
// tunnel
if (tunnelingOption.x11ForwardingCheckBox.isSelected) {
if (validateField(tunnelingOption.x11ServerTextField)) {
return false
}
val segments = tunnelingOption.x11ServerTextField.text.split(":")
if (segments.size != 2 || segments[1].toIntOrNull() == null) {
setOutlineError(tunnelingOption.x11ServerTextField)
return false
}
}
return true
}
@@ -137,9 +192,27 @@ open class HostOptionsPane : OptionsPane() {
*/
private fun validateField(textField: JTextField): Boolean {
if (textField.isEnabled && textField.text.isBlank()) {
selectOptionJComponent(textField)
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
textField.requestFocusInWindow()
setOutlineError(textField)
return true
}
return false
}
private fun setOutlineError(textField: JTextField) {
selectOptionJComponent(textField)
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
textField.requestFocusInWindow()
}
/**
* 返回 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
@@ -150,11 +223,29 @@ open class HostOptionsPane : OptionsPane() {
val nameTextField = OutlineTextField(128)
val protocolTypeComboBox = FlatComboBox<Protocol>()
val usernameTextField = OutlineTextField(128)
val hostTextField = OutlineTextField(255)
val hostTextField = object : OutlineTextField(255) {
override fun paste() {
if (!toolkit.systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
return
}
var text = toolkit.systemClipboard.getData(DataFlavor.stringFlavor)?.toString() ?: return
if (text.isBlank()) {
return
}
// 移除所有不可见字符
text = RegExUtils.replaceAll(text, "[\\p{C}\\s]", StringUtils.EMPTY)
// text
replaceSelection(text)
}
}
private val passwordPanel = JPanel(BorderLayout())
private val chooseKeyBtn = JButton(Icons.greyKey)
val passwordTextField = OutlinePasswordField(255)
val publicKeyTextField = OutlineTextField()
val sshAgentComboBox = OutlineComboBox<String>()
val publicKeyComboBox = OutlineComboBox<String>()
val remarkTextArea = FixedLengthTextArea(512)
val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
@@ -166,9 +257,13 @@ open class HostOptionsPane : OptionsPane() {
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
publicKeyTextField.isEditable = false
publicKeyComboBox.isEditable = false
chooseKeyBtn.isFocusable = false
// 只有 Windows 允许修改
sshAgentComboBox.isEditable = SystemInfo.isWindows
sshAgentComboBox.isEnabled = SystemInfo.isWindows
protocolTypeComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
@@ -187,6 +282,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,10 +338,23 @@ open class HostOptionsPane : OptionsPane() {
protocolTypeComboBox.addItem(Protocol.SSH)
protocolTypeComboBox.addItem(Protocol.Local)
protocolTypeComboBox.addItem(Protocol.Serial)
protocolTypeComboBox.addItem(Protocol.RDP)
authenticationTypeComboBox.addItem(AuthenticationType.No)
authenticationTypeComboBox.addItem(AuthenticationType.Password)
authenticationTypeComboBox.addItem(AuthenticationType.PublicKey)
authenticationTypeComboBox.addItem(AuthenticationType.SSHAgent)
if (SystemInfo.isWindows) {
// 不要修改 addItem 的顺序,因为第一个是默认的
sshAgentComboBox.addItem(PageantConnector.DESCRIPTOR.identityAgent)
sshAgentComboBox.addItem(WinPipeConnector.DESCRIPTOR.identityAgent)
sshAgentComboBox.placeholderText = PageantConnector.DESCRIPTOR.identityAgent
} else {
sshAgentComboBox.addItem(UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
sshAgentComboBox.placeholderText = UnixDomainSocketConnector.DESCRIPTOR.identityAgent
}
authenticationTypeComboBox.selectedItem = AuthenticationType.Password
@@ -265,14 +395,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 +416,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,13 +505,21 @@ 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
)
} else if (authenticationTypeComboBox.selectedItem == AuthenticationType.SSHAgent) {
passwordPanel.add(sshAgentComboBox, BorderLayout.CENTER)
} else {
passwordPanel.add(passwordTextField, BorderLayout.CENTER)
}
@@ -587,8 +735,58 @@ open class HostOptionsPane : OptionsPane() {
}
}
protected inner class SFTPOption : JPanel(BorderLayout()), Option {
val defaultDirectoryField = OutlineTextField(255)
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.folder
}
override fun getTitle(): String {
return "SFTP"
}
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"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
.add(defaultDirectoryField).xy(3, rows).apply { rows += step }
.build()
return panel
}
}
protected inner class TunnelingOption : JPanel(BorderLayout()), Option {
val tunnelings = mutableListOf<Tunneling>()
val x11ForwardingCheckBox = JCheckBox("X DISPLAY:")
val x11ServerTextField = OutlineTextField(255)
private val model = object : DefaultTableModel() {
override fun getRowCount(): Int {
@@ -635,6 +833,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
@@ -657,13 +861,36 @@ open class HostOptionsPane : OptionsPane() {
box.add(Box.createHorizontalStrut(4))
box.add(deleteBtn)
add(JLabel("TCP/IP Forwarding:"), BorderLayout.NORTH)
add(scrollPane, BorderLayout.CENTER)
add(box, BorderLayout.SOUTH)
x11ForwardingCheckBox.isFocusable = false
if (x11ServerTextField.text.isBlank()) {
x11ServerTextField.text = "localhost:0"
}
val x11Forwarding = Box.createHorizontalBox()
x11Forwarding.border = BorderFactory.createCompoundBorder(
BorderFactory.createTitledBorder("X11 Forwarding"),
BorderFactory.createEmptyBorder(4, 4, 4, 4)
)
x11Forwarding.add(x11ForwardingCheckBox)
x11Forwarding.add(x11ServerTextField)
x11ServerTextField.isEnabled = x11ForwardingCheckBox.isSelected
val panel = JPanel(BorderLayout())
panel.add(JLabel("TCP/IP Forwarding:"), BorderLayout.NORTH)
panel.add(scrollPane, BorderLayout.CENTER)
panel.add(box, BorderLayout.SOUTH)
panel.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
add(panel, BorderLayout.CENTER)
add(x11Forwarding, BorderLayout.SOUTH)
}
private fun initEvents() {
x11ForwardingCheckBox.addChangeListener { x11ServerTextField.isEnabled = x11ForwardingCheckBox.isSelected }
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent?) {
val dialog = PortForwardingDialog(SwingUtilities.getWindowAncestor(this@HostOptionsPane))
@@ -843,4 +1070,289 @@ 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)
swingCoroutineScope.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 = NewHostTreeDialog(owner)
dialog.setFilter { node -> jumpHosts.none { it.id == node.host.id } && filter.invoke(node.host) }
dialog.setTreeName("HostOptionsPane.JumpHostsOption.Tree")
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
val hosts = dialog.hosts
if (hosts.isEmpty()) {
return
}
hosts.forEach {
val rowCount = model.rowCount
jumpHosts.add(it)
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,16 +1,26 @@
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
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.swing.Swing
import java.beans.PropertyChangeEvent
import javax.swing.Icon
abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
protected val coroutineScope by lazy { CoroutineScope(Dispatchers.Swing) }
protected val terminal = TerminalFactory.instance.createTerminal()
abstract class HostTerminalTab(
val windowScope: WindowScope,
val host: Host,
protected val terminal: Terminal = TerminalFactory.getInstance().createTerminal()
) : PropertyTerminalTab(), DataProvider {
companion object {
val Host = DataKey(app.termora.Host::class)
}
protected val coroutineScope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Swing) }
protected val terminalModel get() = terminal.getTerminalModel()
protected var unread = false
set(value) {
@@ -25,6 +35,7 @@ abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
}
init {
terminal.getTerminalModel().setData(Host, host)
terminal.getTerminalModel().addDataListener(object : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
if (key == VisualTerminal.Written) {
@@ -51,6 +62,7 @@ abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
}
override fun dispose() {
terminal.close()
coroutineScope.cancel()
}
@@ -60,4 +72,11 @@ abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
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,592 +0,0 @@
package app.termora
import app.termora.db.Database
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeOpenIcon
import org.jdesktop.swingx.action.ActionManager
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
import java.awt.Component
import java.awt.Dimension
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.*
import javax.swing.*
import javax.swing.event.CellEditorListener
import javax.swing.event.ChangeEvent
import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener
import javax.swing.tree.TreePath
import javax.swing.tree.TreeSelectionModel
class HostTree : JTree(), Disposable {
private val hostManager get() = HostManager.instance
private val editor = OutlineTextField(64)
var contextmenu = true
/**
* 双击是否打开连接
*/
var doubleClickConnection = true
val model = HostTreeModel()
val searchableModel = SearchableHostTreeModel(model)
init {
initView()
initEvents()
}
private fun initView() {
setModel(model)
isEditable = true
dropMode = DropMode.ON_OR_INSERT
dragEnabled = true
selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
editor.preferredSize = Dimension(220, 0)
setCellRenderer(object : DefaultXTreeCellRenderer() {
override fun getTreeCellRendererComponent(
tree: JTree,
value: Any,
sel: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
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
}
return c
}
})
setCellEditor(object : DefaultCellEditor(editor) {
override fun isCellEditable(e: EventObject?): Boolean {
if (e is MouseEvent) {
return false
}
return super.isCellEditable(e)
}
})
val state = Database.instance.properties.getString("HostTreeExpansionState")
if (state != null) {
TreeUtils.loadExpansionState(this@HostTree, state)
}
}
override fun convertValueToText(
value: Any?,
selected: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean
): String {
if (value is Host) {
return value.name
}
return super.convertValueToText(value, selected, expanded, leaf, row, hasFocus)
}
private fun initEvents() {
// 右键选中
addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
if (!SwingUtilities.isRightMouseButton(e)) {
return
}
requestFocusInWindow()
val selectionRows = selectionModel.selectionRows
val selRow = getClosestRowForLocation(e.x, e.y)
if (selRow < 0) {
selectionModel.clearSelection()
return
} else if (selectionRows != null && selectionRows.contains(selRow)) {
return
}
selectionPath = getPathForLocation(e.x, e.y)
setSelectionRow(selRow)
}
override fun mouseClicked(e: MouseEvent) {
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val host = lastSelectedPathComponent
if (host is Host && host.protocol != Protocol.Folder) {
ActionManager.getInstance().getAction(Actions.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(this, host))
}
}
}
})
// contextmenu
addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
if (!(SwingUtilities.isRightMouseButton(e))) {
return
}
if (Objects.isNull(lastSelectedPathComponent)) {
return
}
SwingUtilities.invokeLater { showContextMenu(e) }
}
})
// rename
getCellEditor().addCellEditorListener(object : CellEditorListener {
override fun editingStopped(e: ChangeEvent) {
val lastHost = lastSelectedPathComponent
if (lastHost !is Host || editor.text.isBlank() || editor.text == lastHost.name) {
return
}
runCatchingHost(lastHost.copy(name = editor.text))
}
override fun editingCanceled(e: ChangeEvent) {
}
})
// drag
transferHandler = object : TransferHandler() {
override fun createTransferable(c: JComponent): Transferable {
val nodes = selectionModel.selectionPaths
.map { it.lastPathComponent }
.filterIsInstance<Host>()
.toMutableList()
val iterator = nodes.iterator()
while (iterator.hasNext()) {
val node = iterator.next()
val parents = model.getPathToRoot(node).filter { it != node }
if (parents.any { nodes.contains(it) }) {
iterator.remove()
}
}
return MoveHostTransferable(nodes)
}
override fun getSourceActions(c: JComponent?): Int {
return MOVE
}
override fun canImport(support: TransferSupport): Boolean {
if (!support.isDrop) {
return false
}
val dropLocation = support.dropLocation
if (dropLocation !is JTree.DropLocation || support.component != this@HostTree
|| dropLocation.childIndex != -1
) {
return false
}
val lastNode = dropLocation.path.lastPathComponent
if (lastNode !is Host || lastNode.protocol != Protocol.Folder) {
return false
}
if (support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) {
val nodes = support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>
if (nodes.any { it == lastNode }) {
return false
}
for (parent in model.getPathToRoot(lastNode).filter { it != lastNode }) {
if (nodes.any { it == parent }) {
return false
}
}
}
support.setShowDropLocation(true)
return support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)
}
override fun importData(support: TransferSupport): Boolean {
if (!support.isDrop) {
return false
}
val dropLocation = support.dropLocation
if (dropLocation !is JTree.DropLocation) {
return false
}
val lastNode = dropLocation.path.lastPathComponent
if (lastNode !is Host || lastNode.protocol != Protocol.Folder) {
return false
}
if (!support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) {
return false
}
val hosts = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>)
.filterIsInstance<Host>().toMutableList()
if (hosts.isEmpty()) {
return false
}
// 记录展开的节点
val expandedHosts = mutableListOf<String>()
for (host in hosts) {
model.visit(host) {
if (it.protocol == Protocol.Folder) {
if (isExpanded(TreePath(model.getPathToRoot(it)))) {
expandedHosts.addFirst(it.id)
}
}
}
}
var now = System.currentTimeMillis()
for (host in hosts) {
model.removeNodeFromParent(host)
val newHost = host.copy(
parentId = lastNode.id,
sort = ++now,
updateDate = now
)
runCatchingHost(newHost)
}
expandNode(lastNode)
// 展开
for (id in expandedHosts) {
model.getHost(id)?.let { expandNode(it) }
}
return true
}
}
}
override fun isPathEditable(path: TreePath?): Boolean {
if (path == null) return false
if (path.lastPathComponent == model.root) return false
return super.isPathEditable(path)
}
override fun getLastSelectedPathComponent(): Any? {
val last = super.getLastSelectedPathComponent() ?: return null
if (last is Host) {
return model.getHost(last.id) ?: last
}
return last
}
private fun showContextMenu(event: MouseEvent) {
if (!contextmenu) return
val lastHost = lastSelectedPathComponent
if (lastHost !is Host) {
return
}
val 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"))
popupMenu.addSeparator()
val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy"))
val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove"))
val rename = popupMenu.add(I18n.getString("termora.welcome.contextmenu.rename"))
popupMenu.addSeparator()
val expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all"))
val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all"))
popupMenu.addSeparator()
popupMenu.add(newMenu)
popupMenu.addSeparator()
val 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))
}
}
rename.addActionListener {
startEditingAtPath(TreePath(model.getPathToRoot(lastHost)))
}
expandAll.addActionListener {
getSelectionNodes().forEach { expandNode(it, true) }
}
colspanAll.addActionListener {
selectionModel.selectionPaths.map { it.lastPathComponent }
.filterIsInstance<Host>()
.filter { it.protocol == Protocol.Folder }
.forEach { collapseNode(it) }
}
copy.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val parent = model.getParent(lastHost) ?: return
val node = copyNode(parent, lastHost)
selectionPath = TreePath(model.getPathToRoot(node))
}
})
remove.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
I18n.getString("termora.remove"),
JOptionPane.YES_NO_OPTION,
JOptionPane.QUESTION_MESSAGE
) == JOptionPane.YES_OPTION
) {
var lastParent: Host? = null
while (!selectionModel.isSelectionEmpty) {
val host = lastSelectedPathComponent ?: break
if (host !is Host) {
break
} else {
lastParent = model.getParent(host)
}
model.visit(host) { hostManager.removeHost(it.id) }
}
if (lastParent != null) {
selectionPath = TreePath(model.getPathToRoot(lastParent))
}
}
}
newFolder.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (lastHost.protocol != Protocol.Folder) {
return
}
val host = Host(
id = UUID.randomUUID().toSimpleString(),
protocol = Protocol.Folder,
name = I18n.getString("termora.welcome.contextmenu.new.folder.name"),
sort = System.currentTimeMillis(),
parentId = lastHost.id
)
runCatchingHost(host)
expandNode(lastHost)
selectionPath = TreePath(model.getPathToRoot(host))
startEditingAtPath(selectionPath)
}
})
newHost.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
showAddHostDialog()
}
})
property.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = HostDialog(SwingUtilities.getWindowAncestor(this@HostTree), lastHost)
dialog.isVisible = true
val host = dialog.host ?: return
runCatchingHost(host)
}
})
// 初始化状态
newFolder.isEnabled = lastHost.protocol == Protocol.Folder
newHost.isEnabled = newFolder.isEnabled
remove.isEnabled = !getSelectionNodes().any { it == model.root }
copy.isEnabled = remove.isEnabled
rename.isEnabled = remove.isEnabled
property.isEnabled = lastHost.protocol != Protocol.Folder
popupMenu.addPopupMenuListener(object : PopupMenuListener {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
this@HostTree.grabFocus()
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
this@HostTree.requestFocusInWindow()
}
override fun popupMenuCanceled(e: PopupMenuEvent) {
}
})
popupMenu.show(this, event.x, event.y)
}
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 expandNode(node: Host, including: Boolean = false) {
expandPath(TreePath(model.getPathToRoot(node)))
if (including) {
model.getChildren(node).forEach { expandNode(it, true) }
}
}
private fun copyNode(
parent: Host,
host: Host,
idGenerator: () -> String = { UUID.randomUUID().toSimpleString() }
): Host {
val now = System.currentTimeMillis()
val newHost = host.copy(
name = "${host.name} ${I18n.getString("termora.welcome.contextmenu.copy")}",
id = idGenerator.invoke(),
parentId = parent.id,
updateDate = now,
createDate = now,
sort = now
)
runCatchingHost(newHost)
if (host.protocol == Protocol.Folder) {
for (child in model.getChildren(host)) {
copyNode(newHost, child, idGenerator)
}
if (isExpanded(TreePath(model.getPathToRoot(host)))) {
expandNode(newHost)
}
}
return newHost
}
private fun runCatchingHost(host: Host) {
hostManager.addHost(host)
}
private fun collapseNode(node: Host) {
model.getChildren(node).forEach { collapseNode(it) }
collapsePath(TreePath(model.getPathToRoot(node)))
}
fun getSelectionNodes(): List<Host> {
val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent }
.filterIsInstance<Host>()
if (selectionNodes.isEmpty()) {
return emptyList()
}
val nodes = mutableListOf<Host>()
val parents = mutableListOf<Host>()
for (node in selectionNodes) {
if (node.protocol == Protocol.Folder) {
parents.add(node)
}
nodes.add(node)
}
while (parents.isNotEmpty()) {
val p = parents.removeFirst()
for (i in 0 until model.getChildCount(p)) {
val child = model.getChild(p, i) as Host
nodes.add(child)
parents.add(child)
}
}
return nodes
}
override fun dispose() {
Database.instance.properties.putString(
"HostTreeExpansionState",
TreeUtils.saveExpansionState(this)
)
}
private abstract class HostTreeNodeTransferable(val hosts: List<Host>) :
Transferable {
override fun getTransferDataFlavors(): Array<DataFlavor> {
return arrayOf(getDataFlavor())
}
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean {
return getDataFlavor() == flavor
}
override fun getTransferData(flavor: DataFlavor): Any {
return hosts
}
abstract fun getDataFlavor(): DataFlavor
}
private class MoveHostTransferable(hosts: List<Host>) : HostTreeNodeTransferable(hosts) {
companion object {
val dataFlavor =
DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveHostTransferable::class.java.name}")
}
override fun getDataFlavor(): DataFlavor {
return dataFlavor
}
}
}

View File

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

View File

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

View File

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

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,19 +3,38 @@ 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 settingSync by lazy { DynamicIcon("icons/settingSync.svg", "icons/settingSync_dark.svg") }
val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") }
val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") }
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
val inspectionsEye by lazy { DynamicIcon("icons/inspectionsEye.svg", "icons/inspectionsEye_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") }
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val locate by lazy { DynamicIcon("icons/locate.svg", "icons/locate_dark.svg") }
val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") }
val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
val warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_dark.svg") }
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") }
@@ -34,24 +53,27 @@ object Icons {
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") }
val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") }
val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_dark.svg") }
val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") }
val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") }
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") }
val microsoft by lazy { DynamicIcon("icons/microsoft.svg", "icons/microsoft_dark.svg") }
val microsoftWindows by lazy { DynamicIcon("icons/microsoftWindows.svg", "icons/microsoftWindows_dark.svg") }
val tencent by lazy { DynamicIcon("icons/tencent.svg") }
val google by lazy { DynamicIcon("icons/google-small.svg") }
val aliyun by lazy { DynamicIcon("icons/aliyun.svg") }
val yandexCloud by lazy { DynamicIcon("icons/yandexCloud.svg") }
val aws by lazy { DynamicIcon("icons/aws.svg","icons/aws_dark.svg") }
val aws by lazy { DynamicIcon("icons/aws.svg", "icons/aws_dark.svg") }
val huawei by lazy { DynamicIcon("icons/huawei.svg") }
val baidu by lazy { DynamicIcon("icons/baiduyun.svg") }
val tianyi by lazy { DynamicIcon("icons/tianyiyun.svg") }
val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg","icons/digitalocean_dark.svg") }
val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg", "icons/digitalocean_dark.svg") }
val terminalUnread by lazy { DynamicIcon("icons/terminalUnread.svg", "icons/terminalUnread_dark.svg") }
val dbPrimitive by lazy { DynamicIcon("icons/dbPrimitive.svg", "icons/dbPrimitive_dark.svg") }
val linux by lazy { DynamicIcon("icons/linux.svg", "icons/linux_dark.svg") }
@@ -59,6 +81,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") }
@@ -72,9 +95,28 @@ object Icons {
val sortedSet by lazy { DynamicIcon("icons/sortedSet.svg", "icons/sortedSet_dark.svg") }
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") }
val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
val file by lazy { DynamicIcon("icons/file.svg", "icons/file_dark.svg") }
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
val left by lazy { DynamicIcon("icons/left.svg", "icons/left_dark.svg") }
val right by lazy { DynamicIcon("icons/right.svg", "icons/right_dark.svg") }
val dotListFiles by lazy { DynamicIcon("icons/dotListFiles.svg", "icons/dotListFiles_dark.svg") }
val anyType by lazy { DynamicIcon("icons/anyType.svg", "icons/anyType_dark.svg") }
val toolWindowJsonPath by lazy { DynamicIcon("icons/toolWindowJsonPath.svg", "icons/toolWindowJsonPath_dark.svg") }
val codeSpan by lazy { DynamicIcon("icons/codeSpan.svg", "icons/codeSpan_dark.svg") }
val fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") }
val applyNotConflictsLeft by lazy {
DynamicIcon(
"icons/applyNotConflictsLeft.svg",
"icons/applyNotConflictsLeft_dark.svg"
)
}
val applyNotConflictsRight by lazy {
DynamicIcon(
"icons/applyNotConflictsRight.svg",
"icons/applyNotConflictsRight_dark.svg"
)
}
val expand by lazy { DynamicIcon("icons/expand.svg", "icons/expand_dark.svg") }
val collapse by lazy { DynamicIcon("icons/collapse.svg", "icons/collapse_dark.svg") }
val expandAll by lazy { DynamicIcon("icons/expandAll.svg", "icons/expandAll_dark.svg") }
@@ -86,5 +128,6 @@ object Icons {
val listKey by lazy { DynamicIcon("icons/listKey.svg", "icons/listKey_dark.svg") }
val forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") }
val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_dark.svg") }
val nvidia by lazy { DynamicIcon("icons/nvidia.svg", "icons/nvidia_dark.svg") }
}

View File

@@ -1,74 +0,0 @@
package app.termora
import com.formdev.flatlaf.extras.components.FlatTextField
import org.apache.commons.lang3.StringUtils
import java.awt.Window
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.UIManager
class InputDialog(
owner: Window,
title: String,
text: String = StringUtils.EMPTY,
placeholderText: String = StringUtils.EMPTY
) : DialogWrapper(owner) {
private val textField = FlatTextField()
private var text: String? = null
init {
setSize(340, 60)
setLocationRelativeTo(owner)
super.setTitle(title)
isResizable = false
isModal = true
controlsVisible = false
titleBarHeight = UIManager.getInt("TabbedPane.tabHeight") * 0.8f
textField.placeholderText = placeholderText
textField.text = text
textField.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if (e.keyCode == KeyEvent.VK_ENTER) {
if (textField.text.isBlank()) {
return
}
doOKAction()
}
}
})
init()
}
override fun createCenterPanel(): JComponent {
textField.background = UIManager.getColor("window")
textField.border = BorderFactory.createEmptyBorder(0, 13, 0, 13)
return textField
}
fun getText(): String? {
isVisible = true
return text
}
override fun doCancelAction() {
text = null
super.doCancelAction()
}
override fun doOKAction() {
text = textField.text
super.doOKAction()
}
override fun createSouthPanel(): JComponent? {
return null
}
}

View File

@@ -9,7 +9,58 @@ import com.formdev.flatlaf.util.SystemInfo
import java.util.*
class LightLaf : FlatLightLaf(), ColorTheme {
interface LafTag
interface LightLafTag : LafTag
interface DarkLafTag : LafTag
class DraculaLaf : FlatPropertiesLaf("Dracula", Properties().apply {
putAll(
mapOf(
"@baseTheme" to "dark",
"@background" to "#282935",
"@windowText" to "#eaeaea",
)
)
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Basic.BACKGROUND -> 0x282935
TerminalColor.Basic.FOREGROUND -> 0xeaeaea
TerminalColor.Basic.SELECTION_BACKGROUND -> 0x56596b
TerminalColor.Basic.SELECTION_FOREGROUND -> 0xfeffff
TerminalColor.Basic.HYPERLINK -> 0x255ab4
TerminalColor.Cursor.BACKGROUND -> 0xc7c7c7
TerminalColor.Find.BACKGROUND -> 0xffff00
TerminalColor.Find.FOREGROUND -> 0x282935
TerminalColor.Normal.BLACK -> 0
TerminalColor.Normal.RED -> 0xef766d
TerminalColor.Normal.GREEN -> 0x88f397
TerminalColor.Normal.YELLOW -> 0xf4f8a7
TerminalColor.Normal.BLUE -> 0xc4a9f4
TerminalColor.Normal.MAGENTA -> 0xf297cd
TerminalColor.Normal.CYAN -> 0xaceafb
TerminalColor.Normal.WHITE -> 0xc7c7c7
TerminalColor.Bright.BLACK -> 0x676767
TerminalColor.Bright.RED -> 0xef766d
TerminalColor.Bright.GREEN -> 0x88f397
TerminalColor.Bright.YELLOW -> 0xf4f8a7
TerminalColor.Bright.BLUE -> 0xc4a9f4
TerminalColor.Bright.MAGENTA -> 0xf297cd
TerminalColor.Bright.CYAN -> 0xaceafb
TerminalColor.Bright.WHITE -> 0xfeffff
else -> Int.MAX_VALUE
}
}
}
class LightLaf : FlatLightLaf(), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Normal.BLACK -> 0
@@ -36,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
@@ -65,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) {
@@ -113,7 +164,7 @@ class TermiusLightLaf : FlatPropertiesLaf("Termius Light", Properties().apply {
"@windowText" to "#32364a",
)
)
}), ColorTheme {
}), ColorTheme, LightLafTag {
override fun getColor(color: TerminalColor): Int {
@@ -156,14 +207,14 @@ class TermiusDarkLaf : FlatPropertiesLaf("Termius Dark", Properties().apply {
"@windowText" to "#21b568",
)
)
}), ColorTheme {
}), ColorTheme, DarkLafTag {
override fun getColor(color: TerminalColor): Int {
return when (color) {
TerminalColor.Basic.SELECTION_BACKGROUND,
TerminalColor.Cursor.BACKGROUND -> 0x21b568
TerminalColor.Basic.SELECTION_FOREGROUND ->0
TerminalColor.Basic.SELECTION_FOREGROUND -> 0
TerminalColor.Basic.FOREGROUND -> 0x21b568
@@ -198,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
@@ -237,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
@@ -275,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
@@ -313,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
@@ -350,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
@@ -387,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
@@ -424,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
@@ -461,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
@@ -498,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
@@ -535,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
@@ -572,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
@@ -609,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
@@ -646,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
@@ -683,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
@@ -722,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
@@ -761,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
@@ -800,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
@@ -840,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
@@ -879,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
@@ -919,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

@@ -1,14 +1,25 @@
package app.termora
import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate
import app.termora.terminal.PtyProcessConnector
import org.apache.commons.io.Charsets
import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import kotlin.jvm.optionals.getOrNull
class LocalTerminalTab(host: Host) : PtyHostTerminalTab(host) {
class LocalTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
companion object {
private val log = LoggerFactory.getLogger(LocalTerminalTab::class.java)
}
override suspend fun openPtyConnector(): PtyConnector {
val winSize = terminalPanel.winSize()
val ptyConnector = PtyConnectorFactory.instance.createPtyConnector(
val ptyConnector = PtyConnectorFactory.getInstance().createPtyConnector(
winSize.rows, winSize.cols,
host.options.envs(),
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
@@ -17,4 +28,42 @@ class LocalTerminalTab(host: Host) : PtyHostTerminalTab(host) {
return ptyConnector
}
override fun willBeClose(): Boolean {
val ptyProcessConnector = getPtyProcessConnector() ?: return true
val process = ptyProcessConnector.process
var consoleProcessCount = 0
try {
val processHandle = ProcessHandle.of(process.pid()).getOrNull()
if (processHandle != null) {
consoleProcessCount = processHandle.children().count().toInt()
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
// 没有正在运行的进程
if (consoleProcessCount < 1) return true
val owner = SwingUtilities.getWindowAncestor(terminalPanel) ?: return true
return OptionPane.showConfirmDialog(
owner,
I18n.getString("termora.tabbed.local-tab.close-prompt"),
messageType = JOptionPane.INFORMATION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}
private fun getPtyProcessConnector(): PtyProcessConnector? {
var p = getPtyConnector() as PtyConnector?
while (p != null) {
if (p is PtyProcessConnector) return p
if (p is PtyConnectorDelegate) p = p.ptyConnector
}
return null
}
}

View File

@@ -1,109 +0,0 @@
package app.termora
import com.formdev.flatlaf.FlatClientProperties
import com.jetbrains.JBR
import com.jetbrains.WindowDecorations.CustomTitleBar
import java.awt.Rectangle
import java.awt.Window
import javax.swing.RootPaneContainer
class LogicCustomTitleBar(private val titleBar: CustomTitleBar) : CustomTitleBar {
companion object {
fun createCustomTitleBar(rootPaneContainer: RootPaneContainer): CustomTitleBar {
if (!JBR.isWindowDecorationsSupported()) {
return LogicCustomTitleBar(object : CustomTitleBar {
override fun getHeight(): Float {
val bounds = rootPaneContainer.rootPane
.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
if (bounds is Rectangle) {
return bounds.height.toFloat()
}
return 0f
}
override fun setHeight(height: Float) {
rootPaneContainer.rootPane.putClientProperty(
FlatClientProperties.TITLE_BAR_HEIGHT,
height.toInt()
)
}
override fun getProperties(): MutableMap<String, Any> {
return mutableMapOf()
}
override fun putProperties(m: MutableMap<String, *>?) {
}
override fun putProperty(key: String?, value: Any?) {
if (key == "controls.visible" && value is Boolean) {
rootPaneContainer.rootPane.putClientProperty(
FlatClientProperties.TITLE_BAR_SHOW_CLOSE,
value
)
}
}
override fun getLeftInset(): Float {
return 0f
}
override fun getRightInset(): Float {
val bounds = rootPaneContainer.rootPane
.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
if (bounds is Rectangle) {
return bounds.width.toFloat()
}
return 0f
}
override fun forceHitTest(client: Boolean) {
}
override fun getContainingWindow(): Window {
return rootPaneContainer as Window
}
})
}
return JBR.getWindowDecorations().createCustomTitleBar()
}
}
override fun getHeight(): Float {
return titleBar.height
}
override fun setHeight(height: Float) {
titleBar.height = height
}
override fun getProperties(): MutableMap<String, Any> {
return titleBar.properties
}
override fun putProperties(m: MutableMap<String, *>?) {
titleBar.putProperties(m)
}
override fun putProperty(key: String?, value: Any?) {
titleBar.putProperty(key, value)
}
override fun getLeftInset(): Float {
return titleBar.leftInset
}
override fun getRightInset(): Float {
return titleBar.rightInset
}
override fun forceHitTest(client: Boolean) {
titleBar.forceHitTest(client)
}
override fun getContainingWindow(): Window {
return titleBar.containingWindow
}
}

View File

@@ -1,6 +1,6 @@
package app.termora
fun main() {
ApplicationRunner().run()
ApplicationInitializr().run()
}

View File

@@ -1,45 +0,0 @@
package app.termora
import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate
import org.jdesktop.swingx.action.ActionManager
/**
* 当开启转发时,会获取到所有的 [PtyConnector] 然后跳过中间层,直接找到最近的一个 [MultiplePtyConnector],如果找不到那就以最后一个匹配不到的为准 [getMultiplePtyConnector]。
*/
class MultiplePtyConnector(private val myConnector: PtyConnector) : PtyConnectorDelegate(myConnector) {
private val isMultiple get() = ActionManager.getInstance().isSelected(Actions.MULTIPLE)
private val ptyConnectors get() = PtyConnectorFactory.instance.getPtyConnectors()
override fun write(buffer: ByteArray, offset: Int, len: Int) {
if (isMultiple) {
for (connector in ptyConnectors) {
getMultiplePtyConnector(connector).write(buffer, offset, len)
}
} else {
myConnector.write(buffer, offset, len)
}
}
private fun getMultiplePtyConnector(connector: PtyConnector): PtyConnector {
if (connector is MultiplePtyConnector) {
val c = connector.myConnector
if (c is MultiplePtyConnector) {
return getMultiplePtyConnector(c)
}
return c
}
if (connector is PtyConnectorDelegate) {
val c = connector.ptyConnector
if (c != null) {
return getMultiplePtyConnector(c)
}
}
return connector
}
}

View File

@@ -1,14 +1,20 @@
package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.actions.MultipleAction
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 org.apache.commons.lang3.StringUtils
import java.awt.Color
import java.awt.Graphics
import java.util.*
class MultipleTerminalListener : TerminalPaintListener {
override fun after(
@@ -19,9 +25,9 @@ class MultipleTerminalListener : TerminalPaintListener {
terminalDisplay: TerminalDisplay,
terminal: Terminal
) {
if (!ActionManager.getInstance().isSelected(Actions.MULTIPLE)) {
return
}
val windowScope = AnActionEvent(terminalPanel, StringUtils.EMPTY, EventObject(terminalPanel))
.getData(DataProviders.WindowScope) ?: return
if (!MultipleAction.getInstance(windowScope).isSelected) return
val oldFont = g.font
val colorPalette = terminal.getTerminalModel().getColorPalette()
@@ -31,13 +37,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,270 @@
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 dragMouseAdaptor = DragMouseAdaptor()
private val terminalTabbedManager
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
.getData(DataProviders.TerminalTabbedManager)
private val owner
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
.getData(DataProviders.TermoraFrame) as TermoraFrame
init {
isFocusable = false
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(owner, window)
window.location = releasedPoint
window.isVisible = true
} else if (c != owner && c is TermoraFrame) { // 如果在某个窗口内释放,那么就移动到某个窗口内
dragToAnotherWindow(owner, 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(oldFrame: TermoraFrame, 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

@@ -0,0 +1,41 @@
package app.termora
import com.formdev.flatlaf.util.SystemInfo
import de.jangassen.jfa.foundation.Foundation
import de.jangassen.jfa.foundation.Foundation.NSAutoreleasePool
import java.text.Collator
import java.util.*
class NativeStringComparator private constructor() : Comparator<String> {
private val collator by lazy { Collator.getInstance(Locale.getDefault()).apply { strength = Collator.PRIMARY } }
companion object {
fun getInstance(): NativeStringComparator {
return ApplicationScope.forApplicationScope()
.getOrCreate(NativeStringComparator::class) { NativeStringComparator() }
}
private const val SORT_DIGITSASNUMBERS: Int = 0x00000008
}
override fun compare(o1: String, o2: String): Int {
if (SystemInfo.isWindows) {
// CompareStringEx returns 1, 2, 3 respectively instead of -1, 0, 1
return MyKernel32.INSTANCE.CompareStringEx(SORT_DIGITSASNUMBERS, o1, o2) - 2
} else if (SystemInfo.isMacOS) {
val pool = NSAutoreleasePool()
try {
val a = Foundation.nsString(o1)
val b = Foundation.nsString(o2)
return Foundation.invoke(a, "localizedStandardCompare:", b).toInt()
} finally {
pool.drain()
}
}
return collator.compare(o1, o2)
}
}

View File

@@ -0,0 +1,924 @@
package app.termora
import app.termora.Application.ohMyJson
import app.termora.actions.OpenHostAction
import app.termora.sftp.SFTPActionEvent
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVParser
import org.apache.commons.csv.CSVPrinter
import org.apache.commons.io.FileUtils
import org.apache.commons.io.FilenameUtils
import org.apache.commons.io.filefilter.FileFilterUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.client.config.hosts.HostConfigEntry
import org.ini4j.Ini
import org.ini4j.Reg
import org.jdesktop.swingx.action.ActionManager
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
import org.slf4j.LoggerFactory
import org.w3c.dom.Element
import org.w3c.dom.NodeList
import java.awt.Component
import java.awt.event.*
import java.io.*
import java.util.*
import java.util.function.Function
import javax.swing.*
import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener
import javax.swing.filechooser.FileNameExtensionFilter
import javax.swing.tree.TreePath
import javax.swing.tree.TreeSelectionModel
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory
@Suppress("CascadeIf")
class NewHostTree : SimpleTree() {
companion object {
private val log = LoggerFactory.getLogger(NewHostTree::class.java)
private val CSV_HEADERS = arrayOf("Folders", "Label", "Hostname", "Port", "Username", "Protocol")
}
private val hostManager get() = HostManager.getInstance()
private val properties get() = Database.getDatabase().properties
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
private val sftpAction get() = ActionManager.getInstance().getAction(app.termora.Actions.SFTP)
private var isShowMoreInfo
get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
set(value) = properties.putString("HostTree.showMoreInfo", value.toString())
private var isPopupMenu = false
override val model = NewHostTreeModel()
/**
* 是否允许显示右键菜单
*/
var contextmenu = true
/**
* 是否允许双击连接
*/
var doubleClickConnection = true
init {
initViews()
initEvents()
}
private fun initViews() {
super.setModel(model)
isEditable = true
dragEnabled = true
isRootVisible = true
dropMode = DropMode.ON_OR_INSERT
selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
// renderer
setCellRenderer(object : DefaultXTreeCellRenderer() {
override fun getTreeCellRendererComponent(
tree: JTree,
value: Any,
sel: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean
): Component {
val node = value as HostTreeNode
val host = node.host
var text = host.name
// 是否显示更多信息
if (isShowMoreInfo) {
val color = if (sel) {
if (tree.hasFocus() || isPopupMenu) {
UIManager.getColor("textHighlightText")
} else {
this.foreground
}
} else {
UIManager.getColor("textInactiveText")
}
val fontTag = Function<String, String> {
"""<font color=rgb(${color.red},${color.green},${color.blue})>${it}</font>"""
}
// @formatter:off
if (host.protocol == Protocol.SSH || host.protocol == Protocol.RDP) {
text = "<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;${fontTag.apply("${host.username}@${host.host}")}</html>"
} else if (host.protocol == Protocol.Serial) {
text = "<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;${fontTag.apply(host.options.serialComm.port)}</html>"
} else if (host.protocol == Protocol.Folder) {
text = "<html>${host.name}${fontTag.apply(" (${node.getAllChildren().size})")}</html>"
}
// @formatter:on
}
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
icon = node.getIcon(sel, expanded, tree.hasFocus() || isPopupMenu)
return c
}
})
}
private fun initEvents() {
// double click
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val lastNode = lastSelectedPathComponent as? HostTreeNode ?: return
if (lastNode.host.protocol != Protocol.Folder) {
val path = tree.getClosestPathForLocation(e.x, e.y) ?: return
val bounds = tree.getRowBounds(tree.getRowForPath(path)) ?: return
if ((e.y >= bounds.y && e.y < (bounds.y + bounds.height)).not()) return
openHostAction?.actionPerformed(OpenHostActionEvent(e.source, lastNode.host, e))
}
}
}
})
addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if (e.keyCode == KeyEvent.VK_ENTER && doubleClickConnection) {
val nodes = getSelectionSimpleTreeNodes()
if (nodes.size == 1 && nodes.first().host.protocol == Protocol.Folder) {
val path = TreePath(model.getPathToRoot(nodes.first()))
if (isExpanded(path)) {
collapsePath(path)
} else {
expandPath(path)
}
} else {
for (node in getSelectionSimpleTreeNodes(true)) {
openHostAction?.actionPerformed(OpenHostActionEvent(e.source, node.host, e))
}
}
}
}
})
}
override fun showContextmenu(evt: MouseEvent) {
if (!contextmenu) return
val lastNode = lastSelectedPathComponent
if (lastNode !is HostTreeNode) return
val nodes = getSelectionSimpleTreeNodes()
val fullNodes = getSelectionSimpleTreeNodes(true)
val lastNodeParent = lastNode.parent ?: model.root
val lastHost = lastNode.host
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 importMenu = JMenu(I18n.getString("termora.welcome.contextmenu.import"))
val csvMenu = importMenu.add("CSV")
val xShellMenu = importMenu.add("Xshell")
val puTTYMenu = importMenu.add("PuTTY")
val electermMenu = importMenu.add("electerm")
val finalShellMenu = importMenu.add("FinalShell")
val windTermMenu = importMenu.add("WindTerm")
val secureCRTMenu = importMenu.add("SecureCRT")
val sshMenu = importMenu.add(".ssh/config")
val mobaXtermMenu = importMenu.add("MobaXterm")
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect"))
val openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu
val openWithSFTP = openWith.add("SFTP")
val openWithSFTPCommand = openWith.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
val openInNewWindow = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open-in-new-window"))
popupMenu.addSeparator()
val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy"))
val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove"))
val rename = popupMenu.add(I18n.getString("termora.welcome.contextmenu.rename"))
popupMenu.addSeparator()
val refresh = popupMenu.add(I18n.getString("termora.welcome.contextmenu.refresh"))
val expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all"))
val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all"))
popupMenu.addSeparator()
popupMenu.add(importMenu)
popupMenu.add(newMenu)
popupMenu.addSeparator()
val showMoreInfo = JCheckBoxMenuItem(I18n.getString("termora.welcome.contextmenu.show-more-info"))
showMoreInfo.isSelected = isShowMoreInfo
showMoreInfo.addActionListener {
isShowMoreInfo = !isShowMoreInfo
SwingUtilities.updateComponentTreeUI(tree)
}
popupMenu.add(showMoreInfo)
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
xShellMenu.addActionListener { importHosts(lastNode, ImportType.Xshell) }
puTTYMenu.addActionListener { importHosts(lastNode, ImportType.PuTTY) }
secureCRTMenu.addActionListener { importHosts(lastNode, ImportType.SecureCRT) }
electermMenu.addActionListener { importHosts(lastNode, ImportType.electerm) }
mobaXtermMenu.addActionListener { importHosts(lastNode, ImportType.MobaXterm) }
sshMenu.addActionListener { importHosts(lastNode, ImportType.SSH) }
finalShellMenu.addActionListener { importHosts(lastNode, ImportType.FinalShell) }
csvMenu.addActionListener { importHosts(lastNode, ImportType.CSV) }
windTermMenu.addActionListener { importHosts(lastNode, ImportType.WindTerm) }
open.addActionListener { openHosts(it, false) }
openInNewWindow.addActionListener { openHosts(it, true) }
openWithSFTP.addActionListener { openWithSFTP(it) }
openWithSFTPCommand.addActionListener { openWithSFTPCommand(it) }
newFolder.addActionListener {
val host = Host(
id = UUID.randomUUID().toSimpleString(),
protocol = Protocol.Folder,
name = I18n.getString("termora.welcome.contextmenu.new.folder.name"),
sort = System.currentTimeMillis(),
parentId = lastHost.id
)
hostManager.addHost(host)
val newNode = HostTreeNode(host)
model.insertNodeInto(newNode, lastNode, lastNode.folderCount)
selectionPath = TreePath(model.getPathToRoot(newNode))
startEditingAtPath(selectionPath)
}
remove.addActionListener(object : ActionListener {
override fun actionPerformed(e: ActionEvent) {
if (nodes.isEmpty()) return
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(tree),
I18n.getString("termora.keymgr.delete-warning"),
I18n.getString("termora.remove"),
JOptionPane.YES_NO_OPTION,
JOptionPane.QUESTION_MESSAGE
) == JOptionPane.YES_OPTION
) {
for (c in nodes) {
hostManager.addHost(c.host.copy(deleted = true, updateDate = System.currentTimeMillis()))
model.removeNodeFromParent(c)
// 将所有子孙也删除
for (child in c.getAllChildren()) {
hostManager.addHost(
child.host.copy(
deleted = true,
updateDate = System.currentTimeMillis()
)
)
}
}
}
}
})
copy.addActionListener {
for (c in nodes) {
val p = c.parent ?: continue
val newNode = copyNode(c, p.host.id)
model.insertNodeInto(newNode, p, lastNodeParent.getIndex(c) + 1)
selectionPath = TreePath(model.getPathToRoot(newNode))
}
}
rename.addActionListener { startEditingAtPath(TreePath(model.getPathToRoot(lastNode))) }
expandAll.addActionListener {
for (node in fullNodes) {
expandPath(TreePath(model.getPathToRoot(node)))
}
}
colspanAll.addActionListener {
for (node in fullNodes.reversed()) {
collapsePath(TreePath(model.getPathToRoot(node)))
}
}
newHost.addActionListener(object : ActionListener {
override fun actionPerformed(e: ActionEvent) {
val dialog = HostDialog(owner)
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
val host = (dialog.host ?: return).copy(parentId = lastHost.id)
hostManager.addHost(host)
val newNode = HostTreeNode(host)
model.insertNodeInto(newNode, lastNode, lastNode.childCount)
selectionPath = TreePath(model.getPathToRoot(newNode))
}
})
property.addActionListener(object : ActionListener {
override fun actionPerformed(e: ActionEvent) {
val dialog = HostDialog(owner, lastHost)
dialog.title = lastHost.name
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
val host = dialog.host ?: return
lastNode.host = host
hostManager.addHost(host)
model.nodeStructureChanged(lastNode)
}
})
refresh.addActionListener { refreshNode(lastNode) }
newMenu.isEnabled = lastHost.protocol == Protocol.Folder
remove.isEnabled = getSelectionSimpleTreeNodes().none { it == model.root }
copy.isEnabled = remove.isEnabled
rename.isEnabled = remove.isEnabled
property.isEnabled = lastHost.protocol != Protocol.Folder
refresh.isEnabled = lastHost.protocol == Protocol.Folder
importMenu.isEnabled = lastHost.protocol == Protocol.Folder
// 如果选中了 SSH 服务器,那么才启用
openWithSFTP.isEnabled = fullNodes.map { it.host }.any { it.protocol == Protocol.SSH }
openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled
openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled }
popupMenu.addPopupMenuListener(object : PopupMenuListener {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
isPopupMenu = true
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
isPopupMenu = false
}
override fun popupMenuCanceled(e: PopupMenuEvent?) {
}
})
popupMenu.show(this, evt.x, evt.y)
}
override fun onRenamed(node: SimpleTreeNode<*>, text: String) {
val lastNode = node as? HostTreeNode ?: return
lastNode.host = lastNode.host.copy(name = text, updateDate = System.currentTimeMillis())
model.nodeStructureChanged(lastNode)
hostManager.addHost(lastNode.host)
}
override fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>) {
val nNode = node as? HostTreeNode ?: return
val nParent = parent as? HostTreeNode ?: return
nNode.data = nNode.data.copy(parentId = nParent.id, updateDate = System.currentTimeMillis())
hostManager.addHost(nNode.host)
}
private fun copyNode(
node: HostTreeNode,
parentId: String,
idGenerator: () -> String = { UUID.randomUUID().toSimpleString() },
level: Int = 0
): HostTreeNode {
val host = node.host
val now = host.sort + 1
val name = if (level == 0) "${host.name} ${I18n.getString("termora.welcome.contextmenu.copy")}"
else host.name
val newHost = host.copy(
id = idGenerator.invoke(),
name = name,
parentId = parentId,
updateDate = System.currentTimeMillis(),
createDate = System.currentTimeMillis(),
sort = now
)
val newNode = HostTreeNode(newHost)
hostManager.addHost(newHost)
if (host.protocol == Protocol.Folder) {
for (child in node.children()) {
if (child is HostTreeNode) {
newNode.add(copyNode(child, newHost.id, idGenerator, level + 1))
}
}
}
return newNode
}
override fun getSelectionSimpleTreeNodes(include: Boolean): List<HostTreeNode> {
return super.getSelectionSimpleTreeNodes(include).filterIsInstance<HostTreeNode>()
}
private fun openHosts(evt: EventObject, openInNewWindow: Boolean) {
assertEventDispatchThread()
val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol != Protocol.Folder }
if (nodes.isEmpty()) 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 = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return
for (node in nodes) {
sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt))
}
}
private fun openWithSFTPCommand(evt: EventObject) {
val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return
for (host in nodes) {
openHostAction.actionPerformed(OpenHostActionEvent(this, host.copy(protocol = Protocol.SFTPPty), evt))
}
}
private fun importHosts(folder: HostTreeNode, type: ImportType) {
try {
doImportHosts(folder, type)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
OptionPane.showMessageDialog(owner, ExceptionUtils.getMessage(e), messageType = JOptionPane.ERROR_MESSAGE)
}
}
private fun doImportHosts(folder: HostTreeNode, type: ImportType) {
val chooser = JFileChooser()
chooser.fileSelectionMode = JFileChooser.FILES_ONLY
chooser.isAcceptAllFileFilterUsed = false
chooser.isMultiSelectionEnabled = false
when (type) {
ImportType.WindTerm -> chooser.fileFilter = FileNameExtensionFilter("WindTerm (*.sessions)", "sessions")
ImportType.SSH -> chooser.fileFilter = FileNameExtensionFilter("SSH (config)", "config")
ImportType.CSV -> chooser.fileFilter = FileNameExtensionFilter("CSV (*.csv)", "csv")
ImportType.SecureCRT -> chooser.fileFilter = FileNameExtensionFilter("SecureCRT (*.xml)", "xml")
ImportType.electerm -> chooser.fileFilter = FileNameExtensionFilter("electerm (*.json)", "json")
ImportType.PuTTY -> chooser.fileFilter = FileNameExtensionFilter("PuTTY (*.reg)", "reg")
ImportType.MobaXterm -> chooser.fileFilter =
FileNameExtensionFilter("MobaXterm (*.mobaconf,*.ini)", "ini", "mobaconf")
ImportType.Xshell -> {
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.dialogTitle = "Xshell Sessions"
chooser.isAcceptAllFileFilterUsed = true
}
ImportType.FinalShell -> {
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.isAcceptAllFileFilterUsed = true
}
}
val dir = properties.getString("NewHostTree.ImportHosts.defaultDir", StringUtils.EMPTY)
if (dir.isNotBlank()) {
val file = FileUtils.getFile(dir)
if (file.exists()) {
chooser.currentDirectory = file
}
}
// csv template
if (type == ImportType.CSV) {
val code = OptionPane.showConfirmDialog(
owner,
I18n.getString("termora.welcome.contextmenu.import.csv.download-template"),
optionType = JOptionPane.YES_NO_OPTION,
messageType = JOptionPane.QUESTION_MESSAGE,
options = arrayOf(
I18n.getString("termora.welcome.contextmenu.import"),
I18n.getString("termora.welcome.contextmenu.download")
),
initialValue = I18n.getString("termora.welcome.contextmenu.import")
)
if (code == JOptionPane.DEFAULT_OPTION) {
return
} else if (code != JOptionPane.YES_OPTION) {
chooser.setSelectedFile(File("termora_import.csv"))
if (chooser.showSaveDialog(owner) == JFileChooser.APPROVE_OPTION) {
CSVPrinter(
FileWriter(chooser.selectedFile, Charsets.UTF_8),
CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()
).use { printer ->
printer.printRecord("Projects/Dev", "Web Server", "192.168.1.1", "22", "root", "SSH")
printer.printRecord("Projects/Prod", "Web Server", "serverhost.com", "2222", "root", "SSH")
printer.printRecord(StringUtils.EMPTY, "Web Server", "serverhost.com", "2222", "user", "SSH")
}
OptionPane.openFileInFolder(
owner,
chooser.selectedFile,
I18n.getString("termora.welcome.contextmenu.import.csv.download-template-done-open-folder"),
I18n.getString("termora.welcome.contextmenu.import.csv.download-template-done")
)
}
return
}
}
// 选择文件
if (type != ImportType.SSH) {
val code = chooser.showOpenDialog(owner)
if (code != JFileChooser.APPROVE_OPTION) {
return
}
}
val file = chooser.selectedFile
if (file != null && file.parentFile != null) {
properties.putString(
"NewHostTree.ImportHosts.defaultDir",
(if (FileUtils.isDirectory(file)) file else file.parentFile).absolutePath
)
}
val nodes = when (type) {
ImportType.SSH -> parseFromSSH(folder)
ImportType.WindTerm -> parseFromWindTerm(folder, file)
ImportType.SecureCRT -> parseFromSecureCRT(folder, file)
ImportType.MobaXterm -> parseFromMobaXterm(folder, file)
ImportType.PuTTY -> parseFromPuTTY(folder, file)
ImportType.Xshell -> parseFromXshell(folder, file)
ImportType.FinalShell -> parseFromFinalShell(folder, file)
ImportType.electerm -> parseFromElecterm(folder, file)
ImportType.CSV -> file.bufferedReader().use { parseFromCSV(folder, it) }
}
if (nodes.isEmpty()) return
for (node in nodes) {
node.host = node.host.copy(parentId = folder.host.id, updateDate = System.currentTimeMillis())
if (folder.getIndex(node) != -1) {
continue
}
model.insertNodeInto(
node,
folder,
if (node.host.protocol == Protocol.Folder) folder.folderCount else folder.childCount
)
}
for (node in nodes) {
hostManager.addHost(node.host)
node.getAllChildren().forEach { hostManager.addHost(it.host) }
}
// 重新加载
model.reload(folder)
// expand root
expandPath(TreePath(model.getPathToRoot(folder)))
}
private fun parseFromWindTerm(folder: HostTreeNode, file: File): List<HostTreeNode> {
val sessions = ohMyJson.runCatching { ohMyJson.parseToJsonElement(file.readText()).jsonArray }
.onFailure { OptionPane.showMessageDialog(owner, ExceptionUtils.getMessage(it)) }
.getOrNull() ?: return emptyList()
val sw = StringWriter()
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
for (i in 0 until sessions.size) {
val json = sessions[i].jsonObject
val protocol = json["session.protocol"]?.jsonPrimitive?.content ?: "SSH"
if (!StringUtils.equalsIgnoreCase("SSH", protocol)) continue
val label = json["session.label"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val target = json["session.target"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val port = json["session.port"]?.jsonPrimitive?.intOrNull ?: 22
val group = json["session.group"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val groups = group.split(">")
printer.printRecord(groups.joinToString("/"), label, target, port, StringUtils.EMPTY, "SSH")
}
}
return parseFromCSV(folder, StringReader(sw.toString()))
}
private fun parseFromSSH(folder: HostTreeNode): List<HostTreeNode> {
val entries = HostConfigEntry.readHostConfigEntries(HostConfigEntry.getDefaultHostConfigFile())
val sw = StringWriter()
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
for (entry in entries) {
printer.printRecord(
StringUtils.EMPTY,
StringUtils.defaultString(entry.host),
StringUtils.defaultString(entry.hostName),
if (entry.port == 0) 22 else entry.port,
StringUtils.defaultString(entry.username),
"SSH"
)
}
}
return parseFromCSV(folder, StringReader(sw.toString()))
}
private fun parseFromSecureCRT(folder: HostTreeNode, file: File): List<HostTreeNode> {
val xPath = XPathFactory.newInstance().newXPath()
val db = DocumentBuilderFactory.newInstance().newDocumentBuilder()
val doc = db.parse(file)
val sessionElement = xPath.compile("/VanDyke/key[@name='Sessions']")
.evaluate(doc, XPathConstants.NODE) as Element? ?: return emptyList()
val nodeList = xPath.compile(".//key[not(key)]").evaluate(sessionElement, XPathConstants.NODESET) as NodeList
if (nodeList.length == 0) return emptyList()
val sw = StringWriter()
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
for (i in 0 until nodeList.length) {
val ele = nodeList.item(i) as Element
val protocol = xPath.compile("./string[@name='Protocol Name']/text()").evaluate(ele)
if (!StringUtils.equalsIgnoreCase(protocol, "SSH2")) continue
val label = ele.getAttribute("name")
if (StringUtils.isBlank(label)) continue
val hostname = xPath.compile("./string[@name='Hostname']/text()").evaluate(ele)
if (StringUtils.isBlank(hostname)) continue
val username = xPath.compile("./string[@name='Username']/text()").evaluate(ele)
val port = xPath.compile("./dword[@name='[SSH2] Port']/text()").evaluate(ele)?.toIntOrNull() ?: 22
val folders = mutableListOf<String>()
var p = ele.parentNode as Element
while (p != sessionElement) {
folders.addFirst(p.getAttribute("name"))
p = p.parentNode as Element
}
printer.printRecord(folders.joinToString("/"), label, hostname, port.toString(), username, "SSH")
}
}
return parseFromCSV(folder, StringReader(sw.toString()))
}
private fun parseFromPuTTY(folder: HostTreeNode, file: File): List<HostTreeNode> {
val reg = Reg(file)
val prefix = "HKEY_CURRENT_USER\\Software\\SimonTatham\\PuTTY\\Sessions\\"
val sw = StringWriter()
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
for (key in reg.keys) {
if (!key.startsWith(prefix)) {
continue
}
val properties = reg[key]?.toProperties() ?: continue
val label = StringUtils.removeStart(key, prefix)
val hostname = properties.getProperty("HostName")
val username = properties.getProperty("UserName")
val port = properties.getProperty("PortNumber")
printer.printRecord(StringUtils.EMPTY, label, hostname, port.toString(), username, "SSH")
}
}
return parseFromCSV(folder, StringReader(sw.toString()))
}
private fun parseFromMobaXterm(folder: HostTreeNode, file: File): List<HostTreeNode> {
val ini = Ini()
ini.config.isEscapeKeyOnly = true
ini.load(file)
val bookmarks = mutableListOf<String>()
for (key in ini.keys) {
if (key.startsWith("Bookmarks")) {
bookmarks.add(key)
}
}
val sw = StringWriter()
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
for (bookmark in bookmarks) {
val properties = (ini[bookmark] ?: continue).toProperties()
// 删除不必要元素
properties.remove("ImgNum")
val folders = FilenameUtils.separatorsToUnix(
(properties.remove("SubRep")
?: StringUtils.EMPTY).toString()
)
for (key in properties.stringPropertyNames()) {
val segments = properties.getProperty(key).split("%")
if (segments.isEmpty()) continue
// ssh: #109#0
// telnet: #98#1
if (segments.first() != "#109#0") continue
val hostname = segments.getOrNull(1) ?: StringUtils.EMPTY
val port = segments.getOrNull(2) ?: 22
printer.printRecord(folders, key, hostname, port, StringUtils.EMPTY, "SSH")
}
}
}
return parseFromCSV(folder, StringReader(sw.toString()))
}
private fun parseFromXshell(folder: HostTreeNode, dir: File): List<HostTreeNode> {
val files = FileUtils.listFiles(dir, arrayOf("xsh"), true)
if (files.isEmpty()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.welcome.contextmenu.import.xshell-folder-empty")
)
return emptyList()
}
val sw = StringWriter()
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
for (file in files) {
val ini = Ini(file)
val protocol = ini.get("CONNECTION", "Protocol") ?: "SSH"
if (!StringUtils.equalsIgnoreCase("SSH", protocol)) continue
val folders = FilenameUtils.separatorsToUnix(file.parentFile.relativeTo(dir).toString())
val hostname = ini.get("CONNECTION", "Host") ?: StringUtils.EMPTY
val label = file.nameWithoutExtension
val port = ini.get("CONNECTION", "Port")?.toIntOrNull() ?: 22
val username = ini.get("CONNECTION:AUTHENTICATION", "UserName") ?: StringUtils.EMPTY
printer.printRecord(folders, label, hostname, port, username, "SSH")
}
}
return parseFromCSV(folder, StringReader(sw.toString()))
}
private fun parseFromFinalShell(folder: HostTreeNode, dir: File): List<HostTreeNode> {
val files = FileUtils.listFiles(
dir,
FileFilterUtils.suffixFileFilter("_connect_config.json"),
FileFilterUtils.trueFileFilter()
)
if (files.isEmpty()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.welcome.contextmenu.import.finalshell-folder-empty")
)
return emptyList()
}
val sw = StringWriter()
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
for (file in files) {
try {
val json = ohMyJson.runCatching { ohMyJson.parseToJsonElement(file.readText()) }
.getOrNull()?.jsonObject ?: continue
val username = json["user_name"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val label = json["name"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val host = json["host"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val port = json["port"]?.jsonPrimitive?.intOrNull ?: 22
if (StringUtils.isAllBlank(host, label)) continue
val folders = FilenameUtils.separatorsToUnix(file.parentFile.relativeTo(dir).toString())
printer.printRecord(folders, StringUtils.defaultIfBlank(label, host), host, port, username, "SSH")
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(file.absolutePath, e)
}
}
}
}
return parseFromCSV(folder, StringReader(sw.toString()))
}
@Serializable
private data class ElectermGroup(
val id: String = StringUtils.EMPTY,
val title: String = StringUtils.EMPTY,
val bookmarkIds: Set<String> = emptySet(),
val bookmarkGroupIds: Set<String> = emptySet(),
)
private fun parseFromElecterm(folder: HostTreeNode, file: File): List<HostTreeNode> {
val json = ohMyJson.parseToJsonElement(file.readText()).jsonObject
val bookmarks = json["bookmarks"]?.jsonArray ?: return emptyList()
val bookmarkGroups = ohMyJson.decodeFromJsonElement<List<ElectermGroup>>(
json["bookmarkGroups"]?.jsonArray ?: JsonArray(emptyList())
)
val sw = StringWriter()
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
for (i in 0 until bookmarks.size) {
val host = bookmarks[i].jsonObject
val type = host["type"]?.jsonPrimitive?.content ?: "SSH"
if (!StringUtils.equalsIgnoreCase(type, "SSH")) continue
val hostname = host["host"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val id = host["id"]?.jsonPrimitive?.content ?: continue
val title = host["title"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
if (StringUtils.isAllBlank(title, hostname)) continue
val username = host["username"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val port = host["port"]?.jsonPrimitive?.intOrNull ?: 22
val folderNames = mutableListOf<String>()
var group = bookmarkGroups.find { it.bookmarkIds.contains(id) }
while (group != null && group.id != "default") {
folderNames.addFirst(group.title)
group = bookmarkGroups.find { it.bookmarkGroupIds.contains(group?.id ?: StringUtils.EMPTY) }
}
printer.printRecord(
folderNames.joinToString("/"),
StringUtils.defaultIfBlank(title, hostname),
hostname,
port,
username,
"SSH"
)
}
}
return parseFromCSV(folder, StringReader(sw.toString()))
}
private fun parseFromCSV(folderNode: HostTreeNode, sr: Reader): List<HostTreeNode> {
val records = CSVParser.builder()
.setFormat(CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).setSkipHeaderRecord(true).get())
.setCharset(Charsets.UTF_8)
.setReader(sr)
.get()
.use { it.records }
// 把现有目录提取出来,避免重复创建
val nodes = folderNode.clone(setOf(Protocol.Folder))
.childrenNode().filter { it.host.protocol == Protocol.Folder }
.toMutableList()
for (record in records) {
val map = mutableMapOf<String, String>()
for (e in record.parser.headerMap.keys) {
map[e] = record.get(e)
}
val folder = map["Folders"] ?: StringUtils.EMPTY
val label = map["Label"] ?: StringUtils.EMPTY
val hostname = map["Hostname"] ?: StringUtils.EMPTY
val port = map["Port"]?.toIntOrNull() ?: 22
val username = map["Username"] ?: StringUtils.EMPTY
val protocol = map["Protocol"] ?: "SSH"
// 仅支持 SSH、RDP 协议
if (StringUtils.equalsAnyIgnoreCase(protocol, "SSH", "RDP").not()) continue
if (StringUtils.isAllBlank(hostname, label)) continue
var p: HostTreeNode? = null
if (folder.isNotBlank()) {
for ((j, name) in folder.split("/").withIndex()) {
val folders = if (j == 0 || p == null) nodes
else p.children().toList().filterIsInstance<HostTreeNode>()
val n = HostTreeNode(
Host(
name = name, protocol = Protocol.Folder,
parentId = p?.host?.id ?: StringUtils.EMPTY
)
)
val cp = folders.find { it.host.protocol == Protocol.Folder && it.host.name == name }
if (cp != null) {
p = cp
continue
}
if (p == null) {
p = n
nodes.add(n)
} else {
p.add(n)
p = n
}
}
}
val n = HostTreeNode(
Host(
name = StringUtils.defaultIfBlank(label, hostname),
host = hostname,
port = port,
username = username,
protocol = runCatching { Protocol.valueOf(protocol) }.getOrNull() ?: Protocol.SSH,
parentId = p?.host?.id ?: StringUtils.EMPTY,
)
)
if (p == null) {
nodes.add(n)
} else {
p.add(n)
}
}
return nodes
}
private enum class ImportType {
WindTerm,
CSV,
Xshell,
PuTTY,
SecureCRT,
MobaXterm,
SSH,
FinalShell,
electerm,
}
}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package app.termora
import java.util.*
interface NotifyListener : EventListener {
fun addNotify()
}

View File

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

View File

@@ -1,12 +1,15 @@
package app.termora
import app.termora.native.osx.NativeMacLibrary
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextPane
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import org.jdesktop.swingx.JXLabel
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Desktop
@@ -19,6 +22,8 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
object OptionPane {
private val coroutineScope = swingCoroutineScope
fun showConfirmDialog(
parentComponent: Component?,
message: Any,
@@ -28,6 +33,7 @@ object OptionPane {
icon: Icon? = null,
options: Array<Any>? = null,
initialValue: Any? = null,
customizeDialog: (JDialog) -> Unit = {},
): Int {
val panel = if (message is JComponent) {
@@ -46,6 +52,9 @@ object OptionPane {
override fun selectInitialValue() {
super.selectInitialValue()
if (message is JComponent) {
if (message.getClientProperty("SKIP_requestFocusInWindow") == true) {
return
}
message.requestFocusInWindow()
}
}
@@ -56,6 +65,8 @@ object OptionPane {
pane.selectInitialValue()
}
})
dialog.setLocationRelativeTo(parentComponent)
customizeDialog.invoke(dialog)
dialog.isVisible = true
dialog.dispose()
val selectedValue = pane.value
@@ -97,9 +108,8 @@ object OptionPane {
val dialog = initDialog(pane.createDialog(parentComponent, title))
if (duration.inWholeMilliseconds > 0) {
dialog.addWindowListener(object : WindowAdapter() {
@OptIn(DelicateCoroutinesApi::class)
override fun windowOpened(e: WindowEvent) {
GlobalScope.launch(Dispatchers.Swing) {
coroutineScope.launch(Dispatchers.Swing) {
delay(duration.inWholeMilliseconds)
if (dialog.isVisible) {
dialog.isVisible = false
@@ -113,6 +123,36 @@ object OptionPane {
dialog.dispose()
}
fun showInputDialog(
parentComponent: Component?,
title: String = UIManager.getString("OptionPane.messageDialogTitle"),
value: String = StringUtils.EMPTY,
placeholder: String = StringUtils.EMPTY,
): String? {
val pane = JOptionPane(StringUtils.EMPTY, JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION)
val dialog = initDialog(pane.createDialog(parentComponent, title))
pane.wantsInput = true
pane.initialSelectionValue = value
val textField = SwingUtils.getDescendantsOfType(JTextField::class.java, pane, true).firstOrNull()
if (textField?.name == "OptionPane.textField") {
textField.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(0, 0, 2, 0)
)
textField.background = UIManager.getColor("window")
textField.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, placeholder)
}
dialog.isVisible = true
dialog.dispose()
val inputValue = pane.inputValue
if (inputValue == JOptionPane.UNINITIALIZED_VALUE) return null
return inputValue as? String
}
fun openFileInFolder(
parentComponent: Component,
file: File,
@@ -122,7 +162,7 @@ object OptionPane {
if (Desktop.isDesktopSupported() && Desktop.getDesktop()
.isSupported(Desktop.Action.BROWSE_FILE_DIR)
) {
if (JOptionPane.YES_OPTION == showConfirmDialog(
if (yMessage.isEmpty() || JOptionPane.YES_OPTION == showConfirmDialog(
parentComponent,
yMessage,
optionType = JOptionPane.YES_NO_OPTION
@@ -140,14 +180,31 @@ object OptionPane {
}
private fun initDialog(dialog: JDialog): JDialog {
if (SystemInfo.isWindows || SystemInfo.isLinux) {
dialog.rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_CLOSE, false)
dialog.rootPane.putClientProperty(
FlatClientProperties.TITLE_BAR_HEIGHT,
UIManager.getInt("TabbedPane.tabHeight")
)
} else if (SystemInfo.isMacOS) {
dialog.rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
dialog.rootPane.putClientProperty("apple.awt.fullWindowContent", true)
dialog.rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
dialog.rootPane.putClientProperty(
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING,
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING_MEDIUM
)
if (JBR.isWindowDecorationsSupported()) {
val windowDecorations = JBR.getWindowDecorations()
val titleBar = windowDecorations.createCustomTitleBar()
titleBar.putProperty("controls.visible", false)
titleBar.height = UIManager.getInt("TabbedPane.tabHeight") - if (SystemInfo.isMacOS) 10f else 6f
windowDecorations.setCustomTitleBar(dialog, titleBar)
val height = UIManager.getInt("TabbedPane.tabHeight") - 10
if (JBR.isWindowDecorationsSupported()) {
val customTitleBar = JBR.getWindowDecorations().createCustomTitleBar()
customTitleBar.putProperty("controls.visible", false)
customTitleBar.height = height.toFloat()
JBR.getWindowDecorations().setCustomTitleBar(dialog, customTitleBar)
} else {
NativeMacLibrary.setControlsVisible(dialog, false)
}
val label = JLabel(dialog.title)
label.putClientProperty(FlatClientProperties.STYLE, "font: bold")
@@ -155,11 +212,9 @@ object OptionPane {
box.add(Box.createHorizontalGlue())
box.add(label)
box.add(Box.createHorizontalGlue())
box.preferredSize = Dimension(-1, titleBar.height.toInt())
box.preferredSize = Dimension(-1, height)
dialog.contentPane.add(box, BorderLayout.NORTH)
}
return dialog
}
}

View File

@@ -17,6 +17,7 @@ open class OptionsPane : JPanel(BorderLayout()) {
}
private val cardLayout = CardLayout()
private val contentPanel = JPanel(cardLayout)
private val loadedComponents = mutableMapOf<String, JComponent>()
init {
initView()
@@ -103,16 +104,15 @@ open class OptionsPane : JPanel(BorderLayout()) {
throw UnsupportedOperationException("Title already exists")
}
}
contentPanel.add(option.getJComponent(), option.getTitle())
tabListModel.addElement(option)
if (tabList.selectedIndex < 0) {
tabList.selectedIndex = 0
}
}
fun removeOption(option: Option) {
contentPanel.remove(option.getJComponent())
val title = option.getTitle()
loadedComponents[title]?.let {
contentPanel.remove(it)
loadedComponents.remove(title)
}
tabListModel.removeElement(option)
}
@@ -123,7 +123,17 @@ open class OptionsPane : JPanel(BorderLayout()) {
private fun initEvents() {
tabList.addListSelectionListener {
if (tabList.selectedIndex >= 0) {
cardLayout.show(contentPanel, tabListModel.get(tabList.selectedIndex).getTitle())
val option = tabListModel.get(tabList.selectedIndex)
val title = option.getTitle()
if (!loadedComponents.containsKey(title)) {
val component = option.getJComponent()
loadedComponents[title] = component
contentPanel.add(component, title)
SwingUtilities.updateComponentTreeUI(component)
}
cardLayout.show(contentPanel, title)
}
}
}

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,81 @@
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(): PtyConnectorFactory {
return ApplicationScope.forApplicationScope()
.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 = commands.toTypedArray(),
rows = rows,
cols = cols,
env = env,
charset = charset
)
}
fun createPtyConnector(
commands: Array<String>,
rows: Int = 24, cols: Int = 80,
env: Map<String, String> = emptyMap(),
directory: String = SystemUtils.USER_HOME,
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 ptyProcess = PtyProcessBuilder(arrayOf(command))
if (SystemUtils.IS_OS_UNIX) {
if (!envs.containsKey("LANG")) {
val locale = Locale.getDefault()
if (StringUtils.isNoneBlank(locale.language, locale.country)) {
envs["LANG"] = "${locale.language}_${locale.country}.${Charset.defaultCharset().name()}"
} else {
envs["LANG"] = "en_US.UTF-8"
}
}
}
if (log.isDebugEnabled) {
log.debug("command: {} , envs: {}", commands.joinToString(" "), envs)
}
val ptyProcess = PtyProcessBuilder(commands)
.setEnvironment(envs)
.setInitialRows(rows)
.setInitialColumns(cols)
.setConsole(false)
.setDirectory(SystemUtils.USER_HOME)
.setDirectory(StringUtils.defaultIfBlank(directory, SystemUtils.USER_HOME))
.setCygwin(false)
.setUseWinConPty(SystemUtils.IS_OS_WINDOWS)
.setRedirectErrorStream(false)
@@ -47,20 +87,14 @@ class PtyConnectorFactory {
}
fun decorate(ptyConnector: PtyConnector): PtyConnector {
// 集成转发如果PtyConnector支持转发那么应该在当前注释行前面代理
val multiplePtyConnector = MultiplePtyConnector(ptyConnector)
// 宏应该在转发前面执行,不然会导致重复录制
val macroPtyConnector = MacroPtyConnector(multiplePtyConnector)
//
val macroPtyConnector = MacroPtyConnector(ptyConnector)
// 集成自动删除
val autoRemovePtyConnector = AutoRemovePtyConnector(macroPtyConnector)
ptyConnectors.add(autoRemovePtyConnector)
return autoRemovePtyConnector
}
fun getPtyConnectors(): List<PtyConnector> {
return ptyConnectors
}
private inner class AutoRemovePtyConnector(connector: PtyConnector) : PtyConnectorDelegate(connector) {
override fun close() {
ptyConnectors.remove(this)

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,9 +1,7 @@
package app.termora
import app.termora.terminal.ControlCharacters
import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate
import app.termora.terminal.TerminalKeyEvent
import app.termora.actions.DataProviders
import app.termora.terminal.*
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.exception.ExceptionUtils
@@ -12,7 +10,12 @@ import java.awt.event.KeyEvent
import javax.swing.JComponent
import kotlin.time.Duration.Companion.milliseconds
abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
abstract class PtyHostTerminalTab(
windowScope: WindowScope,
host: Host,
terminal: Terminal = TerminalFactory.getInstance().createTerminal()
) : HostTerminalTab(windowScope, host, terminal) {
companion object {
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
}
@@ -20,9 +23,8 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
private var readerJob: Job? = null
private val ptyConnectorDelegate = PtyConnectorDelegate()
protected val terminalPanel = TerminalPanelFactory.instance.createTerminalPanel(terminal, ptyConnectorDelegate)
protected val ptyConnectorFactory get() = PtyConnectorFactory.instance
protected val terminalPanel = TerminalPanelFactory.getInstance().createTerminalPanel(terminal, ptyConnectorDelegate)
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance()
override fun start() {
coroutineScope.launch(Dispatchers.IO) {
@@ -42,12 +44,16 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
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)
)
}
}
}
@@ -60,6 +66,10 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
// 失败关闭
stop()
withContext(Dispatchers.Swing) {
terminal.write("\r\n${ControlCharacters.ESC}[31m")
terminal.write(ExceptionUtils.getRootCauseMessage(e))
@@ -105,9 +115,9 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
override fun dispose() {
stop()
Disposer.dispose(terminalPanel)
super.dispose()
if (log.isInfoEnabled) {
log.info("Host: {} disposed", host.name)
}
@@ -118,4 +128,14 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
}
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?
} else if (dataKey == DataProviders.TerminalWriter) {
return terminalPanel.getData(DataKey.TerminalWriter) as T?
}
return super.getData(dataKey)
}
}

View File

@@ -0,0 +1,16 @@
package app.termora
import java.awt.Component
import java.awt.KeyboardFocusManager
abstract class RememberFocusTerminalTab : TerminalTab {
private var lastFocusedComponent: Component? = null
override fun onLostFocus() {
lastFocusedComponent = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
}
override fun onGrabFocus() {
lastFocusedComponent?.requestFocusInWindow()
}
}

View File

@@ -0,0 +1,165 @@
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, host: Host) : DialogWrapper(owner) {
private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
private val rememberCheckBox = JCheckBox(I18n.getString("termora.new-host.general.remember"))
private val passwordPanel = JPanel(BorderLayout())
private val passwordPasswordField = OutlinePasswordField()
private val usernameTextField = OutlineTextField()
private val publicKeyComboBox = OutlineComboBox<OhKeyPair>()
private val keyManager get() = KeyManager.getInstance()
private var authentication = Authentication.No
init {
isModal = true
title = "SSH User Authentication"
controlsVisible = false
init()
pack()
size = Dimension(max(380, size.width), size.height)
preferredSize = size
minimumSize = size
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()
}
}
if (host.authentication.type != AuthenticationType.No) {
authenticationTypeComboBox.selectedItem = host.authentication.type
}
usernameTextField.text = host.username
}
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, $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.username")}:").xy(1, 3)
.add(usernameTextField).xy(3, 3)
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 5)
.add(passwordPanel).xy(3, 5)
.build()
}
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 {
if (usernameTextField.text.isBlank()) {
usernameTextField.requestFocusInWindow()
} else {
passwordPasswordField.requestFocusInWindow()
}
}
isVisible = true
return authentication
}
fun isRemembered(): Boolean {
return rememberCheckBox.isSelected
}
fun getUsername(): String {
return usernameTextField.text
}
}

View File

@@ -0,0 +1,213 @@
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.commons.lang3.SystemUtils
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
private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand
private val defaultDirectory get() = Database.getDatabase().sftp.defaultDirectory
private val owner get() = SwingUtilities.getWindowAncestor(terminalPanel)
init {
terminalPanel.dropFiles = true
}
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(StringUtils.defaultIfBlank(sftpCommand, "sftp"))
var host = this.host
// 如果配置了跳板机或者代理,那么通过 SSH 的端口转发到本地
if (useJumpHosts) {
host = host.copy(
updateDate = System.currentTimeMillis(),
tunnelings = listOf(
Tunneling(
type = TunnelingType.Local,
sourceHost = SshdSocketAddress.LOCALHOST_NAME,
destinationHost = SshdSocketAddress.LOCALHOST_NAME,
destinationPort = host.port,
)
)
)
val sshClient = SshClients.openClient(host, owner).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,
updateDate = System.currentTimeMillis(),
)
}
}
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 if (host.options.sftpDefaultDirectory.isNotBlank()) {
commands.add("${host.username}@${host.host}:${host.options.sftpDefaultDirectory.trim()}")
} else {
commands.add("${host.username}@${host.host}")
}
val directory = FileUtils.getFile(StringUtils.defaultIfBlank(defaultDirectory, SystemUtils.USER_HOME))
val winSize = terminalPanel.winSize()
val ptyConnector = ptyConnectorFactory.createPtyConnector(
commands = commands.toTypedArray(),
rows = winSize.rows, cols = winSize.cols,
env = host.options.envs(),
charset = Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
directory = if (directory.exists()) directory.absolutePath else SystemUtils.USER_HOME
)
return ptyConnector
}
private fun setAuthentication(commands: MutableList<String>, host: Host) {
// 如果通过公钥连接
if (host.authentication.type == AuthenticationType.PublicKey) {
val ohKeyPair = keyManager.getOhKeyPair(host.authentication.password)
if (ohKeyPair != null) {
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(ohKeyPair)
val privateKeyPath = Application.createSubTemporaryDir()
val privateKeyFile = Files.createTempFile(privateKeyPath, Application.getName(), StringUtils.EMPTY)
Files.newOutputStream(privateKeyFile)
.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,53 +0,0 @@
package app.termora
import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
class SFTPTerminalTab : Disposable, TerminalTab {
private val transportPanel by lazy {
TransportPanel().apply {
Disposer.register(this@SFTPTerminalTab, this)
}
}
override fun getTitle(): String {
return "SFTP"
}
override fun getIcon(): Icon {
return Icons.fileTransfer
}
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
}
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
}
override fun getJComponent(): JComponent {
return transportPanel
}
override fun canClose(): Boolean {
assertEventDispatchThread()
if (transportPanel.transportManager.getTransports().isEmpty()) {
return true
}
return OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(getJComponent()),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}
}

View File

@@ -1,6 +1,11 @@
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.keymap.KeyShortcut
import app.termora.keymap.KeymapManager
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector
@@ -20,25 +25,34 @@ 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)
val SSHSession = DataKey(ClientSession::class)
private val log = LoggerFactory.getLogger(SSHTerminalTab::class.java)
}
private val mutex = Mutex()
private val tab = this
private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null
private var sshChannelShell: ChannelShell? = null
private val terminalTabbedManager
get() = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
.getData(DataProviders.TerminalTabbedManager)
init {
terminalPanel.dropFiles = false
terminalPanel.dataProviderSupport.addData(DataProviders.TerminalTab, this)
}
override fun getJComponent(): JComponent {
@@ -75,7 +89,8 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
terminal.write("SSH client is opening...\r\n")
}
val client = SshClients.openClient(host).also { sshClient = it }
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
val client = SshClients.openClient(host, owner).also { sshClient = it }
val sessionListener = MySessionListener()
val channelListener = MyChannelListener()
@@ -104,12 +119,32 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
channel.addChannelListener(object : ChannelListener {
private val reconnectShortcut
get() = KeymapManager.getInstance().getActiveKeymap()
.getShortcut(TabReconnectAction.RECONNECT_TAB).firstOrNull()
override fun channelClosed(channel: Channel, reason: Throwable?) {
coroutineScope.launch(Dispatchers.Swing) {
terminal.write("\r\n${ControlCharacters.ESC}[31m")
terminal.write("Channel has been disconnected.\r\n")
terminal.write("\r\n\r\n${ControlCharacters.ESC}[31m")
terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
if (reconnectShortcut is KeyShortcut) {
terminal.write(
I18n.getString(
"termora.terminal.channel-reconnect",
reconnectShortcut.toString()
)
)
}
terminal.write("\r\n")
terminal.write("${ControlCharacters.ESC}[0m")
terminalModel.setData(DataKey.ShowCursor, false)
if (Database.getDatabase().terminal.autoCloseTabWhenDisconnected) {
terminalTabbedManager?.let { manager ->
SwingUtilities.invokeLater {
manager.closeTerminalTab(tab, true)
}
}
}
}
}
})
@@ -143,35 +178,30 @@ 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
)
)
try {
SshClients.openTunneling(session, host, tunneling)
withContext(Dispatchers.Swing) {
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error("Start [${tunneling.name}] port forwarding failed: {}", e.message, e)
}
withContext(Dispatchers.Swing) {
terminal.write("Start [${tunneling.name}] port forwarding failed: ${e.message}\r\n")
}
}
if (log.isInfoEnabled) {
log.info("SSH [{}] started {} port forwarding.", host.name, tunneling.name)
}
withContext(Dispatchers.Swing) {
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
}
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == SSHSession) {
return sshSession as T?
}
return super.getData(dataKey)
}
override fun stop() {
if (mutex.tryLock()) {
@@ -193,6 +223,10 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
}
}
override fun beforeClose() {
// 保存窗口状态
terminalPanel.storeVisualWindows(host.id)
}
private inner class MySessionListener : SessionListener, Disposable {
override fun sessionEvent(session: Session, event: Event) {

View File

@@ -0,0 +1,182 @@
package app.termora
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.swing.Swing
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
val swingCoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Swing)
@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> {
if (scopes.isEmpty()) return emptyList()
return scopes.values.toList()
}
override fun dispose() {
if (log.isInfoEnabled) {
log.info("ApplicationScope disposed")
}
swingCoroutineScope.cancel()
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

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

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,11 +1,8 @@
package app.termora
import app.termora.db.Database
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.JPanel
@@ -13,7 +10,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"))
@@ -21,8 +18,10 @@ class SettingsDialog(owner: Window) : DialogWrapper(owner) {
title = I18n.getString("termora.setting")
setLocationRelativeTo(null)
init()
val index = properties.getString("Settings-SelectedOption")?.toIntOrNull() ?: 0
optionsPane.setSelectedIndex(index)
init()
initEvents()
}
@@ -32,14 +31,6 @@ class SettingsDialog(owner: Window) : DialogWrapper(owner) {
properties.putString("Settings-SelectedOption", optionsPane.getSelectedIndex().toString())
}
})
addWindowListener(object : WindowAdapter() {
override fun windowActivated(e: WindowEvent) {
removeWindowListener(this)
val index = properties.getString("Settings-SelectedOption")?.toIntOrNull() ?: return
optionsPane.setSelectedIndex(index)
}
})
}
override fun createCenterPanel(): JComponent {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
package app.termora
import org.jdesktop.swingx.JXTree
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
import java.awt.Component
import java.awt.Dimension
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.*
import javax.swing.*
import javax.swing.event.CellEditorListener
import javax.swing.event.ChangeEvent
import javax.swing.tree.TreePath
import kotlin.math.min
open class SimpleTree : JXTree() {
protected open val model get() = super.getModel() as SimpleTreeModel<*>
private val editor = OutlineTextField(64)
protected val tree get() = this
init {
initViews()
initEvents()
}
private fun initViews() {
// renderer
setCellRenderer(object : DefaultXTreeCellRenderer() {
override fun getTreeCellRendererComponent(
tree: JTree,
value: Any,
sel: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean
): Component {
val node = value as SimpleTreeNode<*>
val c = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus)
icon = node.getIcon(sel, expanded, tree.hasFocus())
return c
}
})
// rename
setCellEditor(object : DefaultCellEditor(editor) {
override fun isCellEditable(e: EventObject?): Boolean {
if (e is MouseEvent || !tree.isCellEditable(e)) {
return false
}
return super.isCellEditable(e).apply {
if (this) {
editor.preferredSize = Dimension(min(220, width - 64), 0)
}
}
}
override fun getCellEditorValue(): Any? {
return getLastSelectedPathNode()?.data
}
})
}
private fun initEvents() {
// 右键选中
addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
if (!SwingUtilities.isRightMouseButton(e)) {
return
}
requestFocusInWindow()
val selectionRows = selectionModel.selectionRows
val selRow = getClosestRowForLocation(e.x, e.y)
if (selRow < 0) {
selectionModel.clearSelection()
return
} else if (selectionRows != null && selectionRows.contains(selRow)) {
return
}
selectionPath = getPathForLocation(e.x, e.y)
setSelectionRow(selRow)
}
})
// contextmenu
addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
if (!(SwingUtilities.isRightMouseButton(e))) {
return
}
if (Objects.isNull(lastSelectedPathComponent)) {
return
}
showContextmenu(e)
}
})
// rename
getCellEditor().addCellEditorListener(object : CellEditorListener {
override fun editingStopped(e: ChangeEvent) {
val node = getLastSelectedPathNode() ?: return
if (editor.text.isBlank() || editor.text == node.toString()) {
return
}
onRenamed(node, editor.text)
}
override fun editingCanceled(e: ChangeEvent) {
}
})
// drag
transferHandler = object : TransferHandler() {
override fun createTransferable(c: JComponent): Transferable? {
val nodes = getSelectionSimpleTreeNodes().toMutableList()
if (nodes.isEmpty()) return null
if (nodes.contains(model.root)) return null
val iterator = nodes.iterator()
while (iterator.hasNext()) {
val node = iterator.next()
val parents = model.getPathToRoot(node).filter { it != node }
if (parents.any { nodes.contains(it) }) {
iterator.remove()
}
}
return MoveNodeTransferable(nodes)
}
override fun getSourceActions(c: JComponent?): Int {
return MOVE
}
override fun canImport(support: TransferSupport): Boolean {
if (support.component != tree) return false
val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false
val path = dropLocation.path ?: return false
val node = path.lastPathComponent as? SimpleTreeNode<*> ?: return false
if (!support.isDataFlavorSupported(MoveNodeTransferable.dataFlavor)) return false
val nodes = (support.transferable.getTransferData(MoveNodeTransferable.dataFlavor) as? List<*>)
?.filterIsInstance<SimpleTreeNode<*>>() ?: return false
if (nodes.isEmpty()) return false
if (!node.isFolder) return false
for (e in nodes) {
// 禁止拖拽到自己的子下面
if (path.equals(TreePath(e.path)) || TreePath(e.path).isDescendant(path)) {
return false
}
// 文件夹只能拖拽到文件夹的下面
if (e.isFolder) {
if (dropLocation.childIndex > node.folderCount) {
return false
}
} else if (dropLocation.childIndex != -1) {
// 非文件夹也不能拖拽到文件夹的上面
if (dropLocation.childIndex < node.folderCount) {
return false
}
}
val p = e.parent ?: continue
// 如果是同级目录排序,那么判断是不是自己的上下,如果是的话也禁止
if (p == node && dropLocation.childIndex != -1) {
val idx = p.getIndex(e)
if (dropLocation.childIndex in idx..idx + 1) {
return false
}
}
}
support.setShowDropLocation(true)
return true
}
override fun importData(support: TransferSupport): Boolean {
if (!support.isDrop) return false
val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false
val node = dropLocation.path.lastPathComponent as? SimpleTreeNode<*> ?: return false
val nodes = (support.transferable.getTransferData(MoveNodeTransferable.dataFlavor) as? List<*>)
?.filterIsInstance<SimpleTreeNode<*>>() ?: return false
// 展开的 node
val expanded = mutableSetOf(node.id)
for (e in nodes) {
e.getAllChildren().filter { isExpanded(TreePath(model.getPathToRoot(it))) }
.map { it }.forEach { expanded.add(it.id) }
}
// 转移
for (e in nodes) {
model.removeNodeFromParent(e)
rebase(e, node)
if (dropLocation.childIndex == -1) {
if (e.isFolder) {
model.insertNodeInto(e, node, node.folderCount)
} else {
model.insertNodeInto(e, node, node.childCount)
}
} else {
if (e.isFolder) {
model.insertNodeInto(e, node, min(node.folderCount, dropLocation.childIndex))
} else {
model.insertNodeInto(e, node, min(node.childCount, dropLocation.childIndex))
}
}
selectionPath = TreePath(model.getPathToRoot(e))
}
// 先展开最顶级的
expandPath(TreePath(model.getPathToRoot(node)))
for (child in node.getAllChildren()) {
if (expanded.contains(child.id)) {
expandPath(TreePath(model.getPathToRoot(child)))
}
}
return true
}
}
}
protected open fun newFolder(newNode: SimpleTreeNode<*>): Boolean {
val lastNode = lastSelectedPathComponent
if (lastNode !is SimpleTreeNode<*>) return false
return newNode(newNode, lastNode.folderCount)
}
protected open fun newFile(newNode: SimpleTreeNode<*>): Boolean {
val lastNode = lastSelectedPathComponent
if (lastNode !is SimpleTreeNode<*>) return false
return newNode(newNode, lastNode.childCount)
}
private fun newNode(newNode: SimpleTreeNode<*>, index: Int): Boolean {
val lastNode = lastSelectedPathComponent
if (lastNode !is SimpleTreeNode<*>) return false
model.insertNodeInto(newNode, lastNode, index)
selectionPath = TreePath(model.getPathToRoot(newNode))
startEditingAtPath(selectionPath)
return true
}
open fun getLastSelectedPathNode(): SimpleTreeNode<*>? {
return lastSelectedPathComponent as? SimpleTreeNode<*>
}
protected open fun showContextmenu(evt: MouseEvent) {
}
protected open fun onRenamed(node: SimpleTreeNode<*>, text: String) {}
open fun refreshNode(node: SimpleTreeNode<*> = model.root) {
val state = TreeUtils.saveExpansionState(tree)
val rows = selectionRows
model.reload(node)
TreeUtils.loadExpansionState(tree, state)
super.setSelectionRows(rows)
}
/**
* 包含孙子
*/
open fun getSelectionSimpleTreeNodes(include: Boolean = false): List<SimpleTreeNode<*>> {
val paths = selectionPaths ?: return emptyList()
if (paths.isEmpty()) return emptyList()
val nodes = mutableListOf<SimpleTreeNode<*>>()
val parents = paths.mapNotNull { it.lastPathComponent }
.filterIsInstance<SimpleTreeNode<*>>().toMutableList()
if (include) {
while (parents.isNotEmpty()) {
val node = parents.removeFirst()
nodes.add(node)
parents.addAll(node.children().toList().filterIsInstance<SimpleTreeNode<*>>())
}
}
return if (include) nodes else parents
}
protected open fun isCellEditable(e: EventObject?): Boolean {
return getLastSelectedPathNode() != model.root
}
protected open fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>) {
}
private class MoveNodeTransferable(val nodes: List<SimpleTreeNode<*>>) : Transferable {
companion object {
val dataFlavor =
DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveNodeTransferable::class.java.name}")
}
override fun getTransferDataFlavors(): Array<DataFlavor> {
return arrayOf(dataFlavor)
}
override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean {
return dataFlavor == flavor
}
override fun getTransferData(flavor: DataFlavor?): Any {
if (flavor == dataFlavor) {
return nodes
}
throw UnsupportedFlavorException(flavor)
}
}
}

View File

@@ -0,0 +1,11 @@
package app.termora
import javax.swing.tree.DefaultTreeModel
abstract class SimpleTreeModel<T>(root: SimpleTreeNode<T>) : DefaultTreeModel(root) {
@Suppress("UNCHECKED_CAST")
override fun getRoot(): SimpleTreeNode<T> {
return super.getRoot() as SimpleTreeNode<T>
}
}

View File

@@ -0,0 +1,37 @@
package app.termora
import javax.swing.Icon
import javax.swing.tree.DefaultMutableTreeNode
abstract class SimpleTreeNode<T>(data: T) : DefaultMutableTreeNode(data) {
@Suppress("UNCHECKED_CAST")
open var data: T
get() = userObject as T
set(value) = setUserObject(value)
@Suppress("UNCHECKED_CAST")
override fun getParent(): SimpleTreeNode<T>? {
return super.getParent() as SimpleTreeNode<T>?
}
open val folderCount: Int get() = 0
open fun getIcon(selected: Boolean, expanded: Boolean, hasFocus: Boolean): Icon? {
return null
}
open val isFolder get() = false
abstract val id: String
@Suppress("UNCHECKED_CAST")
open fun getAllChildren(): List<SimpleTreeNode<T>> {
val children = mutableListOf<SimpleTreeNode<T>>()
for (child in children()) {
val c = child as? SimpleTreeNode<T> ?: continue
children.add(c)
children.addAll(c.getAllChildren())
}
return children
}
}

View File

@@ -1,29 +1,95 @@
package app.termora
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize
import app.termora.x11.X11ChannelFactory
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.util.FontUtils
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
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.auth.password.UserAuthPasswordFactory
import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.channel.ClientChannelEvent
import org.apache.sshd.client.config.hosts.HostConfigEntry
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
import org.apache.sshd.client.config.hosts.KnownHostEntry
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.ClientProxyConnector
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.client.session.ClientSessionImpl
import org.apache.sshd.client.session.SessionFactory
import org.apache.sshd.common.AttributeRepository
import org.apache.sshd.common.SshConstants
import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.ChannelFactory
import org.apache.sshd.common.channel.PtyChannelConfiguration
import org.apache.sshd.common.channel.PtyChannelConfigurationHolder
import org.apache.sshd.common.cipher.CipherNone
import org.apache.sshd.common.compression.BuiltinCompressions
import org.apache.sshd.common.config.keys.KeyRandomArt
import org.apache.sshd.common.config.keys.KeyUtils
import org.apache.sshd.common.future.CloseFuture
import org.apache.sshd.common.future.SshFutureListener
import org.apache.sshd.common.global.KeepAliveHandler
import org.apache.sshd.common.io.IoConnectFuture
import org.apache.sshd.common.io.IoConnector
import org.apache.sshd.common.io.IoServiceEventListener
import org.apache.sshd.common.io.IoSession
import org.apache.sshd.common.kex.BuiltinDHFactories
import org.apache.sshd.common.keyprovider.KeyIdentityProvider
import org.apache.sshd.common.session.Session
import org.apache.sshd.common.session.SessionListener
import org.apache.sshd.common.signature.BuiltinSignatures
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.internal.transport.sshd.agent.JGitSshAgentFactory
import org.eclipse.jgit.internal.transport.sshd.agent.connector.PageantConnector
import org.eclipse.jgit.internal.transport.sshd.agent.connector.UnixDomainSocketConnector
import org.eclipse.jgit.internal.transport.sshd.proxy.AbstractClientProxyConnector
import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector
import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector
import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.SshConstants.IDENTITY_AGENT
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
import org.eclipse.jgit.transport.sshd.ProxyData
import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory
import org.slf4j.LoggerFactory
import java.awt.Font
import java.awt.Window
import java.io.ByteArrayOutputStream
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.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import javax.swing.*
import kotlin.math.max
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
@Suppress("CascadeIf")
object SshClients {
val HOST_KEY = AttributeRepository.AttributeKey<Host>()
private val timeout = Duration.ofSeconds(30)
private val hostManager get() = HostManager.getInstance()
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
/**
* 打开一个 Shell
@@ -45,6 +111,12 @@ object SshClients {
env.putAll(host.options.envs())
val channel = session.createShellChannel(configuration, env)
if (host.options.enableX11Forwarding) {
if (channel is app.termora.x11.ChannelShell) {
channel.xForwarding = true
}
}
if (!channel.open().verify(timeout).await()) {
throw SshException("Failed to open Shell")
}
@@ -53,32 +125,246 @@ object SshClients {
}
/**
* 执行一个命令
*
* @return first: exitCode , second: response
*/
fun execChannel(
session: ClientSession,
command: String
): Pair<Int, String> {
val baos = ByteArrayOutputStream()
val channel = session.createExecChannel(command)
channel.out = baos
if (channel.open().verify(timeout).await(timeout)) {
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout)
}
IOUtils.closeQuietly(channel)
if (channel.exitStatus == null) {
return Pair(-1, baos.toString())
}
return Pair(channel.exitStatus, baos.toString())
}
/**
* 打开一个会话
*/
fun openSession(host: Host, client: SshClient): ClientSession {
val session = client.connect(host.username, host.host, host.port)
.verify(timeout).session
val h = hostManager.getHost(host.id) ?: host
// 如果没有跳板机直接连接
if (h.options.jumpHosts.isEmpty()) {
return doOpenSession(h, client)
}
val jumpHosts = mutableListOf<Host>()
val hosts = HostManager.getInstance().hosts().associateBy { it.id }
for (jumpHostId in h.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(h)
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, updateDate = System.currentTimeMillis())
}
}
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())
entry.setProperty("Host", host.id)
// 设置代理
// configureProxy(entry, host, client)
// ssh-agent
if (host.authentication.type == AuthenticationType.SSHAgent) {
if (host.authentication.password.isNotBlank())
entry.setProperty(IDENTITY_AGENT, host.authentication.password)
else if (SystemInfo.isWindows)
entry.setProperty(IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent)
else
entry.setProperty(IDENTITY_AGENT, UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
}
val session = client.connect(entry).verify(timeout).session
if (host.authentication.type == AuthenticationType.Password) {
session.addPasswordIdentity(host.authentication.password)
} else if (host.authentication.type == AuthenticationType.PublicKey) {
session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password)
}
if (!session.auth().verify(timeout).await(timeout)) {
throw SshException("Authentication failed")
if (host.options.enableX11Forwarding) {
val segments = host.options.x11Forwarding.split(":")
if (segments.size == 2) {
val x11Host = segments[0]
val x11Port = segments[1].toIntOrNull()
if (x11Port != null) {
CoreModuleProperties.X11_BIND_HOST.set(session, x11Host)
CoreModuleProperties.X11_BASE_PORT.set(session, 6000 + x11Port)
}
}
}
try {
if (!session.auth().verify(timeout).await(timeout)) {
throw SshException("Authentication failed")
}
} catch (e: Exception) {
if (e !is SshException || e.disconnectCode != SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e
val owner = client.properties["owner"] as Window? ?: throw e
val askUserInfo = ask(host, entry, owner) ?: throw e
if (askUserInfo.authentication.type == AuthenticationType.No) throw e
return doOpenSession(
host.copy(
authentication = askUserInfo.authentication,
username = askUserInfo.username
), client
)
}
session.setAttribute(HOST_KEY, host)
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
}
fun openClient(host: Host, owner: Window): SshClient {
val h = hostManager.getHost(host.id) ?: host
val client = openClient(h)
client.userInteraction = TerminalUserInteraction(owner)
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
client.properties["owner"] = owner
return client
}
/**
* 打开一个客户端
*/
fun openClient(host: Host): SshClient {
val builder = ClientBuilder.builder()
builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE))
.factory { JGitSshClient() }
.factory { MyJGitSshClient() }
if (host.tunnelings.isEmpty()) {
val keyExchangeFactories = ClientBuilder.setUpDefaultKeyExchanges(true).toMutableList()
// https://github.com/TermoraDev/termora/issues/123
@Suppress("DEPRECATION")
keyExchangeFactories.addAll(
listOf(
DHGClient.newFactory(BuiltinDHFactories.dhg1),
DHGClient.newFactory(BuiltinDHFactories.dhg14),
DHGClient.newFactory(BuiltinDHFactories.dhgex),
)
)
builder.keyExchangeFactories(keyExchangeFactories)
val compressionFactories = ClientBuilder.setUpDefaultCompressionFactories(true).toMutableList()
for (compression in listOf(
BuiltinCompressions.none,
BuiltinCompressions.zlib,
BuiltinCompressions.delayedZlib
)) {
if (compressionFactories.contains(compression)) continue
compressionFactories.add(compression)
}
builder.compressionFactories(compressionFactories)
val signatureFactories = ClientBuilder.setUpDefaultSignatureFactories(true).toMutableList()
for (signature in BuiltinSignatures.entries) {
if (signatureFactories.contains(signature)) continue
signatureFactories.add(signature)
}
builder.signatureFactories(signatureFactories)
if (host.tunnelings.isEmpty() && host.options.jumpHosts.isEmpty()) {
builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE)
} else {
builder.forwardingFilter(AcceptAllForwardingFilter.INSTANCE)
@@ -86,32 +372,350 @@ object SshClients {
builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY)
val channelFactories = mutableListOf<ChannelFactory>()
channelFactories.addAll(ClientBuilder.DEFAULT_CHANNEL_FACTORIES)
channelFactories.add(X11ChannelFactory.INSTANCE)
builder.channelFactories(channelFactories)
val sshClient = builder.build() as JGitSshClient
// https://github.com/TermoraDev/termora/issues/180
// JGit 会尝试读取本地的私钥或缓存的私钥
sshClient.keyIdentityProvider = KeyIdentityProvider { mutableListOf() }
// 设置优先级
if (host.authentication.type == AuthenticationType.PublicKey || host.authentication.type == AuthenticationType.SSHAgent) {
if (host.authentication.type == AuthenticationType.SSHAgent) {
// ssh-agent
sshClient.agentFactory = JGitSshAgentFactory(ConnectorFactory.getDefault(), null)
}
CoreModuleProperties.PREFERRED_AUTHS.set(
sshClient,
listOf(
UserAuthPasswordFactory.PUBLIC_KEY,
UserAuthPasswordFactory.PASSWORD,
UserAuthPasswordFactory.KB_INTERACTIVE
).joinToString(",")
)
} else {
CoreModuleProperties.PREFERRED_AUTHS.set(
sshClient,
listOf(
UserAuthPasswordFactory.PASSWORD,
UserAuthPasswordFactory.PUBLIC_KEY,
UserAuthPasswordFactory.KB_INTERACTIVE
).joinToString(",")
)
}
val heartbeatInterval = max(host.options.heartbeatInterval, 3)
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong()))
sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) }
CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true)
if (host.proxy.type != ProxyType.No) {
sshClient.setProxyDatabase {
if (host.proxy.authenticationType == AuthenticationType.No) ProxyData(
Proxy(
if (host.proxy.type == ProxyType.SOCKS5) Proxy.Type.SOCKS else Proxy.Type.HTTP,
InetSocketAddress(host.proxy.host, host.proxy.port)
)
)
else
ProxyData(
Proxy(
if (host.proxy.type == ProxyType.SOCKS5) Proxy.Type.SOCKS else Proxy.Type.HTTP,
InetSocketAddress(host.proxy.host, host.proxy.port)
),
host.proxy.username,
host.proxy.password.toCharArray(),
)
}
}
sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) }
sshClient.start()
return sshClient
}
}
private data class AskUserInfo(val username: String, val authentication: Authentication)
private fun ask(host: Host, entry: HostConfigEntry, owner: Window): AskUserInfo? {
val ref = AtomicReference<AskUserInfo>(null)
SwingUtilities.invokeAndWait {
val dialog = RequestAuthenticationDialog(owner, host)
dialog.setLocationRelativeTo(owner)
val authentication = dialog.getAuthentication()
ref.set(AskUserInfo(dialog.getUsername(), authentication))
// save
if (dialog.isRemembered()) {
// fix https://github.com/TermoraDev/termora/issues/609
val hostId = entry.getProperty("Host", host.id)
val h = hostManager.getHost(hostId)
if (h != null) {
hostManager.addHost(
h.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
}
}
return ref.get()
}
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
override fun verifyServerKey(
clientSession: ClientSession,
remoteAddress: SocketAddress,
serverKey: PublicKey
): Boolean {
return true
}
override fun acceptModifiedServerKey(
clientSession: ClientSession?,
remoteAddress: SocketAddress?,
entry: KnownHostEntry?,
expected: PublicKey?,
actual: PublicKey?
): Boolean {
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait { result.set(ask(remoteAddress, expected, actual) == JOptionPane.OK_OPTION) }
return result.get()
}
private fun ask(
remoteAddress: SocketAddress?,
expected: PublicKey?,
actual: PublicKey?
): Int {
val formMargin = "7dlu"
val layout = FormLayout(
"default:grow",
"pref, 12dlu, pref, 4dlu, pref, 2dlu, pref, $formMargin, pref, $formMargin, pref, pref, 12dlu, pref"
)
val errorColor = if (FlatLaf.isLafDark()) UIManager.getColor("Component.warning.focusedBorderColor") else
UIManager.getColor("Component.error.focusedBorderColor")
val font = FontUtils.getCompositeFont("JetBrains Mono", Font.PLAIN, 12)
val artBox = Box.createHorizontalBox()
artBox.add(Box.createHorizontalGlue())
val expectedBox = Box.createVerticalBox()
for (line in KeyRandomArt(expected).toString().lines()) {
val label = JLabel(line)
label.font = font
expectedBox.add(label)
}
artBox.add(expectedBox)
artBox.add(Box.createHorizontalGlue())
val actualBox = Box.createVerticalBox()
for (line in KeyRandomArt(actual).toString().lines()) {
val label = JLabel(line)
label.foreground = errorColor
label.font = font
actualBox.add(label)
}
artBox.add(actualBox)
artBox.add(Box.createHorizontalGlue())
var rows = 1
val step = 2
// @formatter:off
val address = remoteAddress.toString().replace("/", StringUtils.EMPTY)
val panel = FormBuilder.create().layout(layout)
.add("<html><b>${I18n.getString("termora.host.modified-server-key.title", address)}</b></html>").xy(1, rows).apply { rows += step }
.add("${I18n.getString("termora.host.modified-server-key.thumbprint")}:").xy(1, rows).apply { rows += step }
.add(" ${I18n.getString("termora.host.modified-server-key.expected")}: ${KeyUtils.getFingerPrint(expected)}").xy(1, rows).apply { rows += step }
.add("<html>&nbsp;&nbsp;${I18n.getString("termora.host.modified-server-key.actual")}: <font color=rgb(${errorColor.red},${errorColor.green},${errorColor.blue})>${KeyUtils.getFingerPrint(actual)}</font></html>").xy(1, rows).apply { rows += step }
.addSeparator(StringUtils.EMPTY).xy(1, rows).apply { rows += step }
.add(artBox).xy(1, rows).apply { rows += step }
.addSeparator(StringUtils.EMPTY).xy(1, rows).apply { rows += 1 }
.add(I18n.getString("termora.host.modified-server-key.are-you-sure")).xy(1, rows).apply { rows += step }
.build()
// @formatter:on
return OptionPane.showConfirmDialog(
owner,
panel,
"SSH Security Warning",
messageType = JOptionPane.WARNING_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
)
}
}
private 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 (isMiddleware(clientSession)) {
return null
}
}
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts)
}
}
@Suppress("UNCHECKED_CAST")
private class MyJGitSshClient : JGitSshClient() {
companion object {
private val HOST_CONFIG_ENTRY: AttributeRepository.AttributeKey<HostConfigEntry> by lazy {
JGitSshClient::class.java.getDeclaredField("HOST_CONFIG_ENTRY").apply { isAccessible = true }
.get(null) as AttributeRepository.AttributeKey<HostConfigEntry>
}
private const val CLIENT_PROXY_CONNECTOR = "ClientProxyConnectorId"
}
private val sshClient = this
private val clientProxyConnectors = ConcurrentHashMap<String, ClientProxyConnector>()
override fun createConnector(): IoConnector {
return MyIoConnector(this, super.createConnector())
}
override fun createSessionFactory(): SessionFactory {
return object : SessionFactory(sshClient) {
override fun doCreateSession(ioSession: IoSession): ClientSessionImpl {
return object : JGitClientSession(sshClient, ioSession) {
override fun getClientProxyConnector(): ClientProxyConnector? {
val entry = getAttribute(HOST_CONFIG_ENTRY) ?: return null
val clientProxyConnectorId = entry.getProperty(CLIENT_PROXY_CONNECTOR) ?: return null
val clientProxyConnector = sshClient.clientProxyConnectors[clientProxyConnectorId]
if (clientProxyConnector != null) {
addSessionListener(object : SessionListener {
override fun sessionClosed(session: Session) {
clientProxyConnectors.remove(clientProxyConnectorId)
}
})
}
return clientProxyConnector
}
override fun createShellChannel(
ptyConfig: PtyChannelConfigurationHolder?,
env: MutableMap<String, *>?
): ChannelShell {
if (inCipher is CipherNone || outCipher is CipherNone)
throw IllegalStateException("Interactive channels are not supported with none cipher")
val channel = app.termora.x11.ChannelShell(ptyConfig, env)
val id = connectionService.registerChannel(channel)
if (log.isDebugEnabled) {
log.debug("createShellChannel({}) created id={} - PTY={}", this, id, ptyConfig)
}
return channel
}
}
}
}
}
override fun setClientProxyConnector(proxyConnector: ClientProxyConnector?) {
throw UnsupportedOperationException()
}
private class MyIoConnector(private val sshClient: MyJGitSshClient, private val ioConnector: IoConnector) :
IoConnector {
override fun close(immediately: Boolean): CloseFuture {
return ioConnector.close(immediately)
}
override fun addCloseFutureListener(listener: SshFutureListener<CloseFuture>?) {
return ioConnector.addCloseFutureListener(listener)
}
override fun removeCloseFutureListener(listener: SshFutureListener<CloseFuture>?) {
return ioConnector.removeCloseFutureListener(listener)
}
override fun isClosed(): Boolean {
return ioConnector.isClosed
}
override fun isClosing(): Boolean {
return ioConnector.isClosing
}
override fun getIoServiceEventListener(): IoServiceEventListener {
return ioConnector.ioServiceEventListener
}
override fun setIoServiceEventListener(listener: IoServiceEventListener?) {
return ioConnector.setIoServiceEventListener(listener)
}
override fun getManagedSessions(): MutableMap<Long, IoSession> {
return ioConnector.managedSessions
}
override fun connect(
targetAddress: SocketAddress,
context: AttributeRepository?,
localAddress: SocketAddress?
): IoConnectFuture {
var tAddress = targetAddress
val entry = context?.getAttribute(HOST_CONFIG_ENTRY)
if (entry != null) {
val host = hostManager.getHost(entry.getProperty("Host") ?: StringUtils.EMPTY)
if (host != null) {
tAddress = configureProxy(entry, host, tAddress)
}
}
return ioConnector.connect(tAddress, context, localAddress)
}
private fun configureProxy(
entry: HostConfigEntry,
host: Host,
targetAddress: SocketAddress
): SocketAddress {
if (host.proxy.type == ProxyType.No) return targetAddress
val address = targetAddress as? InetSocketAddress ?: return targetAddress
if (address.hostString == (SshdSocketAddress.LOCALHOST_IPV4)) return targetAddress
// 获取代理连接器
val clientProxyConnector = getClientProxyConnector(host, address) ?: return targetAddress
val id = UUID.randomUUID().toSimpleString()
entry.setProperty(CLIENT_PROXY_CONNECTOR, id)
sshClient.clientProxyConnectors[id] = clientProxyConnector
return InetSocketAddress(host.proxy.host, host.proxy.port)
}
private fun getClientProxyConnector(
host: Host,
remoteAddress: InetSocketAddress
): AbstractClientProxyConnector? {
if (host.proxy.type == ProxyType.HTTP) {
return HttpClientConnector(
InetSocketAddress(host.proxy.host, host.proxy.port),
remoteAddress,
host.proxy.username.ifBlank { null },
if (host.proxy.password.isBlank()) null else host.proxy.password.toCharArray()
)
} else if (host.proxy.type == ProxyType.SOCKS5) {
return Socks5ClientConnector(
InetSocketAddress(host.proxy.host, host.proxy.port),
remoteAddress,
host.proxy.username.ifBlank { null },
if (host.proxy.password.isBlank()) null else host.proxy.password.toCharArray()
)
}
return null
}
}
}
}

View File

@@ -1,20 +1,32 @@
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(): TerminalFactory {
return ApplicationScope.forApplicationScope().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
}
@@ -23,7 +35,7 @@ class TerminalFactory {
return terminals
}
private inner class MyVisualTerminal : VisualTerminal() {
open class MyVisualTerminal : VisualTerminal() {
private val terminalModel by lazy { MyTerminalModel(this) }
override fun getTerminalModel(): TerminalModel {
@@ -31,19 +43,24 @@ class TerminalFactory {
}
}
private inner class MyTerminalModel(terminal: Terminal) : TerminalModelImpl(terminal) {
open class MyTerminalModel(terminal: Terminal) : TerminalModelImpl(terminal) {
private val colorPalette by lazy { MyColorPalette(terminal) }
private val config get() = Database.instance.terminal
private val config get() = Database.getDatabase().terminal
init {
setData(DataKey.CursorStyle, config.cursor)
setData(TerminalPanel.Debug, config.debug)
this.setData(DataKey.CursorStyle, config.cursor)
this.setData(TerminalPanel.Debug, config.debug)
}
override fun getColorPalette(): ColorPalette {
return colorPalette
}
override fun bell() {
if (config.beep) {
super.bell()
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(key: DataKey<T>): T {
@@ -90,17 +107,19 @@ class TerminalFactory {
TerminalColor.Basic.SELECTION_FOREGROUND
)
else -> DefaultColorTheme.instance.getColor(color)
else -> DefaultColorTheme.getInstance().getColor(color)
}
}
}
private inner class MyColorPalette(terminal: Terminal) : ColorPaletteImpl(terminal) {
class MyColorPalette(terminal: Terminal) : ColorPaletteImpl(terminal) {
private val colorTheme by lazy { FlatLafColorTheme() }
override fun getTheme(): ColorTheme {
return colorTheme
}
}
}

View File

@@ -1,37 +1,71 @@
package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.actions.MultipleAction
import app.termora.highlight.KeywordHighlightPaintListener
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector
import app.termora.terminal.Terminal
import app.termora.terminal.panel.TerminalHyperlinkPaintListener
import app.termora.terminal.panel.TerminalPanel
import app.termora.terminal.panel.TerminalWriter
import kotlinx.coroutines.*
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener
import java.nio.charset.Charset
import java.util.*
import javax.swing.JComponent
import javax.swing.SwingUtilities
import kotlin.time.Duration.Companion.milliseconds
class TerminalPanelFactory {
class TerminalPanelFactory : Disposable {
private val terminalPanels = mutableListOf<TerminalPanel>()
companion object {
val instance by lazy { TerminalPanelFactory() }
fun getInstance(): TerminalPanelFactory {
return ApplicationScope.forApplicationScope()
.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
}
}
init {
// repaint
Painter.getInstance()
}
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
val terminalPanel = TerminalPanel(terminal, ptyConnector)
val writer = MyTerminalWriter(ptyConnector)
val terminalPanel = TerminalPanel(terminal, writer)
// processDeviceStatusReport
terminal.getTerminalModel().setData(DataKey.TerminalWriter, writer)
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.instance)
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.instance)
terminalPanels.add(terminalPanel)
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
Disposer.register(terminalPanel, object : Disposable {
override fun dispose() {
removeTerminalPanel(terminalPanel)
}
})
addTerminalPanel(terminalPanel)
return terminalPanel
}
fun getTerminalPanels(): List<TerminalPanel> {
return terminalPanels
fun getTerminalPanels(): Array<TerminalPanel> {
return terminalPanels.toTypedArray()
}
fun repaintAll() {
if (SwingUtilities.isEventDispatchThread()) {
terminalPanels.forEach { it.repaintImmediate() }
getTerminalPanels().forEach { it.repaintImmediate() }
} else {
SwingUtilities.invokeLater { repaintAll() }
}
@@ -45,4 +79,89 @@ class TerminalPanelFactory {
}
}
private fun removeTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.remove(terminalPanel)
}
private fun addTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.add(terminalPanel)
}
private class Painter : Disposable {
companion object {
fun getInstance(): Painter {
return ApplicationScope.forApplicationScope().getOrCreate(Painter::class) { Painter() }
}
}
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
coroutineScope.launch {
while (coroutineScope.isActive) {
delay(500.milliseconds)
SwingUtilities.invokeLater { TerminalPanelFactory.getInstance().repaintAll() }
}
}
}
override fun dispose() {
coroutineScope.cancel()
}
}
private class MyTerminalWriter(private val ptyConnector: PtyConnector) : TerminalWriter {
companion object {
private val log = LoggerFactory.getLogger(MyTerminalWriter::class.java)
}
private lateinit var evt: AnActionEvent
override fun onMounted(c: JComponent) {
evt = AnActionEvent(c, StringUtils.EMPTY, EventObject(c))
}
override fun write(request: TerminalWriter.WriteRequest) {
if (log.isDebugEnabled) {
log.debug("write: ${String(request.buffer, getCharset())}")
}
val windowScope = evt.getData(DataProviders.WindowScope)
if (windowScope == null) {
ptyConnector.write(request.buffer)
return
}
val multipleAction = MultipleAction.getInstance(windowScope)
if (!multipleAction.isSelected) {
ptyConnector.write(request.buffer)
return
}
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager)
if (terminalTabbedManager == null) {
ptyConnector.write(request.buffer)
return
}
for (tab in terminalTabbedManager.getTerminalTabs()) {
val writer = tab.getData(DataProviders.TerminalWriter) ?: continue
if (writer is MyTerminalWriter) {
writer.ptyConnector.write(request.buffer)
}
}
}
override fun resize(rows: Int, cols: Int) {
ptyConnector.resize(rows, cols)
}
override fun getCharset(): Charset {
return ptyConnector.getCharset()
}
}
}

View File

@@ -1,10 +1,12 @@
package app.termora
import app.termora.Database.Appearance
import app.termora.actions.DataProvider
import java.beans.PropertyChangeListener
import javax.swing.Icon
import javax.swing.JComponent
interface TerminalTab : Disposable {
interface TerminalTab : Disposable, DataProvider {
/**
* 标题
@@ -42,5 +44,20 @@ interface TerminalTab : Disposable {
*/
fun canClose(): Boolean = true
/**
* 返回 true 表示可以关闭,只有当 [Appearance.confirmTabClose] 为 false 时才会调用
*/
fun willBeClose(): Boolean = true
/**
* 即将关闭,已经无法挽回
*/
fun beforeClose() {}
/**
* 是否可以克隆
*/
fun canClone(): Boolean = true
}

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,34 +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 com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionContainerFactory
import org.jdesktop.swingx.action.ActionManager
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.*
import java.awt.event.AWTEventListener
import java.awt.event.ActionEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener
import java.util.*
import javax.swing.*
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
import kotlin.math.min
class TerminalTabbed(
private val toolbar: JToolBar,
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 appearance get() = Database.getDatabase().appearance
private val iconListener = PropertyChangeListener { e ->
val source = e.source
if (e.propertyName == "icon" && source is TerminalTab) {
@@ -50,40 +54,14 @@ class TerminalTabbed(
tabbedPane.isTabsClosable = true
tabbedPane.tabType = FlatTabbedPane.TabType.card
tabbedPane.styleMap = mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground")
)
val actionManager = ActionManager.getInstance()
val actionContainerFactory = ActionContainerFactory(actionManager)
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))
updateBtn.isVisible = updateBtn.isEnabled
updateBtn.addChangeListener { updateBtn.isVisible = updateBtn.isEnabled }
toolbar.add(actionContainerFactory.createButton(object : AnAction(StringUtils.EMPTY, Icons.add) {
override fun actionPerformed(e: ActionEvent?) {
actionManager.getAction(Actions.FIND_EVERYWHERE)?.actionPerformed(e)
}
override fun isEnabled(): Boolean {
return actionManager.getAction(Actions.FIND_EVERYWHERE)?.isEnabled ?: false
}
}))
toolbar.add(Box.createHorizontalStrut(UIManager.getInt("TabbedPane.tabHeight")))
toolbar.add(Box.createHorizontalGlue())
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.MACRO)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEYWORD_HIGHLIGHT_EVERYWHERE)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEY_MANAGER)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.MULTIPLE)))
toolbar.add(updateBtn)
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.FIND_EVERYWHERE)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.SETTING)))
tabbedPane.trailingComponent = toolbar
add(tabbedPane, BorderLayout.CENTER)
windowScope.getOrCreate(TerminalTabbedManager::class) { this }
dataProviderSupport.addData(DataProviders.TerminalTabbed, this)
dataProviderSupport.addData(DataProviders.TerminalTabbedManager, this)
}
@@ -92,56 +70,23 @@ class TerminalTabbed(
tabbedPane.setTabCloseCallback { _, i -> removeTabAt(i, true) }
// 选中变动
tabbedPane.addPropertyChangeListener("selectedIndex", object : PropertyChangeListener {
override fun propertyChange(evt: PropertyChangeEvent) {
val oldIndex = evt.oldValue as Int
val newIndex = evt.newValue as Int
if (oldIndex >= 0 && tabs.size > newIndex) {
tabs[oldIndex].onLostFocus()
}
if (newIndex >= 0 && tabs.size > newIndex) {
tabs[newIndex].onGrabFocus()
}
}
})
tabbedPane.addPropertyChangeListener("selectedIndex") { evt ->
val oldIndex = evt.oldValue as Int
val newIndex = evt.newValue as Int
// 选择变动
tabbedPane.addChangeListener {
if (tabbedPane.selectedIndex >= 0) {
val c = tabbedPane.getComponentAt(tabbedPane.selectedIndex)
c.requestFocusInWindow()
if (oldIndex >= 0 && tabs.size > newIndex) {
tabs[oldIndex].onLostFocus()
}
tabbedPane.getComponentAt(newIndex).requestFocusInWindow()
if (newIndex >= 0 && tabs.size > newIndex) {
tabs[newIndex].onGrabFocus()
}
}
// 快捷键
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) {
@@ -170,43 +115,38 @@ 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) {
if (tabbedPane.getComponentAt(i) is WelcomePanel) {
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 JComponent && c.getClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE) != null) {
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)
}
@@ -214,8 +154,29 @@ class TerminalTabbed(
if (tabbedPane.isTabClosable(index)) {
val tab = tabs[index]
// 询问是否可以关闭
if (disposable) {
if (!tab.canClose()) {
// 如果开启了关闭确认,那么直接询问用户
if (appearance.confirmTabClose) {
if (OptionPane.showConfirmDialog(
windowScope.window,
I18n.getString("termora.tabbed.tab.close-prompt"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) != JOptionPane.OK_OPTION
) {
return
}
} else if (!tab.willBeClose()) { // 如果没有开启则询问用户
return
}
}
// 通知即将关闭
if (disposable) {
try {
tab.beforeClose()
} catch (_: Exception) {
return
}
}
@@ -232,38 +193,33 @@ class TerminalTabbed(
// 新的获取到焦点
tabs[tabbedPane.selectedIndex].onGrabFocus()
// 新的真正获取焦点
tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow()
if (disposable) {
Disposer.dispose(tab)
}
}
}
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]
val popupMenu = FlatPopupMenu()
// 修改名称
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
rename.addActionListener {
val index = tabbedPane.selectedIndex
if (index > 0) {
val dialog = InputDialog(
if (tabIndex > 0) {
val text = OptionPane.showInputDialog(
SwingUtilities.getWindowAncestor(this),
title = rename.text,
text = tabbedPane.getTitleAt(index),
value = tabbedPane.getTitleAt(tabIndex)
)
val text = dialog.getText()
if (!text.isNullOrBlank()) {
tabbedPane.setTitleAt(index, text)
tabbedPane.setTitleAt(tabIndex, text)
c.putClientProperty(titleProperty, text)
}
}
@@ -271,33 +227,58 @@ class TerminalTabbed(
// 克隆
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
clone.addActionListener {
val index = tabbedPane.selectedIndex
if (index > 0) {
val tab = tabs[index]
if (tab is HostTerminalTab) {
ActionManager.getInstance()
.getAction(Actions.OPEN_HOST)
.actionPerformed(OpenHostActionEvent(this, tab.host))
}
clone.addActionListener { evt ->
if (tab is HostTerminalTab) {
actionManager
.getAction(OpenHostAction.OPEN_HOST)
.actionPerformed(OpenHostActionEvent(this, tab.host, evt))
}
}
// 编辑
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
edit.addActionListener(object : AnAction() {
private val hostManager get() = HostManager.getInstance()
override fun actionPerformed(evt: AnActionEvent) {
if (tab is HostTerminalTab) {
val host = hostManager.getHost(tab.host.id) ?: return
val dialog = HostDialog(evt.window, host)
dialog.setLocationRelativeTo(evt.window)
dialog.isVisible = true
hostManager.addHost(dialog.host ?: return)
}
}
})
// 在新窗口中打开
val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window"))
openInNewWindow.addActionListener {
val index = tabbedPane.selectedIndex
if (index > 0) {
val tab = tabs[index]
removeTabAt(index, false)
val dialog = TerminalTabDialog(
owner = SwingUtilities.getWindowAncestor(this),
terminalTab = tab,
size = Dimension(min(size.width, 1280), min(size.height, 800))
)
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) }
}
}
}
@@ -327,24 +308,23 @@ class TerminalTabbed(
}
close.isEnabled = c !is WelcomePanel
close.isEnabled = tab.canClose()
rename.isEnabled = close.isEnabled
clone.isEnabled = close.isEnabled
edit.isEnabled = tab is HostTerminalTab && tab.host.id != "local"
openInNewWindow.isEnabled = close.isEnabled
// SFTP不允许克隆
if (clone.isEnabled && getSelectedTerminalTab() is SFTPTerminalTab) {
// 如果不允许克隆
if (clone.isEnabled && !tab.canClone()) {
clone.isEnabled = false
}
if (close.isEnabled) {
popupMenu.addSeparator()
val reconnect = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.reconnect"))
reconnect.addActionListener {
val index = tabbedPane.selectedIndex
if (index > 0) {
tabs[index].reconnect()
if (tabIndex > 0) {
tabs[tabIndex].reconnect()
}
}
@@ -355,21 +335,128 @@ class TerminalTabbed(
}
fun addTab(tab: TerminalTab) {
tabbedPane.addTab(
tab.getTitle(),
private fun addTab(index: Int, tab: TerminalTab, selected: Boolean) {
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)
if (selected) {
tabbedPane.selectedIndex = index
}
tabbedPane.setTabClosable(index, tab.canClose())
Disposer.register(this, tab)
}
override fun refreshTerminalTabs() {
for (i in 0 until tabbedPane.tabCount) {
tabbedPane.setTabClosable(i, tabs[i].canClose())
}
}
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, updateDate = System.currentTimeMillis(),
options = host.options.copy(env = envs.toPropertiesString())
)
}
openHostAction.actionPerformed(OpenHostActionEvent(this, host, evt))
}
/**
* 对着 ToolBar 右键
*/
private inner class CustomizeToolBarAWTEventListener : AWTEventListener, Disposable {
init {
Disposer.register(this@TerminalTabbed, this)
}
override fun eventDispatched(event: AWTEvent) {
if (event !is MouseEvent || event.id != MouseEvent.MOUSE_CLICKED || !SwingUtilities.isRightMouseButton(event)) return
// 如果 ToolBar 没有显示
if (!toolbar.isShowing) return
// 如果不是作用于在 ToolBar 上面
if (!Rectangle(toolbar.locationOnScreen, toolbar.size).contains(event.locationOnScreen)) return
// 显示右键菜单
showContextMenu(event)
}
private fun showContextMenu(event: MouseEvent) {
val popupMenu = FlatPopupMenu()
popupMenu.add(I18n.getString("termora.toolbar.customize-toolbar")).addActionListener {
val owner = SwingUtilities.getWindowAncestor(this@TerminalTabbed)
val dialog = CustomizeToolBarDialog(owner, windowScope, termoraToolBar)
dialog.setLocationRelativeTo(owner)
if (dialog.open()) {
TermoraToolBar.rebuild()
}
}
popupMenu.show(event.component, event.x, event.y)
}
override fun dispose() {
toolkit.removeAWTEventListener(this)
}
}
/*private inner class CustomizeToolBarDialog(owner: Window) : DialogWrapper(owner) {
init {
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
isModal = true
title = I18n.getString("termora.setting")
setLocationRelativeTo(null)
init()
}
override fun createCenterPanel(): JComponent {
val model = DefaultListModel<String>()
val checkBoxList = CheckBoxList(model)
checkBoxList.fixedCellHeight = UIManager.getInt("Tree.rowHeight")
model.addElement("Test")
return checkBoxList
}
}*/
private inner class SwitchFindEverywhereResult(
private val title: String,
private val icon: Icon?,
@@ -400,8 +487,12 @@ class TerminalTabbed(
override fun dispose() {
}
override fun addTerminalTab(tab: TerminalTab) {
addTab(tab)
override fun addTerminalTab(tab: TerminalTab, selected: Boolean) {
addTab(tabs.size, tab, selected)
}
override fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean) {
addTab(index, tab, selected)
}
override fun getSelectedTerminalTab(): TerminalTab? {
@@ -426,5 +517,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

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

View File

@@ -1,281 +1,195 @@
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.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders
import app.termora.sftp.SFTPTab
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.ui.FlatRootPaneUI
import com.formdev.flatlaf.ui.FlatTitlePane
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 org.apache.commons.lang3.ArrayUtils
import java.awt.*
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseListener
import java.awt.event.MouseMotionListener
import java.util.*
import javax.imageio.ImageIO
import javax.swing.*
import javax.swing.JComponent
import javax.swing.JFrame
import javax.swing.SwingUtilities
import javax.swing.SwingUtilities.isEventDispatchThread
import javax.swing.event.HyperlinkEvent
import kotlin.concurrent.fixedRateTimer
import kotlin.math.max
import kotlin.system.exitProcess
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import javax.swing.UIManager
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 toolbar = JToolBar()
private val id = UUID.randomUUID().toString()
private val windowScope = ApplicationScope.forWindowScope(this)
private val tabbedPane = MyTabbedPane()
private lateinit var terminalTabbed: TerminalTabbed
private val disposable = Disposer.newDisposable()
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
private val updaterManager get() = UpdaterManager.instance
private val toolbar = TermoraToolBar(windowScope, this)
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
private val dataProviderSupport = DataProviderSupport()
private val welcomePanel = WelcomePanel(windowScope)
private val sftp get() = Database.getDatabase().sftp
private var notifyListeners = emptyArray<NotifyListener>()
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.isVisible = true
}
}
init {
initActions()
initView()
initEvents()
initDesktopHandler()
scheduleUpdate()
}
private fun initEvents() {
if (SystemInfo.isLinux) {
val mouseAdapter = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
getMouseHandler()?.mouseClicked(e)
}
// 监听窗口大小变动,然后修改边距避开控制按钮
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
if (SystemInfo.isMacOS) {
val left = titleBar.leftInset.toInt()
if (tabbedPane.tabAreaInsets.left != left) {
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0)
}
} else if (SystemInfo.isWindows || SystemInfo.isLinux) {
override fun mousePressed(e: MouseEvent) {
getMouseHandler()?.mousePressed(e)
}
val right = titleBar.rightInset.toInt()
override fun mouseDragged(e: MouseEvent) {
getMouseMotionListener()?.mouseDragged(
MouseEvent(
e.component,
e.id,
e.`when`,
e.modifiersEx,
e.x,
e.y,
e.clickCount,
e.isPopupTrigger,
e.button
)
)
}
for (i in 0 until toolbar.componentCount) {
val c = toolbar.getComponent(i)
if (c.name == "spacing") {
if (c.width == right) {
private fun getMouseHandler(): MouseListener? {
return getHandler() as? MouseListener
}
private fun getMouseMotionListener(): MouseMotionListener? {
return getHandler() as? MouseMotionListener
}
private fun getHandler(): Any? {
val titlePane = getTitlePane() ?: return null
val handlerField = titlePane.javaClass.getDeclaredField("handler") ?: return null
handlerField.isAccessible = true
return handlerField.get(titlePane)
}
private fun getTitlePane(): FlatTitlePane? {
val ui = rootPane.ui as? FlatRootPaneUI ?: return null
val titlePaneField = ui.javaClass.getDeclaredField("titlePane")
titlePaneField.isAccessible = true
return titlePaneField.get(ui) as? FlatTitlePane
}
}
toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
}
/// force hit
if (SystemInfo.isMacOS) {
if (JBR.isWindowDecorationsSupported()) {
val height = UIManager.getInt("TabbedPane.tabHeight") + tabbedPane.tabAreaInsets.top
val customTitleBar = JBR.getWindowDecorations().createCustomTitleBar()
customTitleBar.height = height.toFloat()
val mouseAdapter = object : MouseAdapter() {
private fun hit(e: MouseEvent) {
if (e.source == tabbedPane) {
val index = tabbedPane.indexAtLocation(e.x, e.y)
if (index >= 0) {
return
}
toolbar.remove(i)
break
}
customTitleBar.forceHitTest(false)
}
if (right > 0) {
val spacing = Box.createHorizontalStrut(right)
spacing.name = "spacing"
toolbar.add(spacing)
override fun mouseClicked(e: MouseEvent) {
hit(e)
}
override fun mousePressed(e: MouseEvent) {
hit(e)
}
override fun mouseReleased(e: MouseEvent) {
hit(e)
}
override fun mouseEntered(e: MouseEvent) {
hit(e)
}
override fun mouseDragged(e: MouseEvent) {
hit(e)
}
override fun mouseMoved(e: MouseEvent) {
hit(e)
}
}
}
})
forceHitTest()
terminalTabbed.addMouseListener(mouseAdapter)
terminalTabbed.addMouseMotionListener(mouseAdapter)
// macos 需要判断是否全部删除
// 当 Tab 为 0 的时候,需要加一个边距,避开控制栏
if (SystemInfo.isMacOS && isWindowDecorationsSupported) {
tabbedPane.addChangeListener {
tabbedPane.leadingComponent = if (tabbedPane.tabCount == 0) {
Box.createHorizontalStrut(titleBar.leftInset.toInt())
} else {
null
}
tabbedPane.addMouseListener(mouseAdapter)
tabbedPane.addMouseMotionListener(mouseAdapter)
toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
JBR.getWindowDecorations().setCustomTitleBar(this, customTitleBar)
}
}
// 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 {
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_EVERYWHERE, 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()
}
})
// 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) {
FindEverywhere(frame).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()
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
JBR.getWindowDecorations().setCustomTitleBar(this, titleBar)
// macOS 要避开左边的控制栏
if (SystemInfo.isMacOS) {
tabbedPane.tabAreaInsets = Insets(0, 76, 0, 0)
} else if (SystemInfo.isWindows) {
// Windows 10 会有1像素误差
tabbedPane.tabAreaInsets = Insets(if (SystemInfo.isWindows_11_orLater) 1 else 2, 2, 0, 0)
} else if (SystemInfo.isLinux) {
tabbedPane.tabAreaInsets = Insets(1, 2, 0, 0)
}
if (SystemInfo.isLinux) {
val height = UIManager.getInt("TabbedPane.tabHeight") + tabbedPane.tabAreaInsets.top
if (SystemInfo.isWindows || SystemInfo.isLinux) {
rootPane.putClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT, true)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, UIManager.getInt("TabbedPane.tabHeight"))
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICON, false)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, false)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, height)
} else if (SystemInfo.isMacOS) {
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
rootPane.putClientProperty(
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING,
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING_MEDIUM
)
}
if (SystemInfo.isWindows || SystemInfo.isLinux) {
@@ -288,167 +202,82 @@ 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) {
val left = max(titleBar.leftInset.toInt(), 76)
if (tabbedPane.tabCount == 0) {
tabbedPane.leadingComponent = Box.createHorizontalStrut(left)
} else {
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0)
// 下一次事件循环检测是否固定 SFTP
if (sftp.pinTab) {
SwingUtilities.invokeLater {
terminalTabbed.addTerminalTab(SFTPTab(), false)
}
}
Disposer.register(disposable, terminalTabbed)
add(terminalTabbed)
val glassPane = GlassPane()
rootPane.glassPane = glassPane
glassPane.isOpaque = false
glassPane.isVisible = true
Disposer.register(windowScope, terminalTabbed)
add(terminalTabbed, BorderLayout.CENTER)
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}"))
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey)
?: terminalTabbed.getData(dataKey)
?: welcomePanel.getData(dataKey)
}
@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() } } }
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TermoraFrame
return id == other.id
}
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)
}
override fun hashCode(): Int {
return id.hashCode()
}
private fun forceHitTest() {
val mouseAdapter = object : MouseAdapter() {
private fun hit(e: MouseEvent) {
if (e.source == tabbedPane) {
val index = tabbedPane.indexAtLocation(e.x, e.y)
if (index >= 0) {
return
}
}
titleBar.forceHitTest(false)
}
override fun mouseClicked(e: MouseEvent) {
hit(e)
}
override fun mousePressed(e: MouseEvent) {
if (e.source == toolbar) {
if (!isWindowDecorationsSupported && SwingUtilities.isLeftMouseButton(e)) {
if (JBR.isWindowMoveSupported()) {
JBR.getWindowMove().startMovingTogetherWithMouse(this@TermoraFrame, e.button)
}
}
}
hit(e)
}
override fun mouseReleased(e: MouseEvent) {
hit(e)
}
override fun mouseEntered(e: MouseEvent) {
hit(e)
}
override fun mouseDragged(e: MouseEvent) {
hit(e)
}
override fun mouseMoved(e: MouseEvent) {
hit(e)
}
}
terminalTabbed.addMouseListener(mouseAdapter)
terminalTabbed.addMouseMotionListener(mouseAdapter)
tabbedPane.addMouseListener(mouseAdapter)
tabbedPane.addMouseMotionListener(mouseAdapter)
toolbar.addMouseListener(mouseAdapter)
toolbar.addMouseMotionListener(mouseAdapter)
fun addNotifyListener(listener: NotifyListener) {
notifyListeners += listener
}
private fun initDesktopHandler() {
if (SystemInfo.isMacOS) {
FlatDesktop.setPreferencesHandler {
preferencesHandler.run()
}
fun removeNotifyListener(listener: NotifyListener) {
notifyListeners = ArrayUtils.removeElements(notifyListeners, listener)
}
override fun addNotify() {
super.addNotify()
notifyListeners.forEach { it.addNotify() }
}
private class GlassPane : JComponent() {
init {
isFocusable = false
}
override fun paintComponent(g: Graphics) {
val img = BackgroundManager.getInstance().getBackgroundImage() ?: return
val g2d = g as Graphics2D
g2d.composite = AlphaComposite.getInstance(
AlphaComposite.SRC_OVER,
if (FlatLaf.isLafDark()) 0.2f else 0.1f
)
g2d.drawImage(img, 0, 0, width, height, null)
g2d.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER)
}
override fun contains(x: Int, y: Int): Boolean {
return false
}
}
}

View File

@@ -0,0 +1,229 @@
package app.termora
import app.termora.native.osx.NativeMacLibrary
import com.formdev.flatlaf.ui.FlatNativeWindowsLibrary
import com.formdev.flatlaf.util.SystemInfo
import com.sun.jna.Pointer
import com.sun.jna.platform.win32.User32
import com.sun.jna.platform.win32.WinDef
import com.sun.jna.platform.win32.WinUser.*
import de.jangassen.jfa.ThreadUtils
import de.jangassen.jfa.foundation.Foundation
import de.jangassen.jfa.foundation.ID
import org.slf4j.LoggerFactory
import java.awt.Frame
import java.awt.Window
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JFrame
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import javax.swing.UIManager
import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
import kotlin.math.max
import kotlin.system.exitProcess
class TermoraFrameManager : Disposable {
companion object {
private val log = LoggerFactory.getLogger(TermoraFrameManager::class.java)
fun getInstance(): TermoraFrameManager {
return ApplicationScope.forApplicationScope()
.getOrCreate(TermoraFrameManager::class) { TermoraFrameManager() }
}
}
private val frames = mutableListOf<TermoraFrame>()
private val properties get() = Database.getDatabase().properties
private val isDisposed = AtomicBoolean(false)
private val isBackgroundRunning get() = Database.getDatabase().appearance.backgroundRunning
fun createWindow(): TermoraFrame {
val frame = TermoraFrame().apply { registerCloseCallback(this) }
frame.title = Application.getName()
frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
val rectangle = getFrameRectangle() ?: FrameRectangle(-1, -1, 1280, 800, 0)
if (rectangle.isMaximized) {
frame.setSize(1280, 800)
frame.setLocationRelativeTo(null)
frame.extendedState = rectangle.s
} else {
// 控制最小
frame.setSize(
max(rectangle.w, UIManager.getInt("Dialog.width") - 150),
max(rectangle.h, UIManager.getInt("Dialog.height") - 100)
)
if (rectangle.x == -1 && rectangle.y == -1) {
frame.setLocationRelativeTo(null)
} else {
frame.setLocation(max(rectangle.x, 0), max(rectangle.y, 0))
}
}
frame.addNotifyListener(object : NotifyListener {
private val opacity get() = Database.getDatabase().appearance.opacity
override fun addNotify() {
val opacity = this.opacity
if (opacity >= 1.0) return
setOpacity(frame, opacity)
}
})
return frame.apply { frames.add(this) }
}
fun getWindows(): Array<TermoraFrame> {
return frames.toTypedArray()
}
private fun registerCloseCallback(window: TermoraFrame) {
val manager = this
window.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
// 存储位置信息
saveFrameRectangle(window)
// 删除
frames.remove(window)
// dispose windowScope
val windowScope = ApplicationScope.forWindowScope(e.window)
Disposer.disposeChildren(windowScope, null)
Disposer.dispose(windowScope)
val windowScopes = ApplicationScope.windowScopes()
if (windowScopes.isNotEmpty()) {
return
}
// 如果已经没有 Window 域了,那么就可以退出程序了
if (SystemInfo.isWindows || SystemInfo.isLinux) {
Disposer.dispose(manager)
} else if (SystemInfo.isMacOS) {
// 如果 macOS 开启了后台运行,那么尽管所有窗口都没了,也不会退出
if (isBackgroundRunning) {
return
}
Disposer.dispose(manager)
}
}
override fun windowClosing(e: WindowEvent) {
if (ApplicationScope.windowScopes().size != 1) {
window.dispose()
return
}
// 如果 Windows 开启了后台运行,那么最小化
if (SystemInfo.isWindows && isBackgroundRunning) {
// 最小化
window.extendedState = window.extendedState or JFrame.ICONIFIED
// 隐藏
window.isVisible = false
return
}
// 如果 macOS 已经开启了后台运行,那么直接销毁,因为会有一个进程驻守
if (SystemInfo.isMacOS && isBackgroundRunning) {
window.dispose()
return
}
val option = OptionPane.showConfirmDialog(
window,
I18n.getString("termora.quit-confirm", Application.getName()),
optionType = JOptionPane.YES_NO_OPTION,
)
if (option == JOptionPane.YES_OPTION) {
window.dispose()
}
}
})
}
fun tick() {
if (SwingUtilities.isEventDispatchThread()) {
val windows = getWindows()
if (windows.isEmpty()) return
for (window in windows) {
if (window.extendedState and JFrame.ICONIFIED == JFrame.ICONIFIED) {
window.extendedState = window.extendedState and JFrame.ICONIFIED.inv()
}
window.isVisible = true
}
windows.last().toFront()
} else {
SwingUtilities.invokeLater { tick() }
}
}
override fun dispose() {
if (isDisposed.compareAndSet(false, true)) {
Disposer.dispose(ApplicationScope.forApplicationScope())
try {
Disposer.getTree().assertIsEmpty(true)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
exitProcess(0)
}
private fun saveFrameRectangle(frame: TermoraFrame) {
properties.putString("TermoraFrame.x", frame.x.toString())
properties.putString("TermoraFrame.y", frame.y.toString())
properties.putString("TermoraFrame.width", frame.width.toString())
properties.putString("TermoraFrame.height", frame.height.toString())
properties.putString("TermoraFrame.extendedState", frame.extendedState.toString())
}
private fun getFrameRectangle(): FrameRectangle? {
val x = properties.getString("TermoraFrame.x")?.toIntOrNull() ?: return null
val y = properties.getString("TermoraFrame.y")?.toIntOrNull() ?: return null
val w = properties.getString("TermoraFrame.width")?.toIntOrNull() ?: return null
val h = properties.getString("TermoraFrame.height")?.toIntOrNull() ?: return null
val s = properties.getString("TermoraFrame.extendedState")?.toIntOrNull() ?: return null
return FrameRectangle(x, y, w, h, s)
}
fun setOpacity(opacity: Double) {
if (opacity < 0 || opacity > 1 || SystemInfo.isLinux) return
for (window in getWindows()) {
setOpacity(window, opacity)
}
}
private fun setOpacity(window: Window, opacity: Double) {
if (SystemInfo.isMacOS) {
val nsWindow = ID(NativeMacLibrary.getNSWindow(window) ?: return)
ThreadUtils.dispatch_async {
Foundation.invoke(nsWindow, "setOpaque:", false)
Foundation.invoke(nsWindow, "setAlphaValue:", opacity)
}
} else if (SystemInfo.isWindows) {
val alpha = ((opacity * 255).toInt() and 0xFF).toByte()
val hwnd = WinDef.HWND(Pointer.createConstant(FlatNativeWindowsLibrary.getHWND(window)))
val exStyle = User32.INSTANCE.GetWindowLong(hwnd, User32.GWL_EXSTYLE)
if (exStyle and WS_EX_LAYERED == 0) {
User32.INSTANCE.SetWindowLong(hwnd, GWL_EXSTYLE, exStyle or WS_EX_LAYERED)
}
User32.INSTANCE.SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA)
}
}
private data class FrameRectangle(
val x: Int, val y: Int, val w: Int, val h: Int, val s: Int
) {
val isMaximized get() = (s and Frame.MAXIMIZED_BOTH) == Frame.MAXIMIZED_BOTH
}
}

View File

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

View File

@@ -0,0 +1,191 @@
package app.termora
import app.termora.Application.ohMyJson
import app.termora.actions.*
import app.termora.findeverywhere.FindEverywhereAction
import app.termora.snippet.SnippetAction
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.serialization.Serializable
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionContainerFactory
import java.awt.Rectangle
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import javax.swing.Box
import javax.swing.JToolBar
@Serializable
data class ToolBarAction(
val id: String,
val visible: Boolean,
)
class TermoraToolBar(
private val windowScope: WindowScope,
private val frame: TermoraFrame,
) {
companion object {
fun rebuild() {
for (frame in TermoraFrameManager.getInstance().getWindows()) {
val toolbars = SwingUtils.getDescendantsOfClass(MyToolBar::class.java, frame)
for (toolbar in toolbars) {
toolbar.rebuild()
}
}
}
}
private val properties by lazy { Database.getDatabase().properties }
private val toolbar by lazy { MyToolBar().apply { rebuild() } }
fun getJToolBar(): JToolBar {
return toolbar
}
/**
* 获取到所有的 Action
*/
fun getAllActions(): List<ToolBarAction> {
return listOf(
ToolBarAction(SnippetAction.SNIPPET, true),
ToolBarAction(Actions.SFTP, true),
ToolBarAction(Actions.TERMINAL_LOGGER, true),
ToolBarAction(Actions.MACRO, true),
ToolBarAction(Actions.KEYWORD_HIGHLIGHT, true),
ToolBarAction(Actions.KEY_MANAGER, true),
ToolBarAction(MultipleAction.MULTIPLE, true),
ToolBarAction(FindEverywhereAction.FIND_EVERYWHERE, true),
ToolBarAction(SettingsAction.SETTING, true),
)
}
/**
* 获取到所有 Action会根据用户个性化排序/显示
*/
fun getActions(): List<ToolBarAction> {
val text = properties.getString(
"Termora.ToolBar.Actions",
StringUtils.EMPTY
)
val actions = getAllActions()
if (text.isBlank()) {
return actions
}
// 存储的 action
val storageActions = (ohMyJson.runCatching {
ohMyJson.decodeFromString<List<ToolBarAction>>(text)
}.getOrNull() ?: return actions).toMutableList()
for (action in actions) {
// 如果存储的 action 不包含这个,那么这个可能是新增的,新增的默认显示出来
if (storageActions.none { it.id == action.id }) {
storageActions.addFirst(ToolBarAction(action.id, true))
}
}
// 如果存储的 Action 在所有 Action 里没有,那么移除
storageActions.removeIf { e -> actions.none { e.id == it.id } }
return storageActions
}
private inner class MyToolBar : JToolBar() {
init {
// 监听窗口大小变动,然后修改边距避开控制按钮
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
adjust()
}
})
}
fun adjust() {
if (SystemInfo.isWindows || SystemInfo.isLinux) {
val rectangle =
frame.rootPane.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
as? Rectangle ?: return
val right = rectangle.width
val toolbar = this@MyToolBar
for (i in 0 until toolbar.componentCount) {
val c = toolbar.getComponent(i)
if (c.name == "spacing") {
if (c.width == right) {
return
}
toolbar.remove(i)
break
}
}
if (right > 0) {
val spacing = Box.createHorizontalStrut(right)
spacing.name = "spacing"
toolbar.add(spacing)
}
}
}
fun rebuild() {
val toolbar: JToolBar = this
val actionManager = ActionManager.getInstance()
val actionContainerFactory = ActionContainerFactory(actionManager)
toolbar.removeAll()
toolbar.add(actionContainerFactory.createButton(object : AnAction(StringUtils.EMPTY, Icons.add) {
override fun actionPerformed(evt: AnActionEvent) {
actionManager.getAction(FindEverywhereAction.FIND_EVERYWHERE)?.actionPerformed(evt)
}
override fun isEnabled(): Boolean {
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))
updateBtn.isVisible = updateBtn.isEnabled
updateBtn.addChangeListener { updateBtn.isVisible = updateBtn.isEnabled }
toolbar.add(updateBtn)
// 获取显示的Action如果不是 false 那么就是显示出来
for (action in getActions()) {
if (action.visible) {
val ac = actionManager.getAction(action.id)
if (ac == null) {
if (action.id == MultipleAction.MULTIPLE) {
toolbar.add(actionContainerFactory.createButton(MultipleAction.getInstance(windowScope)))
}
} else {
toolbar.add(actionContainerFactory.createButton(ac))
}
}
}
if (toolbar is MyToolBar) {
toolbar.adjust()
}
toolbar.revalidate()
toolbar.repaint()
}
}
}

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> : FlatComboBox<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)
}
}
@@ -137,7 +146,7 @@ open class EmailFormattedTextField(var maxLength: Int = Int.MAX_VALUE) : Outline
}
abstract class NumberSpinner(
open class NumberSpinner(
value: Int,
minimum: Int,
maximum: Int,

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