Compare commits

...

122 Commits

Author SHA1 Message Date
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
232 changed files with 10717 additions and 4619 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

View File

@@ -11,7 +11,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
# download jdk # download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-aarch64-b825.69.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-aarch64-b895.91.tar.gz
# appimagetool # appimagetool
- run: sudo apt install libfuse2 - run: sudo apt install libfuse2

View File

@@ -11,7 +11,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
# download jdk # download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b825.69.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b895.91.tar.gz
# appimagetool # appimagetool
- run: sudo apt install libfuse2 - run: sudo apt install libfuse2

View File

@@ -33,8 +33,8 @@ jobs:
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH
- name: Setup the Notary Information - name: Setup the Notary information
if: github.ref_type == 'tag' && github.repository == 'TermoraDev/termora' if: "startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora'"
env: env:
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
@@ -44,7 +44,7 @@ jobs:
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD" xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
# download jdk # download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b895.91.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -70,7 +70,7 @@ jobs:
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }} TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }} TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
# 只有发布版本时才需要公证 # 只有发布版本时才需要公证
TERMORA_MAC_NOTARY: ${{ github.ref_type == 'tag' && github.repository == 'TermoraDev/termora' }} TERMORA_MAC_NOTARY: "${{ startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' }}"
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }} TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: | run: |
./gradlew dist --no-daemon ./gradlew dist --no-daemon

View File

@@ -33,8 +33,8 @@ jobs:
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH
- name: Setup the Notary Information - name: Setup the Notary information
if: github.ref_type == 'tag' && github.repository == 'TermoraDev/termora' if: "startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora'"
env: env:
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
@@ -44,7 +44,7 @@ jobs:
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD" xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
# download jdk # download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b895.91.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -72,7 +72,7 @@ jobs:
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }} TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }} TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
# 只有发布版本时才需要公证 # 只有发布版本时才需要公证
TERMORA_MAC_NOTARY: ${{ github.ref_type == 'tag' && github.repository == 'TermoraDev/termora' }} TERMORA_MAC_NOTARY: "${{ startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' }}"
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }} TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: | run: |
./gradlew dist --no-daemon ./gradlew dist --no-daemon

View File

@@ -45,5 +45,4 @@ jobs:
name: termora-windows-x86-64 name: termora-windows-x86-64
path: | path: |
build/distributions/*.zip build/distributions/*.zip
build/distributions/*.msi
build/distributions/*.exe build/distributions/*.exe

1
.gitignore vendored
View File

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

View File

@@ -20,6 +20,7 @@
- Compatible with Windows, macOS, and Linux - Compatible with Windows, macOS, and Linux
- Zmodem protocol support - Zmodem protocol support
- SSH port forwarding & Jump hosts - SSH port forwarding & Jump hosts
- Support for X11 and SSH-Agent
- Terminal log - Terminal log
- Configuration synchronization via [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV) - Configuration synchronization via [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- Macro support (record and replay scripts) - Macro support (record and replay scripts)

View File

@@ -16,6 +16,7 @@
- 支持 Windows、macOS、Linux 平台 - 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议 - 支持 Zmodem 协议
- 支持 SSH 端口转发和跳板机 - 支持 SSH 端口转发和跳板机
- 支持 X11 和 SSH-Agent
- 终端日志记录 - 终端日志记录
- 支持配置同步到 [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV) - 支持配置同步到 [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- 支持宏(录制脚本并回放) - 支持宏(录制脚本并回放)

View File

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

View File

@@ -20,7 +20,7 @@ plugins {
group = "app.termora" group = "app.termora"
version = "1.0.9" version = "1.0.14"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture() val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
@@ -67,6 +67,7 @@ dependencies {
implementation(libs.commons.net) implementation(libs.commons.net)
implementation(libs.commons.text) implementation(libs.commons.text)
implementation(libs.commons.compress) implementation(libs.commons.compress)
implementation(libs.commons.vfs2) { exclude(group = "*", module = "*") }
implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.coroutines.swing)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
@@ -104,6 +105,8 @@ dependencies {
implementation(libs.commonmark) implementation(libs.commonmark)
implementation(libs.jgit) implementation(libs.jgit)
implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") } implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.jgit.agent) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.eddsa)
implementation(libs.jnafilechooser) implementation(libs.jnafilechooser)
implementation(libs.xodus.vfs) implementation(libs.xodus.vfs)
implementation(libs.xodus.openAPI) implementation(libs.xodus.openAPI)
@@ -118,7 +121,6 @@ dependencies {
application { application {
val args = mutableListOf( val args = mutableListOf(
"--add-exports java.base/sun.nio.ch=ALL-UNNAMED",
"-Xmx2g", "-Xmx2g",
"-XX:+UseZGC", "-XX:+UseZGC",
"-XX:+ZUncommit", "-XX:+ZUncommit",
@@ -127,7 +129,10 @@ application {
) )
if (os.isMacOsX) { 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("-Dsun.java2d.metal=true") args.add("-Dsun.java2d.metal=true")
args.add("-Dapple.awt.application.appearance=system") args.add("-Dapple.awt.application.appearance=system")
} }
@@ -345,6 +350,10 @@ tasks.register<Exec>("jpackage") {
options.add("-Dsun.java2d.metal=true") options.add("-Dsun.java2d.metal=true")
if (os.isMacOsX) { if (os.isMacOsX) {
// 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("-Dapple.awt.application.appearance=system")
options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED") options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
} }
@@ -421,15 +430,9 @@ tasks.register<Exec>("jpackage") {
tasks.register("dist") { tasks.register("dist") {
doLast { doLast {
val vendor = Jvm.current().vendor ?: StringUtils.EMPTY
@Suppress("UnstableApiUsage")
if (!JvmVendorSpec.JETBRAINS.matches(vendor)) {
throw GradleException("JVM: $vendor is not supported")
}
val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath
// 清空目录 // 清空目录
exec { commandLine(gradlew, "clean") } exec { commandLine(gradlew, "clean") }
@@ -455,33 +458,26 @@ tasks.register("dist") {
tasks.register("check-license") { tasks.register("check-license") {
doLast { doLast {
val thirdParty = mutableMapOf<String, String>()
val iterator = File(projectDir, "THIRDPARTY").readLines().iterator() val iterator = File(projectDir, "THIRDPARTY").readLines().iterator()
val thirdPartyNames = mutableSetOf<String>() val thirdPartyNames = mutableSetOf<String>()
while (iterator.hasNext()) { while (iterator.hasNext()) {
val nameWithVersion = iterator.next() val name = iterator.next()
if (nameWithVersion.isBlank()) { if (name.isBlank()) {
continue continue
} }
// ignore license name // ignore license name
iterator.next() iterator.next()
// ignore license url
iterator.next()
val license = iterator.next() thirdPartyNames.add(name)
thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license
thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first())
} }
for (file in configurations.runtimeClasspath.get()) { for (dependency in configurations.runtimeClasspath.get().allDependencies) {
val name = file.nameWithoutExtension if (!thirdPartyNames.contains(dependency.name)) {
if (!thirdParty.containsKey(name)) { throw GradleException("${dependency.name} No license found")
if (logger.isWarnEnabled) {
logger.warn("$name does not exist in third-party")
}
if (!thirdPartyNames.contains(name)) {
throw GradleException("$name No license found")
}
} }
} }
} }
@@ -736,8 +732,6 @@ fun stapleMacOSLocalFile(file: File) {
kotlin { kotlin {
jvmToolchain { jvmToolchain {
languageVersion = JavaLanguageVersion.of(21) languageVersion = JavaLanguageVersion.of(21)
@Suppress("UnstableApiUsage")
vendor = JvmVendorSpec.JETBRAINS
} }
} }

View File

@@ -1,50 +1,46 @@
[versions] [versions]
kotlin = "2.1.10" kotlin = "2.1.20"
slf4j = "2.0.16" slf4j = "2.0.17"
pty4j = "0.13.2" pty4j = "0.13.4"
tinylog = "2.7.0" tinylog = "2.7.0"
kotlinx-coroutines = "1.10.1" kotlinx-coroutines = "1.10.2"
flatlaf = "3.5.4" flatlaf = "3.6"
trove4j = "1.0.20200330" kotlinx-serialization-json = "1.8.1"
kotlinx-serialization-json = "1.8.0"
commons-codec = "1.18.0" commons-codec = "1.18.0"
commons-lang3 = "3.17.0" commons-lang3 = "3.17.0"
commons-csv = "1.13.0" commons-csv = "1.14.0"
commons-net = "3.11.1" commons-net = "3.11.1"
commons-text = "1.13.0" commons-text = "1.13.1"
commons-compress = "1.27.1" commons-compress = "1.27.1"
koin-bom = "4.0.0" commons-vfs2="2.10.0"
swingx = "1.6.5-1" swingx = "1.6.5-1"
jgoodies-forms = "1.9.0" jgoodies-forms = "1.9.0"
jfa = "1.2.0" jfa = "1.2.0"
oshi = "6.6.5" oshi = "6.8.1"
versioncompare = "1.4.1" versioncompare = "1.4.1"
jna = "5.16.0" jna = "5.17.0"
jSystemThemeDetector = "3.9.1" jSystemThemeDetector = "3.9.1"
commons-io = "2.18.0" commons-io = "2.19.0"
jbr-api = "17.1.10.1" jbr-api = "17.1.10.1"
leveldb = "0.12" hutool = "5.8.37"
guava = "33.3.1-jre" jsch = "0.2.26"
credential-secure-storage = "1.0.3"
hutool = "5.8.34"
jsch = "0.2.21"
okhttp = "4.12.0" okhttp = "4.12.0"
bcprov = "1.79"
sshj = "0.39.0" sshj = "0.39.0"
sshd-core = "2.14.0" sshd-core = "2.15.0"
jgit = "7.1.0.202411261347-r" jgit = "7.2.0.202503040940-r"
commonmark = "0.24.0" commonmark = "0.24.0"
jnafilechooser = "1.1.2" jnafilechooser = "1.1.2"
xodus = "2.0.1" xodus = "2.0.1"
bip39 = "1.0.8" bip39 = "1.0.9"
colorpicker = "2.0.1" colorpicker = "2.0.1"
rhino = "1.7.15" rhino = "1.8.0"
delight-rhino-sandbox = "0.0.17" delight-rhino-sandbox = "0.0.17"
testcontainers = "1.20.4" testcontainers = "1.21.0"
mixpanel = "1.5.3" mixpanel = "1.5.3"
jSerialComm = "2.11.0" jSerialComm = "2.11.0"
ini4j = "0.5.5-2" ini4j = "0.5.5-2"
restart4j = "0.0.1" restart4j = "0.0.1"
eddsa = "0.3.0"
[libraries] [libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
@@ -59,15 +55,13 @@ commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version.
commons-csv = { group = "org.apache.commons", name = "commons-csv", version.ref = "commons-csv" } 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-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-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" } pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" }
ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" } ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" }
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" } flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", 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-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" }
testcontainers = { module = "org.testcontainers:testcontainers" } testcontainers = { module = "org.testcontainers:testcontainers" }
koin-core = { module = "io.insert-koin:koin-core" }
swingx = { module = "org.swinglabs.swingx:swingx-all", version.ref = "swingx" } swingx = { module = "org.swinglabs.swingx:swingx-all", version.ref = "swingx" }
jgoodies-forms = { module = "com.jgoodies:jgoodies-forms", version.ref = "jgoodies-forms" } jgoodies-forms = { module = "com.jgoodies:jgoodies-forms", version.ref = "jgoodies-forms" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
@@ -80,31 +74,27 @@ commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" }
restart4j = { module = "com.github.hstyi:restart4j", version.ref = "restart4j" } restart4j = { module = "com.github.hstyi:restart4j", version.ref = "restart4j" }
jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" } jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" }
flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" } 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" } 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" } jsch = { module = "com.github.mwiede:jsch", version.ref = "jsch" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", 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" } sshj = { module = "com.hierynomus:sshj", version.ref = "sshj" }
sshd-core = { module = "org.apache.sshd:sshd-core", version.ref = "sshd-core" } sshd-core = { module = "org.apache.sshd:sshd-core", version.ref = "sshd-core" }
jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" } jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" }
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
jgit-sshd = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache", version.ref = "jgit" } 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-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-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" } xodus-vfs = { module = "org.jetbrains.xodus:xodus-vfs", version.ref = "xodus" }
jnafilechooser = { module = "com.github.steos.jnafilechooser:jnafilechooser-api", version.ref = "jnafilechooser" } 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" } rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" } delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" } colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" } mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" } jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" }
eddsa = { module = "net.i2p.crypto:eddsa", version.ref = "eddsa" }
[plugins] [plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
} }
rootProject.name = "termora" 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

@@ -2,12 +2,6 @@ package app.termora
object Actions { object Actions {
/**
* 将命令发送到多个会话
*/
const val MULTIPLE = "MultipleAction"
/** /**
* 关键词高亮 * 关键词高亮
*/ */

View File

@@ -3,7 +3,6 @@ package app.termora
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jthemedetecor.util.OsInfo import com.jthemedetecor.util.OsInfo
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -123,19 +122,18 @@ object Application {
return "Termora" return "Termora"
} }
@Suppress("OPT_IN_USAGE")
fun browse(uri: URI, async: Boolean = true) { fun browse(uri: URI, async: Boolean = true) {
// https://github.com/TermoraDev/termora/issues/178 // https://github.com/TermoraDev/termora/issues/178
if (SystemInfo.isWindows && uri.scheme == "file") { if (SystemInfo.isWindows && uri.scheme == "file") {
if (async) { if (async) {
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) } swingCoroutineScope.launch(Dispatchers.IO) { tryBrowse(uri) }
} else { } else {
tryBrowse(uri) tryBrowse(uri)
} }
} else if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { } else if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(uri) Desktop.getDesktop().browse(uri)
} else if (async) { } else if (async) {
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) } swingCoroutineScope.launch(Dispatchers.IO) { tryBrowse(uri) }
} else { } else {
tryBrowse(uri) tryBrowse(uri)
} }
@@ -150,6 +148,16 @@ object Application {
ProcessBuilder("xdg-open", uri.toString()).start() 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 { fun formatBytes(bytes: Long): String {
@@ -159,7 +167,7 @@ fun formatBytes(bytes: Long): String {
val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt() val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt()
val value = bytes / 1024.0.pow(exp.toDouble()) 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 { fun formatSeconds(seconds: Long): String {
@@ -168,11 +176,33 @@ fun formatSeconds(seconds: Long): String {
val minutes = (seconds % 3600) / 60 val minutes = (seconds % 3600) / 60
val remainingSeconds = seconds % 60 val remainingSeconds = seconds % 60
return when { return when {
days > 0 -> "${days}${hours}小时${minutes}${remainingSeconds}" days > 0 -> I18n.getString(
hours > 0 -> "${hours}小时${minutes}${remainingSeconds}" "termora.transport.jobs.table.estimated-time-days-format",
minutes > 0 -> "${minutes}${remainingSeconds}" days,
else -> "${remainingSeconds}" 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

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

@@ -2,55 +2,49 @@ package app.termora
import app.termora.actions.ActionManager import app.termora.actions.ActionManager
import app.termora.keymap.KeymapManager import app.termora.keymap.KeymapManager
import app.termora.vfs2.sftp.MySftpFileProvider
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatSystemProperties import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatDesktop import com.formdev.flatlaf.extras.FlatDesktop
import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse
import com.formdev.flatlaf.extras.FlatInspector import com.formdev.flatlaf.extras.FlatInspector
import com.formdev.flatlaf.ui.FlatTableCellBorder
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jthemedetecor.OsThemeDetector import com.jthemedetecor.OsThemeDetector
import com.mixpanel.mixpanelapi.ClientDelivery import com.mixpanel.mixpanelapi.ClientDelivery
import com.mixpanel.mixpanelapi.MessageBuilder import com.mixpanel.mixpanelapi.MessageBuilder
import com.mixpanel.mixpanelapi.MixpanelAPI import com.mixpanel.mixpanelapi.MixpanelAPI
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.LocaleUtils import org.apache.commons.lang3.LocaleUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
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.json.JSONObject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.tinylog.configuration.Configuration import java.awt.MenuItem
import java.awt.KeyboardFocusManager import java.awt.PopupMenu
import java.io.File import java.awt.SystemTray
import java.nio.channels.FileChannel import java.awt.TrayIcon
import java.nio.channels.FileLock import java.awt.desktop.AppReopenedEvent
import java.nio.file.Paths import java.awt.desktop.AppReopenedListener
import java.nio.file.StandardOpenOption import java.awt.desktop.SystemEventListener
import java.awt.event.ActionEvent
import java.awt.event.WindowEvent
import java.util.* import java.util.*
import java.util.function.Consumer import java.util.concurrent.CountDownLatch
import javax.imageio.ImageIO
import javax.swing.* import javax.swing.*
import kotlin.system.exitProcess import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
class ApplicationRunner { class ApplicationRunner {
private lateinit var singletonChannel: FileChannel private val log by lazy { LoggerFactory.getLogger(ApplicationRunner::class.java) }
private lateinit var singletonLock: FileLock
private val log by lazy {
if (!::singletonLock.isInitialized) {
throw UnsupportedOperationException("Singleton lock is not initialized")
}
LoggerFactory.getLogger(ApplicationRunner::class.java)
}
fun run() { fun run() {
measureTimeMillis { measureTimeMillis {
// 覆盖 tinylog 配置
val setupTinylog = measureTimeMillis { setupTinylog() }
// 是否单例
val checkSingleton = measureTimeMillis { checkSingleton() }
// 打印系统信息 // 打印系统信息
val printSystemInfo = measureTimeMillis { printSystemInfo() } val printSystemInfo = measureTimeMillis { printSystemInfo() }
@@ -64,11 +58,20 @@ class ApplicationRunner {
// 统计 // 统计
val enableAnalytics = measureTimeMillis { enableAnalytics() } val enableAnalytics = measureTimeMillis { enableAnalytics() }
// init ActionManager、KeymapManager // init ActionManager、KeymapManager、VFS
@Suppress("OPT_IN_USAGE") swingCoroutineScope.launch(Dispatchers.IO) {
GlobalScope.launch(Dispatchers.IO) {
ActionManager.getInstance() ActionManager.getInstance()
KeymapManager.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 // 设置 LAF
@@ -84,8 +87,6 @@ class ApplicationRunner {
val startMainFrame = measureTimeMillis { startMainFrame() } val startMainFrame = measureTimeMillis { startMainFrame() }
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("setupTinylog: {}ms", setupTinylog)
log.debug("checkSingleton: {}ms", checkSingleton)
log.debug("printSystemInfo: {}ms", printSystemInfo) log.debug("printSystemInfo: {}ms", printSystemInfo)
log.debug("openDatabase: {}ms", openDatabase) log.debug("openDatabase: {}ms", openDatabase)
log.debug("loadSettings: {}ms", loadSettings) log.debug("loadSettings: {}ms", loadSettings)
@@ -101,9 +102,8 @@ class ApplicationRunner {
} }
} }
@Suppress("OPT_IN_USAGE")
private fun clearTemporary() { private fun clearTemporary() {
GlobalScope.launch(Dispatchers.IO) { swingCoroutineScope.launch(Dispatchers.IO) {
// 启动时清除 // 启动时清除
FileUtils.cleanDirectory(Application.getTemporaryDir()) FileUtils.cleanDirectory(Application.getTemporaryDir())
} }
@@ -120,36 +120,69 @@ class ApplicationRunner {
private fun startMainFrame() { private fun startMainFrame() {
TermoraFrameManager.getInstance().createWindow().isVisible = true TermoraFrameManager.getInstance().createWindow().isVisible = true
if (SystemUtils.IS_OS_MAC_OSX) { if (SystemInfo.isMacOS) {
SwingUtilities.invokeLater { SwingUtilities.invokeLater {
FlatDesktop.setQuitHandler(object : Consumer<QuitResponse> {
override fun accept(response: QuitResponse) { try {
quitHandler(response) // 设置 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 quitHandler(response: QuitResponse) { private fun setupSystemTray() {
val keyboardFocusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() if (!SystemInfo.isWindows || !SystemTray.isSupported()) return
if (OptionPane.showConfirmDialog( val tray = SystemTray.getSystemTray()
keyboardFocusManager.focusedWindow, val image = ImageIO.read(TermoraFrame::class.java.getResourceAsStream("/icons/termora_16x16.png"))
I18n.getString("termora.quit-confirm", Application.getName()), val trayIcon = TrayIcon(image)
optionType = JOptionPane.YES_NO_OPTION, val popupMenu = PopupMenu()
) != JOptionPane.YES_OPTION trayIcon.popupMenu = popupMenu
) { trayIcon.toolTip = Application.getName()
response.cancelQuit()
return // PopupMenu 不支持中文
} val exitMenu = MenuItem("Exit")
exitMenu.addActionListener { SwingUtilities.invokeLater { quitHandler() } }
for (frame in TermoraFrameManager.getInstance().getWindows()) { popupMenu.add(exitMenu)
frame.dispose()
// 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() { private fun loadSettings() {
@@ -164,7 +197,7 @@ class ApplicationRunner {
private fun setupLaf() { 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") System.setProperty(FlatSystemProperties.USE_ROUNDED_POPUP_BORDER, "false")
if (SystemInfo.isLinux) { if (SystemInfo.isLinux) {
@@ -186,6 +219,7 @@ class ApplicationRunner {
themeManager.change(theme, true) themeManager.change(theme, true)
if (Application.isUnknownVersion()) if (Application.isUnknownVersion())
FlatInspector.install("ctrl shift alt X") FlatInspector.install("ctrl shift alt X")
@@ -218,9 +252,8 @@ class ApplicationRunner {
} }
UIManager.put("Table.rowHeight", 24) UIManager.put("Table.rowHeight", 24)
UIManager.put("Table.cellNoFocusBorder", BorderFactory.createEmptyBorder()) UIManager.put("Table.focusCellHighlightBorder", FlatTableCellBorder.Default())
UIManager.put("Table.focusCellHighlightBorder", BorderFactory.createEmptyBorder()) UIManager.put("Table.focusSelectedCellHighlightBorder", FlatTableCellBorder.Default())
UIManager.put("Table.focusSelectedCellHighlightBorder", BorderFactory.createEmptyBorder())
UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc")) UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Tree.rowHeight", 24) UIManager.put("Tree.rowHeight", 24)
@@ -231,7 +264,35 @@ class ApplicationRunner {
UIManager.put("List.selectionArc", UIManager.getInt("Component.arc")) 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() { private fun printSystemInfo() {
@@ -254,36 +315,6 @@ class ApplicationRunner {
} }
/**
* 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() {
singletonChannel = FileChannel.open(
Paths.get(Application.getBaseDataDir().absolutePath, "lock"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
)
val lock = singletonChannel.tryLock()
if (lock == null) {
System.err.println("Program is already running")
exitProcess(1)
}
singletonLock = lock
}
private fun openDatabase() { private fun openDatabase() {
try { try {
Database.getDatabase() Database.getDatabase()
@@ -302,13 +333,12 @@ class ApplicationRunner {
/** /**
* 统计 https://mixpanel.com * 统计 https://mixpanel.com
*/ */
@OptIn(DelicateCoroutinesApi::class)
private fun enableAnalytics() { private fun enableAnalytics() {
if (Application.isUnknownVersion()) { if (Application.isUnknownVersion()) {
return return
} }
GlobalScope.launch(Dispatchers.IO) { swingCoroutineScope.launch(Dispatchers.IO) {
try { try {
val properties = JSONObject() val properties = JSONObject()
properties.put("os", SystemUtils.OS_NAME) properties.put("os", SystemUtils.OS_NAME)

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

@@ -5,14 +5,14 @@ import app.termora.highlight.KeywordHighlight
import app.termora.keymap.Keymap import app.termora.keymap.Keymap
import app.termora.keymgr.OhKeyPair import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro import app.termora.macro.Macro
import app.termora.snippet.Snippet
import app.termora.sync.SyncManager
import app.termora.sync.SyncType import app.termora.sync.SyncType
import app.termora.terminal.CursorStyle import app.termora.terminal.CursorStyle
import jetbrains.exodus.bindings.StringBinding import jetbrains.exodus.bindings.StringBinding
import jetbrains.exodus.env.* import jetbrains.exodus.env.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -26,9 +26,11 @@ class Database private constructor(private val env: Environment) : Disposable {
companion object { companion object {
private const val KEYMAP_STORE = "Keymap" private const val KEYMAP_STORE = "Keymap"
private const val HOST_STORE = "Host" private const val HOST_STORE = "Host"
private const val SNIPPET_STORE = "Snippet"
private const val KEYWORD_HIGHLIGHT_STORE = "KeywordHighlight" private const val KEYWORD_HIGHLIGHT_STORE = "KeywordHighlight"
private const val MACRO_STORE = "Macro" private const val MACRO_STORE = "Macro"
private const val KEY_PAIR_STORE = "KeyPair" private const val KEY_PAIR_STORE = "KeyPair"
private const val DELETED_DATA_STORE = "DeletedData"
private val log = LoggerFactory.getLogger(Database::class.java) private val log = LoggerFactory.getLogger(Database::class.java)
@@ -105,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() { fun removeAllKeyPair() {
env.executeInTransaction { tx -> env.executeInTransaction { tx ->
val store = env.openStore(KEY_PAIR_STORE, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx) val store = env.openStore(KEY_PAIR_STORE, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
@@ -156,11 +147,67 @@ class Database private constructor(private val env: Environment) : Disposable {
env.executeInTransaction { env.executeInTransaction {
delete(it, HOST_STORE, id) delete(it, HOST_STORE, id)
if (log.isDebugEnabled) { 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> { fun getKeywordHighlights(): Collection<KeywordHighlight> {
return env.computeInTransaction { tx -> return env.computeInTransaction { tx ->
openCursor<KeywordHighlight>(tx, KEYWORD_HIGHLIGHT_STORE) { _, value -> openCursor<KeywordHighlight>(tx, KEYWORD_HIGHLIGHT_STORE) { _, value ->
@@ -242,6 +289,18 @@ class Database private constructor(private val env: Environment) : Disposable {
val k = StringBinding.stringToEntry(key) val k = StringBinding.stringToEntry(key)
val v = StringBinding.stringToEntry(value) val v = StringBinding.stringToEntry(value)
store.put(tx, k, v) 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) { private fun delete(tx: Transaction, name: String, key: String) {
@@ -301,8 +360,7 @@ class Database private constructor(private val env: Environment) : Disposable {
private val properties = Collections.synchronizedMap(mutableMapOf<String, String>()) private val properties = Collections.synchronizedMap(mutableMapOf<String, String>())
init { init {
@Suppress("OPT_IN_USAGE") swingCoroutineScope.launch(Dispatchers.IO) { properties.putAll(getProperties()) }
GlobalScope.launch(Dispatchers.IO) { properties.putAll(getProperties()) }
} }
protected open fun getString(key: String): String? { protected open fun getString(key: String): String? {
@@ -374,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) : protected inner class LongPropertyDelegate(defaultValue: Long) :
PropertyDelegate<Long>(defaultValue) { PropertyDelegate<Long>(defaultValue) {
@@ -573,6 +638,16 @@ class Database private constructor(private val env: Environment) : Disposable {
var darkTheme by StringPropertyDelegate("Dark") var darkTheme by StringPropertyDelegate("Dark")
var lightTheme by StringPropertyDelegate("Light") var lightTheme by StringPropertyDelegate("Light")
/**
* 允许后台运行,也就是托盘
*/
var backgroundRunning by BooleanPropertyDelegate(false)
/**
* 背景图片的地址
*/
var backgroundImage by StringPropertyDelegate(StringUtils.EMPTY)
/** /**
* 语言 * 语言
*/ */
@@ -580,6 +655,11 @@ class Database private constructor(private val env: Environment) : Disposable {
I18n.containsLanguage(Locale.getDefault()) ?: Locale.US.toString() I18n.containsLanguage(Locale.getDefault()) ?: Locale.US.toString()
} }
/**
* 透明度
*/
var opacity by DoublePropertyDelegate(1.0)
} }
/** /**
@@ -599,12 +679,22 @@ class Database private constructor(private val env: Environment) : Disposable {
*/ */
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY) var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* defaultDirectory
*/
var defaultDirectory by StringPropertyDelegate(StringUtils.EMPTY)
/** /**
* 是否固定在标签栏 * 是否固定在标签栏
*/ */
var pinTab by BooleanPropertyDelegate(false) var pinTab by BooleanPropertyDelegate(false)
/**
* 是否保留原始文件时间
*/
var preserveModificationTime by BooleanPropertyDelegate(false)
} }
/** /**
@@ -621,6 +711,7 @@ class Database private constructor(private val env: Environment) : Disposable {
*/ */
var rangeHosts by BooleanPropertyDelegate(true) var rangeHosts by BooleanPropertyDelegate(true)
var rangeKeyPairs by BooleanPropertyDelegate(true) var rangeKeyPairs by BooleanPropertyDelegate(true)
var rangeSnippets by BooleanPropertyDelegate(true)
var rangeKeywordHighlights by BooleanPropertyDelegate(true) var rangeKeywordHighlights by BooleanPropertyDelegate(true)
var rangeMacros by BooleanPropertyDelegate(true) var rangeMacros by BooleanPropertyDelegate(true)
var rangeKeymap by BooleanPropertyDelegate(true) var rangeKeymap by BooleanPropertyDelegate(true)
@@ -644,6 +735,11 @@ class Database private constructor(private val env: Environment) : Disposable {
* 最后同步时间 * 最后同步时间
*/ */
var lastSyncTime by LongPropertyDelegate(0L) var lastSyncTime by LongPropertyDelegate(0L)
/**
* 同步策略,为空就是默认手动
*/
var policy by StringPropertyDelegate(StringUtils.EMPTY)
} }
override fun dispose() { 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

@@ -2,8 +2,8 @@ package app.termora
import app.termora.actions.AnAction import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.native.osx.NativeMacLibrary
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR import com.jetbrains.JBR
import java.awt.* import java.awt.*
@@ -12,29 +12,60 @@ import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent import java.awt.event.WindowEvent
import javax.swing.* import javax.swing.*
abstract class DialogWrapper(owner: Window?) : JDialog(owner) { abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
private val rootPanel = JPanel(BorderLayout())
private val titleLabel = JLabel() private val titleLabel = JLabel()
private val titleBar by lazy { LogicCustomTitleBar.createCustomTitleBar(this) }
val disposable = Disposer.newDisposable() val disposable = Disposer.newDisposable()
private val customTitleBar = if (SystemInfo.isMacOS && JBR.isWindowDecorationsSupported())
JBR.getWindowDecorations().createCustomTitleBar() else null
companion object { companion object {
const val DEFAULT_ACTION = "DEFAULT_ACTION" const val DEFAULT_ACTION = "DEFAULT_ACTION"
private const val PROCESS_GLOBAL_KEYMAP = "PROCESS_GLOBAL_KEYMAP" private const val PROCESS_GLOBAL_KEYMAP = "PROCESS_GLOBAL_KEYMAP"
} }
protected var controlsVisible = true protected var controlsVisible = true
set(value) { set(value) {
field = 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) { set(value) {
titleBar.height = value
field = 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 lostFocusDispose = false
@@ -51,25 +82,43 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
super.rootPane.putClientProperty(PROCESS_GLOBAL_KEYMAP, 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() { protected fun init() {
defaultCloseOperation = DISPOSE_ON_CLOSE
initTitleBar()
initEvents() initEvents()
if (JBR.isWindowDecorationsSupported()) { val rootPanel = JPanel(BorderLayout())
if (rootPane.getClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE) != false) { rootPanel.add(createCenterPanel(), BorderLayout.CENTER)
val titlePanel = createTitlePanel()
if (titlePanel != null) { if (SystemInfo.isMacOS) {
rootPanel.add(titlePanel, BorderLayout.NORTH) 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)
} }
} }
rootPanel.add(createCenterPanel(), BorderLayout.CENTER)
val southPanel = createSouthPanel() val southPanel = createSouthPanel()
if (southPanel != null) { if (southPanel != null) {
rootPanel.add(southPanel, BorderLayout.SOUTH) rootPanel.add(southPanel, BorderLayout.SOUTH)
@@ -122,7 +171,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
val panel = JPanel(BorderLayout()) val panel = JPanel(BorderLayout())
panel.add(titleLabel, BorderLayout.CENTER) panel.add(titleLabel, BorderLayout.CENTER)
panel.preferredSize = Dimension(-1, titleBar.height.toInt()) panel.preferredSize = Dimension(-1, titleBarHeight)
return panel return panel
@@ -173,7 +222,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
return return
} }
doCancelAction() SwingUtilities.invokeLater { doCancelAction() }
} }
}) })
@@ -191,30 +240,20 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
} }
}) })
if (SystemInfo.isWindows) {
addWindowListener(object : WindowAdapter(), ThemeChangeListener {
override fun windowClosed(e: WindowEvent) {
ThemeManager.getInstance().removeThemeChangeListener(this)
}
override fun windowOpened(e: WindowEvent) {
onChanged()
ThemeManager.getInstance().addThemeChangeListener(this)
}
override fun onChanged() {
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
}
})
}
} }
private fun initTitleBar() { override fun addNotify() {
titleBar.height = titleBarHeight super.addNotify()
titleBar.putProperty("controls.visible", controlsVisible)
if (JBR.isWindowDecorationsSupported()) { // 显示后触发一次重绘制
JBR.getWindowDecorations().setCustomTitleBar(this, titleBar) if (SystemInfo.isWindows || SystemInfo.isLinux) {
this.controlsVisible = controlsVisible
this.titleBarHeight = titleBarHeight
this.titleIconVisible = titleIconVisible
this.titleVisible = titleVisible
this.fullWindowContent = fullWindowContent
} }
} }
protected open fun doOKAction() { protected open fun doOKAction() {

View File

@@ -1,5 +1,8 @@
package app.termora package app.termora
import org.apache.commons.lang3.StringUtils
@Suppress("CascadeIf")
class EditHostOptionsPane(private val host: Host) : HostOptionsPane() { class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
init { init {
generalOption.portTextField.value = host.port generalOption.portTextField.value = host.port
@@ -13,6 +16,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
generalOption.passwordTextField.text = host.authentication.password generalOption.passwordTextField.text = host.authentication.password
} else if (host.authentication.type == AuthenticationType.PublicKey) { } else if (host.authentication.type == AuthenticationType.PublicKey) {
generalOption.publicKeyComboBox.selectedItem = host.authentication.password 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 proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
@@ -28,6 +33,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
tunnelingOption.tunnelings.addAll(host.tunnelings) 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()) { if (host.options.jumpHosts.isNotEmpty()) {
val hosts = HostManager.getInstance().hosts().associateBy { it.id } val hosts = HostManager.getInstance().hosts().associateBy { it.id }
@@ -47,6 +54,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
serialCommOption.parityComboBox.selectedItem = serialComm.parity serialCommOption.parityComboBox.selectedItem = serialComm.parity
serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits
serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
} }
override fun getHost(): Host { override fun getHost(): Host {

View File

@@ -41,7 +41,7 @@ class FilterableHostTreeModel(
continue continue
} }
if (c.host.protocol != Protocol.Folder) { if (c.data.protocol != Protocol.Folder) {
if (filters.isNotEmpty() && filters.none { it.apply(c) }) { if (filters.isNotEmpty() && filters.none { it.apply(c) }) {
continue continue
} }

View File

@@ -25,6 +25,7 @@ enum class Protocol {
SSH, SSH,
Local, Local,
Serial, Serial,
RDP,
/** /**
* 交互式的 SFTP此协议只在系统内部交互不应该暴露给用户也不应该持久化 * 交互式的 SFTP此协议只在系统内部交互不应该暴露给用户也不应该持久化
@@ -38,6 +39,7 @@ enum class AuthenticationType {
No, No,
Password, Password,
PublicKey, PublicKey,
SSHAgent,
KeyboardInteractive, KeyboardInteractive,
} }
@@ -132,6 +134,21 @@ data class Options(
* 串口配置 * 串口配置
*/ */
val serialComm: SerialComm = SerialComm(), 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 { companion object {
val Default = Options() val Default = Options()
@@ -209,6 +226,27 @@ data class EncryptedHost(
var updateDate: Long = 0L, 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 @Serializable
data class Host( data class Host(

View File

@@ -2,15 +2,17 @@ package app.termora
import app.termora.actions.AnAction import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.keyboardinteractive.TerminalUserInteraction import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.* import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.client.SshClient import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession import org.apache.sshd.client.session.ClientSession
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Dimension import java.awt.Dimension
import java.awt.Window import java.awt.Window
import java.util.*
import javax.swing.* import javax.swing.*
class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) { class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
@@ -52,9 +54,9 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...") putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...")
isEnabled = false isEnabled = false
@OptIn(DelicateCoroutinesApi::class) swingCoroutineScope.launch(Dispatchers.IO) {
GlobalScope.launch(Dispatchers.IO) { // 因为测试连接的时候从数据库读取会导致失效所以这里生成随机ID
testConnection(pane.getHost()) testConnection(pane.getHost().copy(id = UUID.randomUUID().toSimpleString()))
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
putValue(NAME, I18n.getString("termora.new-host.test-connection")) putValue(NAME, I18n.getString("termora.new-host.test-connection"))
isEnabled = true isEnabled = true
@@ -103,8 +105,7 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
var client: SshClient? = null var client: SshClient? = null
var session: ClientSession? = null var session: ClientSession? = null
try { try {
client = SshClients.openClient(host) client = SshClients.openClient(host, this)
client.userInteraction = TerminalUserInteraction(owner)
session = SshClients.openSession(host, client) session = SshClients.openSession(host, client)
} finally { } finally {
session?.close() session?.close()

View File

@@ -16,14 +16,20 @@ class HostManager private constructor() {
*/ */
fun addHost(host: Host) { fun addHost(host: Host) {
assertEventDispatchThread() assertEventDispatchThread()
database.addHost(host)
if (host.deleted) { if (host.deleted) {
hosts.entries.removeIf { it.value.id == host.id || it.value.parentId == host.id } removeHost(host.id)
} else { } else {
database.addHost(host)
hosts[host.id] = host hosts[host.id] = host
} }
} }
fun removeHost(id: String) {
hosts.entries.removeIf { it.value.id == id || it.value.parentId == id }
database.removeHost(id)
DeleteDataManager.getInstance().removeHost(id)
}
/** /**
* 第一次调用从数据库中获取,后续从缓存中获取 * 第一次调用从数据库中获取,后续从缓存中获取
*/ */

View File

@@ -6,14 +6,17 @@ import com.fazecast.jSerialComm.SerialPort
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.ui.FlatTextBorder import com.formdev.flatlaf.ui.FlatTextBorder
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils 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.*
import java.awt.event.* import java.awt.event.*
import java.nio.charset.Charset import java.nio.charset.Charset
@@ -21,7 +24,7 @@ import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel import javax.swing.table.DefaultTableModel
@Suppress("CascadeIf")
open class HostOptionsPane : OptionsPane() { open class HostOptionsPane : OptionsPane() {
protected val tunnelingOption = TunnelingOption() protected val tunnelingOption = TunnelingOption()
protected val generalOption = GeneralOption() protected val generalOption = GeneralOption()
@@ -29,6 +32,7 @@ open class HostOptionsPane : OptionsPane() {
protected val terminalOption = TerminalOption() protected val terminalOption = TerminalOption()
protected val jumpHostsOption = JumpHostsOption() protected val jumpHostsOption = JumpHostsOption()
protected val serialCommOption = SerialCommOption() protected val serialCommOption = SerialCommOption()
protected val sftpOption = SFTPOption()
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this) protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init { init {
@@ -38,6 +42,7 @@ open class HostOptionsPane : OptionsPane() {
addOption(jumpHostsOption) addOption(jumpHostsOption)
addOption(terminalOption) addOption(terminalOption)
addOption(serialCommOption) addOption(serialCommOption)
addOption(sftpOption)
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)) setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
} }
@@ -50,18 +55,23 @@ open class HostOptionsPane : OptionsPane() {
val port = (generalOption.portTextField.value ?: 22) as Int val port = (generalOption.portTextField.value ?: 22) as Int
var authentication = Authentication.No var authentication = Authentication.No
var proxy = Proxy.No var proxy = Proxy.No
val authenticationType = generalOption.authenticationTypeComboBox.selectedItem as AuthenticationType
if (authenticationType == AuthenticationType.Password) {
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
authentication = authentication.copy( authentication = authentication.copy(
type = AuthenticationType.Password, type = authenticationType,
password = String(generalOption.passwordTextField.password) password = String(generalOption.passwordTextField.password)
) )
} else if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) { } else if (authenticationType == AuthenticationType.PublicKey) {
authentication = authentication.copy( authentication = authentication.copy(
type = AuthenticationType.PublicKey, type = authenticationType,
password = generalOption.publicKeyComboBox.selectedItem?.toString() ?: StringUtils.EMPTY password = generalOption.publicKeyComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
) )
} else if (authenticationType == AuthenticationType.SSHAgent) {
authentication = authentication.copy(
type = authenticationType,
password = generalOption.sshAgentComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
)
} }
if (proxyOption.proxyTypeComboBox.selectedItem != ProxyType.No) { if (proxyOption.proxyTypeComboBox.selectedItem != ProxyType.No) {
@@ -91,7 +101,10 @@ open class HostOptionsPane : OptionsPane() {
startupCommand = terminalOption.startupCommandTextField.text, startupCommand = terminalOption.startupCommandTextField.text,
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int, heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
jumpHosts = jumpHostsOption.jumpHosts.map { it.id }, jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
serialComm = serialComm serialComm = serialComm,
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text,
enableX11Forwarding = tunnelingOption.x11ForwardingCheckBox.isSelected,
x11Forwarding = tunnelingOption.x11ServerTextField.text,
) )
return Host( return Host(
@@ -157,6 +170,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 return true
} }
@@ -166,14 +190,18 @@ open class HostOptionsPane : OptionsPane() {
*/ */
private fun validateField(textField: JTextField): Boolean { private fun validateField(textField: JTextField): Boolean {
if (textField.isEnabled && textField.text.isBlank()) { if (textField.isEnabled && textField.text.isBlank()) {
selectOptionJComponent(textField) setOutlineError(textField)
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
textField.requestFocusInWindow()
return true return true
} }
return false return false
} }
private fun setOutlineError(textField: JTextField) {
selectOptionJComponent(textField)
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
textField.requestFocusInWindow()
}
/** /**
* 返回 true 表示有错误 * 返回 true 表示有错误
*/ */
@@ -197,6 +225,7 @@ open class HostOptionsPane : OptionsPane() {
private val passwordPanel = JPanel(BorderLayout()) private val passwordPanel = JPanel(BorderLayout())
private val chooseKeyBtn = JButton(Icons.greyKey) private val chooseKeyBtn = JButton(Icons.greyKey)
val passwordTextField = OutlinePasswordField(255) val passwordTextField = OutlinePasswordField(255)
val sshAgentComboBox = OutlineComboBox<String>()
val publicKeyComboBox = OutlineComboBox<String>() val publicKeyComboBox = OutlineComboBox<String>()
val remarkTextArea = FixedLengthTextArea(512) val remarkTextArea = FixedLengthTextArea(512)
val authenticationTypeComboBox = FlatComboBox<AuthenticationType>() val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
@@ -212,6 +241,10 @@ open class HostOptionsPane : OptionsPane() {
publicKeyComboBox.isEditable = false publicKeyComboBox.isEditable = false
chooseKeyBtn.isFocusable = false chooseKeyBtn.isFocusable = false
// 只有 Windows 允许修改
sshAgentComboBox.isEditable = SystemInfo.isWindows
sshAgentComboBox.isEnabled = SystemInfo.isWindows
protocolTypeComboBox.renderer = object : DefaultListCellRenderer() { protocolTypeComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent( override fun getListCellRendererComponent(
list: JList<*>?, list: JList<*>?,
@@ -287,10 +320,22 @@ open class HostOptionsPane : OptionsPane() {
protocolTypeComboBox.addItem(Protocol.SSH) protocolTypeComboBox.addItem(Protocol.SSH)
protocolTypeComboBox.addItem(Protocol.Local) protocolTypeComboBox.addItem(Protocol.Local)
protocolTypeComboBox.addItem(Protocol.Serial) protocolTypeComboBox.addItem(Protocol.Serial)
protocolTypeComboBox.addItem(Protocol.RDP)
authenticationTypeComboBox.addItem(AuthenticationType.No) authenticationTypeComboBox.addItem(AuthenticationType.No)
authenticationTypeComboBox.addItem(AuthenticationType.Password) authenticationTypeComboBox.addItem(AuthenticationType.Password)
authenticationTypeComboBox.addItem(AuthenticationType.PublicKey) 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 authenticationTypeComboBox.selectedItem = AuthenticationType.Password
@@ -454,6 +499,8 @@ open class HostOptionsPane : OptionsPane() {
.add(chooseKeyBtn).xy(3, 1) .add(chooseKeyBtn).xy(3, 1)
.build(), BorderLayout.CENTER .build(), BorderLayout.CENTER
) )
} else if (authenticationTypeComboBox.selectedItem == AuthenticationType.SSHAgent) {
passwordPanel.add(sshAgentComboBox, BorderLayout.CENTER)
} else { } else {
passwordPanel.add(passwordTextField, BorderLayout.CENTER) passwordPanel.add(passwordTextField, BorderLayout.CENTER)
} }
@@ -669,8 +716,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 { protected inner class TunnelingOption : JPanel(BorderLayout()), Option {
val tunnelings = mutableListOf<Tunneling>() val tunnelings = mutableListOf<Tunneling>()
val x11ForwardingCheckBox = JCheckBox("X DISPLAY:")
val x11ServerTextField = OutlineTextField(255)
private val model = object : DefaultTableModel() { private val model = object : DefaultTableModel() {
override fun getRowCount(): Int { override fun getRowCount(): Int {
@@ -745,13 +842,36 @@ open class HostOptionsPane : OptionsPane() {
box.add(Box.createHorizontalStrut(4)) box.add(Box.createHorizontalStrut(4))
box.add(deleteBtn) box.add(deleteBtn)
add(JLabel("TCP/IP Forwarding:"), BorderLayout.NORTH) x11ForwardingCheckBox.isFocusable = false
add(scrollPane, BorderLayout.CENTER)
add(box, BorderLayout.SOUTH) 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() { private fun initEvents() {
x11ForwardingCheckBox.addChangeListener { x11ServerTextField.isEnabled = x11ForwardingCheckBox.isSelected }
addBtn.addActionListener(object : AbstractAction() { addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent?) { override fun actionPerformed(e: ActionEvent?) {
val dialog = PortForwardingDialog(SwingUtilities.getWindowAncestor(this@HostOptionsPane)) val dialog = PortForwardingDialog(SwingUtilities.getWindowAncestor(this@HostOptionsPane))
@@ -1003,8 +1123,7 @@ open class HostOptionsPane : OptionsPane() {
addComponentListener(object : ComponentAdapter() { addComponentListener(object : ComponentAdapter() {
override fun componentShown(e: ComponentEvent) { override fun componentShown(e: ComponentEvent) {
removeComponentListener(this) removeComponentListener(this)
@Suppress("OPT_IN_USAGE") swingCoroutineScope.launch(Dispatchers.IO) {
GlobalScope.launch(Dispatchers.IO) {
for (commPort in SerialPort.getCommPorts()) { for (commPort in SerialPort.getCommPorts()) {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
serialPortComboBox.addItem(commPort.systemPortName) serialPortComboBox.addItem(commPort.systemPortName)

View File

@@ -5,6 +5,7 @@ import app.termora.actions.DataProviders
import app.termora.terminal.* import app.termora.terminal.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import java.beans.PropertyChangeEvent import java.beans.PropertyChangeEvent
@@ -13,13 +14,13 @@ import javax.swing.Icon
abstract class HostTerminalTab( abstract class HostTerminalTab(
val windowScope: WindowScope, val windowScope: WindowScope,
val host: Host, val host: Host,
protected val terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal() protected val terminal: Terminal = TerminalFactory.getInstance().createTerminal()
) : PropertyTerminalTab(), DataProvider { ) : PropertyTerminalTab(), DataProvider {
companion object { companion object {
val Host = DataKey(app.termora.Host::class) val Host = DataKey(app.termora.Host::class)
} }
protected val coroutineScope by lazy { CoroutineScope(Dispatchers.Swing) } protected val coroutineScope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Swing) }
protected val terminalModel get() = terminal.getTerminalModel() protected val terminalModel get() = terminal.getTerminalModel()
protected var unread = false protected var unread = false
set(value) { set(value) {

View File

@@ -1,17 +1,29 @@
package app.termora package app.termora
import javax.swing.tree.DefaultMutableTreeNode import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeOpenIcon
import javax.swing.Icon
import javax.swing.tree.TreeNode import javax.swing.tree.TreeNode
class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) { class HostTreeNode(host: Host) : SimpleTreeNode<Host>(host) {
companion object { companion object {
private val hostManager get() = HostManager.getInstance() 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] 否则下次取出时可能时缓存的 * 如果要重新赋值,记得修改 [Host.updateDate] 否则下次取出时可能时缓存的
*/ */
var host: Host override var data: Host
get() { get() {
val cacheHost = hostManager.getHost((userObject as Host).id) val cacheHost = hostManager.getHost((userObject as Host).id)
val myHost = userObject as Host val myHost = userObject as Host
@@ -22,22 +34,24 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
} }
set(value) = setUserObject(value) set(value) = setUserObject(value)
val folderCount override val folderCount
get() = children().toList().count { if (it is HostTreeNode) it.host.protocol == Protocol.Folder else false } get() = children().toList().count { if (it is HostTreeNode) it.data.protocol == Protocol.Folder else false }
override fun getParent(): HostTreeNode? { override fun getParent(): HostTreeNode? {
return super.getParent() as HostTreeNode? return super.getParent() as HostTreeNode?
} }
fun getAllChildren(): List<HostTreeNode> { override fun getAllChildren(): List<HostTreeNode> {
val children = mutableListOf<HostTreeNode>() return super.getAllChildren().filterIsInstance<HostTreeNode>()
for (child in children()) { }
if (child is HostTreeNode) {
children.add(child) override fun getIcon(selected: Boolean, expanded: Boolean, hasFocus: Boolean): Icon {
children.addAll(child.getAllChildren()) 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
} }
return children
} }
fun childrenNode(): List<HostTreeNode> { fun childrenNode(): List<HostTreeNode> {
@@ -57,7 +71,7 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
private fun deepClone(newNode: HostTreeNode, oldNode: HostTreeNode, scopes: Set<Protocol> = emptySet()) { private fun deepClone(newNode: HostTreeNode, oldNode: HostTreeNode, scopes: Set<Protocol> = emptySet()) {
for (child in oldNode.childrenNode()) { for (child in oldNode.childrenNode()) {
if (scopes.isNotEmpty() && !scopes.contains(child.host.protocol)) continue if (scopes.isNotEmpty() && !scopes.contains(child.data.protocol)) continue
val newChildNode = child.clone() as HostTreeNode val newChildNode = child.clone() as HostTreeNode
deepClone(newChildNode, child, scopes) deepClone(newChildNode, child, scopes)
newNode.add(newChildNode) newNode.add(newChildNode)
@@ -65,7 +79,7 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
} }
override fun clone(): Any { override fun clone(): Any {
val newNode = HostTreeNode(host) val newNode = HostTreeNode(data)
newNode.children = null newNode.children = null
newNode.parent = null newNode.parent = null
return newNode return newNode
@@ -74,7 +88,7 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
override fun isNodeChild(aNode: TreeNode?): Boolean { override fun isNodeChild(aNode: TreeNode?): Boolean {
if (aNode is HostTreeNode) { if (aNode is HostTreeNode) {
for (node in childrenNode()) { for (node in childrenNode()) {
if (node.host == aNode.host) { if (node.data == aNode.data) {
return true return true
} }
} }
@@ -88,10 +102,10 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
other as HostTreeNode other as HostTreeNode
return host == other.host return data == other.data
} }
override fun hashCode(): Int { override fun hashCode(): Int {
return host.hashCode() return data.hashCode()
} }
} }

View File

@@ -10,9 +10,11 @@ object Icons {
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_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 moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_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 openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") }
val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_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 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 eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_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 matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") }
@@ -32,6 +34,7 @@ object Icons {
val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_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 text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_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 networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_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") } val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") }
@@ -50,6 +53,7 @@ object Icons {
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") } 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 colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_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 homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") }
val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_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 import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
@@ -60,6 +64,7 @@ object Icons {
val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_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 edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") }
val microsoft by lazy { DynamicIcon("icons/microsoft.svg", "icons/microsoft_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 tencent by lazy { DynamicIcon("icons/tencent.svg") }
val google by lazy { DynamicIcon("icons/google-small.svg") } val google by lazy { DynamicIcon("icons/google-small.svg") }
val aliyun by lazy { DynamicIcon("icons/aliyun.svg") } val aliyun by lazy { DynamicIcon("icons/aliyun.svg") }
@@ -90,10 +95,14 @@ object Icons {
val sortedSet by lazy { DynamicIcon("icons/sortedSet.svg", "icons/sortedSet_dark.svg") } 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 colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") }
val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_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 listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
val left by lazy { DynamicIcon("icons/left.svg", "icons/left_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 right by lazy { DynamicIcon("icons/right.svg", "icons/right_dark.svg") }
val dotListFiles by lazy { DynamicIcon("icons/dotListFiles.svg", "icons/dotListFiles_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 fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") } val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") }
val applyNotConflictsLeft by lazy { val applyNotConflictsLeft by lazy {

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

@@ -1,15 +1,25 @@
package app.termora package app.termora
import app.termora.terminal.PtyConnector import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate
import app.termora.terminal.PtyProcessConnector
import org.apache.commons.io.Charsets import org.apache.commons.io.Charsets
import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import kotlin.jvm.optionals.getOrNull
class LocalTerminalTab(windowScope: WindowScope, host: Host) : class LocalTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) { PtyHostTerminalTab(windowScope, host) {
companion object {
private val log = LoggerFactory.getLogger(LocalTerminalTab::class.java)
}
override suspend fun openPtyConnector(): PtyConnector { override suspend fun openPtyConnector(): PtyConnector {
val winSize = terminalPanel.winSize() val winSize = terminalPanel.winSize()
val ptyConnector = PtyConnectorFactory.getInstance(windowScope).createPtyConnector( val ptyConnector = PtyConnectorFactory.getInstance().createPtyConnector(
winSize.rows, winSize.cols, winSize.rows, winSize.cols,
host.options.envs(), host.options.envs(),
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8), Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
@@ -18,4 +28,42 @@ class LocalTerminalTab(windowScope: WindowScope, host: Host) :
return ptyConnector 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,58 +1,6 @@
package app.termora package app.termora
import com.pty4j.util.PtyUtil
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import java.io.File
fun main() { fun main() {
// 由于 macOS 签名和公证问题,依赖二进制依赖会单独在一个文件夹 ApplicationInitializr().run()
if (SystemUtils.IS_OS_MAC_OSX) {
setupNativeLibraries()
}
if (SystemUtils.IS_OS_MAC_OSX) {
System.setProperty("apple.awt.application.name", Application.getName())
}
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)
}
}

View File

@@ -1,51 +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() = ApplicationScope.forApplicationScope()
.windowScopes().map { PtyConnectorFactory.getInstance(it).getPtyConnectors() }
.flatten()
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,7 +1,9 @@
package app.termora package app.termora
import app.termora.actions.ActionManager import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.actions.MultipleAction
import app.termora.terminal.Terminal import app.termora.terminal.Terminal
import app.termora.terminal.TerminalColor import app.termora.terminal.TerminalColor
import app.termora.terminal.TextStyle import app.termora.terminal.TextStyle
@@ -9,8 +11,10 @@ import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalDisplay import app.termora.terminal.panel.TerminalDisplay
import app.termora.terminal.panel.TerminalPaintListener import app.termora.terminal.panel.TerminalPaintListener
import app.termora.terminal.panel.TerminalPanel import app.termora.terminal.panel.TerminalPanel
import org.apache.commons.lang3.StringUtils
import java.awt.Color import java.awt.Color
import java.awt.Graphics import java.awt.Graphics
import java.util.*
class MultipleTerminalListener : TerminalPaintListener { class MultipleTerminalListener : TerminalPaintListener {
override fun after( override fun after(
@@ -21,9 +25,9 @@ class MultipleTerminalListener : TerminalPaintListener {
terminalDisplay: TerminalDisplay, terminalDisplay: TerminalDisplay,
terminal: Terminal terminal: Terminal
) { ) {
if (!ActionManager.getInstance().isSelected(Actions.MULTIPLE)) { val windowScope = AnActionEvent(terminalPanel, StringUtils.EMPTY, EventObject(terminalPanel))
return .getData(DataProviders.WindowScope) ?: return
} if (!MultipleAction.getInstance(windowScope).isSelected) return
val oldFont = g.font val oldFont = g.font
val colorPalette = terminal.getTerminalModel().getColorPalette() val colorPalette = terminal.getTerminalModel().getColorPalette()

View File

@@ -1,7 +1,6 @@
package app.termora package app.termora
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
@@ -23,6 +22,7 @@ class MyTabbedPane : FlatTabbedPane() {
.getData(DataProviders.TermoraFrame) as TermoraFrame .getData(DataProviders.TermoraFrame) as TermoraFrame
init { init {
isFocusable = false
initEvents() initEvents()
} }
@@ -229,11 +229,8 @@ class MyTabbedPane : FlatTabbedPane() {
private fun dragToAnotherWindow(oldFrame: TermoraFrame, frame: TermoraFrame) { private fun dragToAnotherWindow(oldFrame: TermoraFrame, frame: TermoraFrame) {
val tab = this.terminalTab ?: return val tab = this.terminalTab ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return
val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return
val windowScope = frame.getData(DataProviders.WindowScope) ?: return
val oldWindowScope = oldFrame.getData(DataProviders.WindowScope) ?: return
val location = Point(MouseInfo.getPointerInfo().location) val location = Point(MouseInfo.getPointerInfo().location)
SwingUtilities.convertPointFromScreen(location, tabbedPane) SwingUtilities.convertPointFromScreen(location, tabbedPane)
val index = tabbedPane.indexAtLocation(location.x, location.y) val index = tabbedPane.indexAtLocation(location.x, location.y)
@@ -245,11 +242,6 @@ class MyTabbedPane : FlatTabbedPane() {
index index
) )
TerminalPanelFactory.getInstance(oldWindowScope).removeTerminalPanel(terminalPanel)
TerminalPanelFactory.getInstance(windowScope).addTerminalPanel(terminalPanel)
if (frame.hasFocus()) { if (frame.hasFocus()) {
return return
} }

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

@@ -1,12 +1,9 @@
package app.termora package app.termora
import app.termora.Application.ohMyJson import app.termora.Application.ohMyJson
import app.termora.actions.AnActionEvent
import app.termora.actions.OpenHostAction import app.termora.actions.OpenHostAction
import app.termora.transport.SFTPAction import app.termora.sftp.SFTPActionEvent
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeOpenIcon
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import org.apache.commons.csv.CSVFormat import org.apache.commons.csv.CSVFormat
@@ -17,29 +14,20 @@ import org.apache.commons.io.FilenameUtils
import org.apache.commons.io.filefilter.FileFilterUtils import org.apache.commons.io.filefilter.FileFilterUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.client.config.hosts.HostConfigEntry
import org.ini4j.Ini import org.ini4j.Ini
import org.ini4j.Reg import org.ini4j.Reg
import org.jdesktop.swingx.JXTree
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.w3c.dom.Element import org.w3c.dom.Element
import org.w3c.dom.NodeList import org.w3c.dom.NodeList
import java.awt.Component import java.awt.Component
import java.awt.Dimension import java.awt.event.*
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.awt.event.ActionEvent
import java.awt.event.ActionListener
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.io.* import java.io.*
import java.util.* import java.util.*
import java.util.function.Function import java.util.function.Function
import javax.swing.* import javax.swing.*
import javax.swing.event.CellEditorListener
import javax.swing.event.ChangeEvent
import javax.swing.event.PopupMenuEvent import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener import javax.swing.event.PopupMenuListener
import javax.swing.filechooser.FileNameExtensionFilter import javax.swing.filechooser.FileNameExtensionFilter
@@ -48,26 +36,25 @@ import javax.swing.tree.TreeSelectionModel
import javax.xml.parsers.DocumentBuilderFactory import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathConstants import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory import javax.xml.xpath.XPathFactory
import kotlin.math.min
class NewHostTree : JXTree() { @Suppress("CascadeIf")
class NewHostTree : SimpleTree() {
companion object { companion object {
private val log = LoggerFactory.getLogger(NewHostTree::class.java) private val log = LoggerFactory.getLogger(NewHostTree::class.java)
private val CSV_HEADERS = arrayOf("Folders", "Label", "Hostname", "Port", "Username", "Protocol") private val CSV_HEADERS = arrayOf("Folders", "Label", "Hostname", "Port", "Username", "Protocol")
} }
private val tree = this
private val editor = OutlineTextField(64)
private val hostManager get() = HostManager.getInstance() private val hostManager get() = HostManager.getInstance()
private val properties get() = Database.getDatabase().properties private val properties get() = Database.getDatabase().properties
private val owner get() = SwingUtilities.getWindowAncestor(this) private val owner get() = SwingUtilities.getWindowAncestor(this)
private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
private val sftpAction get() = ActionManager.getInstance().getAction(app.termora.Actions.SFTP)
private var isShowMoreInfo private var isShowMoreInfo
get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean() get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
set(value) = properties.putString("HostTree.showMoreInfo", value.toString()) set(value) = properties.putString("HostTree.showMoreInfo", value.toString())
private var isPopupMenu = false
private val model = NewHostTreeModel() override val model = NewHostTreeModel()
/** /**
* 是否允许显示右键菜单 * 是否允许显示右键菜单
@@ -92,7 +79,6 @@ class NewHostTree : JXTree() {
isRootVisible = true isRootVisible = true
dropMode = DropMode.ON_OR_INSERT dropMode = DropMode.ON_OR_INSERT
selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
editor.preferredSize = Dimension(220, 0)
// renderer // renderer
setCellRenderer(object : DefaultXTreeCellRenderer() { setCellRenderer(object : DefaultXTreeCellRenderer() {
@@ -112,7 +98,7 @@ class NewHostTree : JXTree() {
// 是否显示更多信息 // 是否显示更多信息
if (isShowMoreInfo) { if (isShowMoreInfo) {
val color = if (sel) { val color = if (sel) {
if (tree.hasFocus()) { if (tree.hasFocus() || isPopupMenu) {
UIManager.getColor("textHighlightText") UIManager.getColor("textHighlightText")
} else { } else {
this.foreground this.foreground
@@ -125,88 +111,31 @@ class NewHostTree : JXTree() {
"""<font color=rgb(${color.red},${color.green},${color.blue})>${it}</font>""" """<font color=rgb(${color.red},${color.green},${color.blue})>${it}</font>"""
} }
if (host.protocol == Protocol.SSH) { // @formatter:off
text = if (host.protocol == Protocol.SSH || host.protocol == Protocol.RDP) {
"<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;${fontTag.apply("${host.username}@${host.host}")}</html>" text = "<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;${fontTag.apply("${host.username}@${host.host}")}</html>"
} else if (host.protocol == Protocol.Serial) { } else if (host.protocol == Protocol.Serial) {
text = text = "<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;${fontTag.apply(host.options.serialComm.port)}</html>"
"<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;${fontTag.apply(host.options.serialComm.port)}</html>"
} else if (host.protocol == Protocol.Folder) { } else if (host.protocol == Protocol.Folder) {
text = "<html>${host.name}${fontTag.apply(" (${node.childCount})")}</html>" text = "<html>${host.name}${fontTag.apply(" (${node.getAllChildren().size})")}</html>"
} }
// @formatter:on
} }
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus) val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
icon = when (host.protocol) { icon = node.getIcon(sel, expanded, tree.hasFocus() || isPopupMenu)
Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
Protocol.Serial -> if (sel && tree.hasFocus()) Icons.plugin.dark else Icons.plugin
else -> if (sel && tree.hasFocus()) Icons.terminal.dark else Icons.terminal
}
return c return c
} }
}) })
// rename
setCellEditor(object : DefaultCellEditor(editor) {
override fun isCellEditable(e: EventObject?): Boolean {
if (e is MouseEvent) {
return false
}
return super.isCellEditable(e)
}
override fun getCellEditorValue(): Any {
val node = lastSelectedPathComponent as HostTreeNode
return node.host
}
})
} }
private fun initEvents() { private fun initEvents() {
// 右键选中 // double click
addMouseListener(object : MouseAdapter() { 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
}
if (contextmenu) {
SwingUtilities.invokeLater { showContextmenu(e) }
}
}
override fun mouseClicked(e: MouseEvent) { override fun mouseClicked(e: MouseEvent) {
if (getPathForLocation(e.x, e.y) == null) return
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val lastNode = lastSelectedPathComponent as? HostTreeNode ?: return val lastNode = lastSelectedPathComponent as? HostTreeNode ?: return
if (lastNode.host.protocol != Protocol.Folder) { if (lastNode.host.protocol != Protocol.Folder) {
@@ -216,145 +145,36 @@ class NewHostTree : JXTree() {
} }
}) })
// rename addKeyListener(object : KeyAdapter() {
getCellEditor().addCellEditorListener(object : CellEditorListener { override fun keyPressed(e: KeyEvent) {
override fun editingStopped(e: ChangeEvent) { if (e.keyCode == KeyEvent.VK_ENTER && doubleClickConnection) {
val lastHost = lastSelectedPathComponent val nodes = getSelectionSimpleTreeNodes()
if (lastHost !is HostTreeNode || editor.text.isBlank() || editor.text == lastHost.host.name) { if (nodes.size == 1 && nodes.first().host.protocol == Protocol.Folder) {
return 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))
}
}
} }
lastHost.host = lastHost.host.copy(name = editor.text, updateDate = System.currentTimeMillis())
hostManager.addHost(lastHost.host)
}
override fun editingCanceled(e: ChangeEvent) {
} }
}) })
// drag
transferHandler = object : TransferHandler() {
override fun createTransferable(c: JComponent): Transferable? {
val nodes = getSelectionHostTreeNodes().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 MoveHostTransferable(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 node = dropLocation.path.lastPathComponent as? HostTreeNode ?: return false
if (!support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) return false
val nodes = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as? List<*>)
?.filterIsInstance<HostTreeNode>() ?: return false
if (nodes.isEmpty()) return false
if (node.host.protocol != Protocol.Folder) return false
for (e in nodes) {
// 禁止拖拽到自己的子下面
if (dropLocation.path.equals(TreePath(e.path)) || TreePath(e.path).isDescendant(dropLocation.path)) {
return false
}
// 文件夹只能拖拽到文件夹的下面
if (e.host.protocol == Protocol.Folder) {
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 {
val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false
val node = dropLocation.path.lastPathComponent as? HostTreeNode ?: return false
val nodes = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as? List<*>)
?.filterIsInstance<HostTreeNode>() ?: return false
// 展开的 host id
val expanded = mutableSetOf(node.host.id)
for (e in nodes) {
e.getAllChildren().filter { isExpanded(TreePath(model.getPathToRoot(it))) }
.map { it.host.id }.forEach { expanded.add(it) }
}
// 转移
for (e in nodes) {
model.removeNodeFromParent(e)
e.host = e.host.copy(parentId = node.host.id, updateDate = System.currentTimeMillis())
hostManager.addHost(e.host)
if (dropLocation.childIndex == -1) {
if (e.host.protocol == Protocol.Folder) {
model.insertNodeInto(e, node, node.folderCount)
} else {
model.insertNodeInto(e, node, node.childCount)
}
} else {
if (e.host.protocol == Protocol.Folder) {
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.host.id)) {
expandPath(TreePath(model.getPathToRoot(child)))
}
}
return true
}
}
} }
private fun showContextmenu(event: MouseEvent) { override fun showContextmenu(evt: MouseEvent) {
if (!contextmenu) return
val lastNode = lastSelectedPathComponent val lastNode = lastSelectedPathComponent
if (lastNode !is HostTreeNode) return if (lastNode !is HostTreeNode) return
val nodes = getSelectionSimpleTreeNodes()
val fullNodes = getSelectionSimpleTreeNodes(true)
val lastNodeParent = lastNode.parent ?: model.root val lastNodeParent = lastNode.parent ?: model.root
val lastHost = lastNode.host val lastHost = lastNode.host
@@ -370,8 +190,10 @@ class NewHostTree : JXTree() {
val finalShellMenu = importMenu.add("FinalShell") val finalShellMenu = importMenu.add("FinalShell")
val windTermMenu = importMenu.add("WindTerm") val windTermMenu = importMenu.add("WindTerm")
val secureCRTMenu = importMenu.add("SecureCRT") val secureCRTMenu = importMenu.add("SecureCRT")
val sshMenu = importMenu.add(".ssh/config")
val mobaXtermMenu = importMenu.add("MobaXterm") val mobaXtermMenu = importMenu.add("MobaXterm")
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect")) 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 openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu
val openWithSFTP = openWith.add("SFTP") val openWithSFTP = openWith.add("SFTP")
@@ -403,6 +225,7 @@ class NewHostTree : JXTree() {
secureCRTMenu.addActionListener { importHosts(lastNode, ImportType.SecureCRT) } secureCRTMenu.addActionListener { importHosts(lastNode, ImportType.SecureCRT) }
electermMenu.addActionListener { importHosts(lastNode, ImportType.electerm) } electermMenu.addActionListener { importHosts(lastNode, ImportType.electerm) }
mobaXtermMenu.addActionListener { importHosts(lastNode, ImportType.MobaXterm) } mobaXtermMenu.addActionListener { importHosts(lastNode, ImportType.MobaXterm) }
sshMenu.addActionListener { importHosts(lastNode, ImportType.SSH) }
finalShellMenu.addActionListener { importHosts(lastNode, ImportType.FinalShell) } finalShellMenu.addActionListener { importHosts(lastNode, ImportType.FinalShell) }
csvMenu.addActionListener { importHosts(lastNode, ImportType.CSV) } csvMenu.addActionListener { importHosts(lastNode, ImportType.CSV) }
windTermMenu.addActionListener { importHosts(lastNode, ImportType.WindTerm) } windTermMenu.addActionListener { importHosts(lastNode, ImportType.WindTerm) }
@@ -426,7 +249,6 @@ class NewHostTree : JXTree() {
} }
remove.addActionListener(object : ActionListener { remove.addActionListener(object : ActionListener {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) {
val nodes = getSelectionHostTreeNodes()
if (nodes.isEmpty()) return if (nodes.isEmpty()) return
if (OptionPane.showConfirmDialog( if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(tree), SwingUtilities.getWindowAncestor(tree),
@@ -453,7 +275,7 @@ class NewHostTree : JXTree() {
} }
}) })
copy.addActionListener { copy.addActionListener {
for (c in getSelectionHostTreeNodes()) { for (c in nodes) {
val p = c.parent ?: continue val p = c.parent ?: continue
val newNode = copyNode(c, p.host.id) val newNode = copyNode(c, p.host.id)
model.insertNodeInto(newNode, p, lastNodeParent.getIndex(c) + 1) model.insertNodeInto(newNode, p, lastNodeParent.getIndex(c) + 1)
@@ -462,12 +284,12 @@ class NewHostTree : JXTree() {
} }
rename.addActionListener { startEditingAtPath(TreePath(model.getPathToRoot(lastNode))) } rename.addActionListener { startEditingAtPath(TreePath(model.getPathToRoot(lastNode))) }
expandAll.addActionListener { expandAll.addActionListener {
for (node in getSelectionHostTreeNodes(true)) { for (node in fullNodes) {
expandPath(TreePath(model.getPathToRoot(node))) expandPath(TreePath(model.getPathToRoot(node)))
} }
} }
colspanAll.addActionListener { colspanAll.addActionListener {
for (node in getSelectionHostTreeNodes(true).reversed()) { for (node in fullNodes.reversed()) {
collapsePath(TreePath(model.getPathToRoot(node))) collapsePath(TreePath(model.getPathToRoot(node)))
} }
} }
@@ -495,29 +317,10 @@ class NewHostTree : JXTree() {
model.nodeStructureChanged(lastNode) model.nodeStructureChanged(lastNode)
} }
}) })
refresh.addActionListener { refresh.addActionListener { refreshNode(lastNode) }
val expanded = mutableSetOf(lastNode.host.id)
for (e in lastNode.getAllChildren()) {
if (e.host.protocol == Protocol.Folder && isExpanded(TreePath(model.getPathToRoot(e)))) {
expanded.add(e.host.id)
}
}
// 刷新
model.reload(lastNode)
// 先展开最顶级的
expandPath(TreePath(model.getPathToRoot(lastNode)))
for (child in lastNode.getAllChildren()) {
if (expanded.contains(child.host.id)) {
expandPath(TreePath(model.getPathToRoot(child)))
}
}
}
newMenu.isEnabled = lastHost.protocol == Protocol.Folder newMenu.isEnabled = lastHost.protocol == Protocol.Folder
remove.isEnabled = getSelectionHostTreeNodes().none { it == model.root } remove.isEnabled = getSelectionSimpleTreeNodes().none { it == model.root }
copy.isEnabled = remove.isEnabled copy.isEnabled = remove.isEnabled
rename.isEnabled = remove.isEnabled rename.isEnabled = remove.isEnabled
property.isEnabled = lastHost.protocol != Protocol.Folder property.isEnabled = lastHost.protocol != Protocol.Folder
@@ -525,28 +328,42 @@ class NewHostTree : JXTree() {
importMenu.isEnabled = lastHost.protocol == Protocol.Folder importMenu.isEnabled = lastHost.protocol == Protocol.Folder
// 如果选中了 SSH 服务器,那么才启用 // 如果选中了 SSH 服务器,那么才启用
openWithSFTP.isEnabled = getSelectionHostTreeNodes(true).map { it.host }.any { it.protocol == Protocol.SSH } openWithSFTP.isEnabled = fullNodes.map { it.host }.any { it.protocol == Protocol.SSH }
openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled
openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled } openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled }
popupMenu.addPopupMenuListener(object : PopupMenuListener { popupMenu.addPopupMenuListener(object : PopupMenuListener {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
tree.grabFocus() isPopupMenu = true
} }
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) { override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
tree.requestFocusInWindow() isPopupMenu = false
} }
override fun popupMenuCanceled(e: PopupMenuEvent) { override fun popupMenuCanceled(e: PopupMenuEvent?) {
} }
}) })
popupMenu.show(this, evt.x, evt.y)
popupMenu.show(this, event.x, event.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( private fun copyNode(
node: HostTreeNode, node: HostTreeNode,
parentId: String, parentId: String,
@@ -583,30 +400,14 @@ class NewHostTree : JXTree() {
} }
/** override fun getSelectionSimpleTreeNodes(include: Boolean): List<HostTreeNode> {
* 包含孙子 return super.getSelectionSimpleTreeNodes(include).filterIsInstance<HostTreeNode>()
*/
fun getSelectionHostTreeNodes(include: Boolean = false): List<HostTreeNode> {
val paths = selectionPaths ?: return emptyList()
if (paths.isEmpty()) return emptyList()
val nodes = mutableListOf<HostTreeNode>()
val parents = paths.mapNotNull { it.lastPathComponent }
.filterIsInstance<HostTreeNode>().toMutableList()
if (include) {
while (parents.isNotEmpty()) {
val node = parents.removeFirst()
nodes.add(node)
parents.addAll(node.children().toList().filterIsInstance<HostTreeNode>())
}
}
return if (include) nodes else parents
} }
private fun openHosts(evt: EventObject, openInNewWindow: Boolean) { private fun openHosts(evt: EventObject, openInNewWindow: Boolean) {
assertEventDispatchThread() assertEventDispatchThread()
val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol != Protocol.Folder } val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol != Protocol.Folder }
if (nodes.isEmpty()) return if (nodes.isEmpty()) return
val source = if (openInNewWindow) val source = if (openInNewWindow)
TermoraFrameManager.getInstance().createWindow().apply { isVisible = true } TermoraFrameManager.getInstance().createWindow().apply { isVisible = true }
@@ -615,18 +416,16 @@ class NewHostTree : JXTree() {
} }
private fun openWithSFTP(evt: EventObject) { private fun openWithSFTP(evt: EventObject) {
val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return if (nodes.isEmpty()) return
val sftpAction = ActionManager.getInstance().getAction(app.termora.Actions.SFTP) as SFTPAction? ?: return
val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return
for (node in nodes) { for (node in nodes) {
sftpAction.connectHost(node, tab) sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt))
} }
} }
private fun openWithSFTPCommand(evt: EventObject) { private fun openWithSFTPCommand(evt: EventObject) {
val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return if (nodes.isEmpty()) return
for (host in nodes) { for (host in nodes) {
openHostAction.actionPerformed(OpenHostActionEvent(this, host.copy(protocol = Protocol.SFTPPty), evt)) openHostAction.actionPerformed(OpenHostActionEvent(this, host.copy(protocol = Protocol.SFTPPty), evt))
@@ -652,6 +451,7 @@ class NewHostTree : JXTree() {
when (type) { when (type) {
ImportType.WindTerm -> chooser.fileFilter = FileNameExtensionFilter("WindTerm (*.sessions)", "sessions") 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.CSV -> chooser.fileFilter = FileNameExtensionFilter("CSV (*.csv)", "csv")
ImportType.SecureCRT -> chooser.fileFilter = FileNameExtensionFilter("SecureCRT (*.xml)", "xml") ImportType.SecureCRT -> chooser.fileFilter = FileNameExtensionFilter("SecureCRT (*.xml)", "xml")
ImportType.electerm -> chooser.fileFilter = FileNameExtensionFilter("electerm (*.json)", "json") ImportType.electerm -> chooser.fileFilter = FileNameExtensionFilter("electerm (*.json)", "json")
@@ -717,19 +517,23 @@ class NewHostTree : JXTree() {
} }
// 选择文件 // 选择文件
val code = chooser.showOpenDialog(owner) if (type != ImportType.SSH) {
val code = chooser.showOpenDialog(owner)
if (code != JFileChooser.APPROVE_OPTION) { if (code != JFileChooser.APPROVE_OPTION) {
return return
}
} }
val file = chooser.selectedFile val file = chooser.selectedFile
properties.putString( if (file != null && file.parentFile != null) {
"NewHostTree.ImportHosts.defaultDir", properties.putString(
(if (FileUtils.isDirectory(file)) file else file.parentFile).absolutePath "NewHostTree.ImportHosts.defaultDir",
) (if (FileUtils.isDirectory(file)) file else file.parentFile).absolutePath
)
}
val nodes = when (type) { val nodes = when (type) {
ImportType.SSH -> parseFromSSH(folder)
ImportType.WindTerm -> parseFromWindTerm(folder, file) ImportType.WindTerm -> parseFromWindTerm(folder, file)
ImportType.SecureCRT -> parseFromSecureCRT(folder, file) ImportType.SecureCRT -> parseFromSecureCRT(folder, file)
ImportType.MobaXterm -> parseFromMobaXterm(folder, file) ImportType.MobaXterm -> parseFromMobaXterm(folder, file)
@@ -761,6 +565,9 @@ class NewHostTree : JXTree() {
// 重新加载 // 重新加载
model.reload(folder) model.reload(folder)
// expand root
expandPath(TreePath(model.getPathToRoot(folder)))
} }
private fun parseFromWindTerm(folder: HostTreeNode, file: File): List<HostTreeNode> { private fun parseFromWindTerm(folder: HostTreeNode, file: File): List<HostTreeNode> {
@@ -786,6 +593,26 @@ class NewHostTree : JXTree() {
return parseFromCSV(folder, StringReader(sw.toString())) 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> { private fun parseFromSecureCRT(folder: HostTreeNode, file: File): List<HostTreeNode> {
val xPath = XPathFactory.newInstance().newXPath() val xPath = XPathFactory.newInstance().newXPath()
val db = DocumentBuilderFactory.newInstance().newDocumentBuilder() val db = DocumentBuilderFactory.newInstance().newDocumentBuilder()
@@ -1085,32 +912,10 @@ class NewHostTree : JXTree() {
PuTTY, PuTTY,
SecureCRT, SecureCRT,
MobaXterm, MobaXterm,
SSH,
FinalShell, FinalShell,
electerm, electerm,
} }
private class MoveHostTransferable(val nodes: List<HostTreeNode>) : Transferable {
companion object {
val dataFlavor =
DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveHostTransferable::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

@@ -3,11 +3,10 @@ package app.termora
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import java.awt.Dimension import java.awt.Dimension
import java.awt.Window import java.awt.Window
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.function.Function import java.util.function.Function
import javax.swing.BorderFactory import javax.swing.*
import javax.swing.JComponent
import javax.swing.JScrollPane
import javax.swing.UIManager
class NewHostTreeDialog( class NewHostTreeDialog(
owner: Window, owner: Window,
@@ -19,7 +18,7 @@ class NewHostTreeDialog(
private val tree = NewHostTree() private val tree = NewHostTree()
init { init {
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150) size = Dimension(UIManager.getInt("Dialog.width") - 250, UIManager.getInt("Dialog.height") - 150)
isModal = true isModal = true
isResizable = false isResizable = false
controlsVisible = false controlsVisible = false
@@ -29,6 +28,15 @@ class NewHostTreeDialog(
tree.doubleClickConnection = false tree.doubleClickConnection = false
tree.dragEnabled = 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() init()
@@ -60,7 +68,7 @@ class NewHostTreeDialog(
} }
override fun doOKAction() { override fun doOKAction() {
hosts = tree.getSelectionHostTreeNodes(true) hosts = tree.getSelectionSimpleTreeNodes(true)
.filter { filter.apply(it) } .filter { filter.apply(it) }
.map { it.host } .map { it.host }

View File

@@ -1,12 +1,11 @@
package app.termora package app.termora
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import javax.swing.tree.DefaultTreeModel
import javax.swing.tree.MutableTreeNode import javax.swing.tree.MutableTreeNode
import javax.swing.tree.TreeNode import javax.swing.tree.TreeNode
class NewHostTreeModel : DefaultTreeModel( class NewHostTreeModel : SimpleTreeModel<Host>(
HostTreeNode( HostTreeNode(
Host( Host(
id = "0", id = "0",

View File

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

View File

@@ -1,11 +1,15 @@
package app.termora package app.termora
import app.termora.native.osx.NativeMacLibrary
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextPane import com.formdev.flatlaf.extras.components.FlatTextPane
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR 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 kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
import java.awt.Desktop import java.awt.Desktop
@@ -18,6 +22,8 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
object OptionPane { object OptionPane {
private val coroutineScope = swingCoroutineScope
fun showConfirmDialog( fun showConfirmDialog(
parentComponent: Component?, parentComponent: Component?,
message: Any, message: Any,
@@ -27,6 +33,7 @@ object OptionPane {
icon: Icon? = null, icon: Icon? = null,
options: Array<Any>? = null, options: Array<Any>? = null,
initialValue: Any? = null, initialValue: Any? = null,
customizeDialog: (JDialog) -> Unit = {},
): Int { ): Int {
val panel = if (message is JComponent) { val panel = if (message is JComponent) {
@@ -45,6 +52,9 @@ object OptionPane {
override fun selectInitialValue() { override fun selectInitialValue() {
super.selectInitialValue() super.selectInitialValue()
if (message is JComponent) { if (message is JComponent) {
if (message.getClientProperty("SKIP_requestFocusInWindow") == true) {
return
}
message.requestFocusInWindow() message.requestFocusInWindow()
} }
} }
@@ -56,6 +66,7 @@ object OptionPane {
} }
}) })
dialog.setLocationRelativeTo(parentComponent) dialog.setLocationRelativeTo(parentComponent)
customizeDialog.invoke(dialog)
dialog.isVisible = true dialog.isVisible = true
dialog.dispose() dialog.dispose()
val selectedValue = pane.value val selectedValue = pane.value
@@ -97,9 +108,8 @@ object OptionPane {
val dialog = initDialog(pane.createDialog(parentComponent, title)) val dialog = initDialog(pane.createDialog(parentComponent, title))
if (duration.inWholeMilliseconds > 0) { if (duration.inWholeMilliseconds > 0) {
dialog.addWindowListener(object : WindowAdapter() { dialog.addWindowListener(object : WindowAdapter() {
@OptIn(DelicateCoroutinesApi::class)
override fun windowOpened(e: WindowEvent) { override fun windowOpened(e: WindowEvent) {
GlobalScope.launch(Dispatchers.Swing) { coroutineScope.launch(Dispatchers.Swing) {
delay(duration.inWholeMilliseconds) delay(duration.inWholeMilliseconds)
if (dialog.isVisible) { if (dialog.isVisible) {
dialog.isVisible = false dialog.isVisible = false
@@ -113,6 +123,36 @@ object OptionPane {
dialog.dispose() 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( fun openFileInFolder(
parentComponent: Component, parentComponent: Component,
file: File, file: File,
@@ -140,14 +180,31 @@ object OptionPane {
} }
private fun initDialog(dialog: JDialog): JDialog { 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 height = UIManager.getInt("TabbedPane.tabHeight") - 10
val titleBar = windowDecorations.createCustomTitleBar() if (JBR.isWindowDecorationsSupported()) {
titleBar.putProperty("controls.visible", false) val customTitleBar = JBR.getWindowDecorations().createCustomTitleBar()
titleBar.height = UIManager.getInt("TabbedPane.tabHeight") - if (SystemInfo.isMacOS) 10f else 6f customTitleBar.putProperty("controls.visible", false)
windowDecorations.setCustomTitleBar(dialog, titleBar) customTitleBar.height = height.toFloat()
JBR.getWindowDecorations().setCustomTitleBar(dialog, customTitleBar)
} else {
NativeMacLibrary.setControlsVisible(dialog, false)
}
val label = JLabel(dialog.title) val label = JLabel(dialog.title)
label.putClientProperty(FlatClientProperties.STYLE, "font: bold") label.putClientProperty(FlatClientProperties.STYLE, "font: bold")
@@ -155,11 +212,9 @@ object OptionPane {
box.add(Box.createHorizontalGlue()) box.add(Box.createHorizontalGlue())
box.add(label) box.add(label)
box.add(Box.createHorizontalGlue()) box.add(Box.createHorizontalGlue())
box.preferredSize = Dimension(-1, titleBar.height.toInt()) box.preferredSize = Dimension(-1, height)
dialog.contentPane.add(box, BorderLayout.NORTH) dialog.contentPane.add(box, BorderLayout.NORTH)
} }
return dialog return dialog
} }
} }

View File

@@ -18,8 +18,9 @@ class PtyConnectorFactory : Disposable {
companion object { companion object {
private val log = LoggerFactory.getLogger(PtyConnectorFactory::class.java) private val log = LoggerFactory.getLogger(PtyConnectorFactory::class.java)
fun getInstance(scope: Scope): PtyConnectorFactory { fun getInstance(): PtyConnectorFactory {
return scope.getOrCreate(PtyConnectorFactory::class) { PtyConnectorFactory() } return ApplicationScope.forApplicationScope()
.getOrCreate(PtyConnectorFactory::class) { PtyConnectorFactory() }
} }
} }
@@ -33,14 +34,21 @@ class PtyConnectorFactory : Disposable {
if (SystemUtils.IS_OS_UNIX) { if (SystemUtils.IS_OS_UNIX) {
commands.add("-l") commands.add("-l")
} }
return createPtyConnector(commands.toTypedArray(), rows, cols, env, charset) return createPtyConnector(
commands = commands.toTypedArray(),
rows = rows,
cols = cols,
env = env,
charset = charset
)
} }
fun createPtyConnector( fun createPtyConnector(
commands: Array<String>, commands: Array<String>,
rows: Int = 24, cols: Int = 80, rows: Int = 24, cols: Int = 80,
env: Map<String, String> = emptyMap(), env: Map<String, String> = emptyMap(),
charset: Charset = StandardCharsets.UTF_8 directory: String = SystemUtils.USER_HOME,
charset: Charset = StandardCharsets.UTF_8,
): PtyConnector { ): PtyConnector {
val envs = mutableMapOf<String, String>() val envs = mutableMapOf<String, String>()
envs.putAll(System.getenv()) envs.putAll(System.getenv())
@@ -67,7 +75,7 @@ class PtyConnectorFactory : Disposable {
.setInitialRows(rows) .setInitialRows(rows)
.setInitialColumns(cols) .setInitialColumns(cols)
.setConsole(false) .setConsole(false)
.setDirectory(SystemUtils.USER_HOME) .setDirectory(StringUtils.defaultIfBlank(directory, SystemUtils.USER_HOME))
.setCygwin(false) .setCygwin(false)
.setUseWinConPty(SystemUtils.IS_OS_WINDOWS) .setUseWinConPty(SystemUtils.IS_OS_WINDOWS)
.setRedirectErrorStream(false) .setRedirectErrorStream(false)
@@ -79,20 +87,14 @@ class PtyConnectorFactory : Disposable {
} }
fun decorate(ptyConnector: PtyConnector): PtyConnector { fun decorate(ptyConnector: PtyConnector): PtyConnector {
// 集成转发如果PtyConnector支持转发那么应该在当前注释行前面代理 //
val multiplePtyConnector = MultiplePtyConnector(ptyConnector) val macroPtyConnector = MacroPtyConnector(ptyConnector)
// 宏应该在转发前面执行,不然会导致重复录制
val macroPtyConnector = MacroPtyConnector(multiplePtyConnector)
// 集成自动删除 // 集成自动删除
val autoRemovePtyConnector = AutoRemovePtyConnector(macroPtyConnector) val autoRemovePtyConnector = AutoRemovePtyConnector(macroPtyConnector)
ptyConnectors.add(autoRemovePtyConnector) ptyConnectors.add(autoRemovePtyConnector)
return autoRemovePtyConnector return autoRemovePtyConnector
} }
fun getPtyConnectors(): List<PtyConnector> {
return ptyConnectors
}
private inner class AutoRemovePtyConnector(connector: PtyConnector) : PtyConnectorDelegate(connector) { private inner class AutoRemovePtyConnector(connector: PtyConnector) : PtyConnectorDelegate(connector) {
override fun close() { override fun close() {
ptyConnectors.remove(this) ptyConnectors.remove(this)

View File

@@ -13,7 +13,7 @@ import kotlin.time.Duration.Companion.milliseconds
abstract class PtyHostTerminalTab( abstract class PtyHostTerminalTab(
windowScope: WindowScope, windowScope: WindowScope,
host: Host, host: Host,
terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal() terminal: Terminal = TerminalFactory.getInstance().createTerminal()
) : HostTerminalTab(windowScope, host, terminal) { ) : HostTerminalTab(windowScope, host, terminal) {
companion object { companion object {
@@ -23,15 +23,8 @@ abstract class PtyHostTerminalTab(
private var readerJob: Job? = null private var readerJob: Job? = null
private val ptyConnectorDelegate = PtyConnectorDelegate() private val ptyConnectorDelegate = PtyConnectorDelegate()
protected val terminalPanel = TerminalPanelFactory.getInstance().createTerminalPanel(terminal, ptyConnectorDelegate)
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope) protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance()
protected val terminalPanel = terminalPanelFactory.createTerminalPanel(terminal, ptyConnectorDelegate)
.apply { Disposer.register(this@PtyHostTerminalTab, this) }
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance(windowScope)
init {
terminal.getTerminalModel().setData(DataKey.PtyConnector, ptyConnectorDelegate)
}
override fun start() { override fun start() {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
@@ -122,10 +115,9 @@ abstract class PtyHostTerminalTab(
override fun dispose() { override fun dispose() {
stop() stop()
terminalPanel Disposer.dispose(terminalPanel)
super.dispose() super.dispose()
if (log.isInfoEnabled) { if (log.isInfoEnabled) {
log.info("Host: {} disposed", host.name) log.info("Host: {} disposed", host.name)
} }
@@ -141,6 +133,8 @@ abstract class PtyHostTerminalTab(
override fun <T : Any> getData(dataKey: DataKey<T>): T? { override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == DataProviders.TerminalPanel) { if (dataKey == DataProviders.TerminalPanel) {
return terminalPanel as T? return terminalPanel as T?
} else if (dataKey == DataProviders.TerminalWriter) {
return terminalPanel.getData(DataKey.TerminalWriter) as T?
} }
return super.getData(dataKey) 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

@@ -16,7 +16,7 @@ import kotlin.math.max
class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(owner) { class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(owner) {
private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>() private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
private val rememberCheckBox = JCheckBox("Remember") private val rememberCheckBox = JCheckBox(I18n.getString("termora.new-host.general.remember"))
private val passwordPanel = JPanel(BorderLayout()) private val passwordPanel = JPanel(BorderLayout())
private val passwordPasswordField = OutlinePasswordField() private val passwordPasswordField = OutlinePasswordField()
private val usernameTextField = OutlineTextField() private val usernameTextField = OutlineTextField()
@@ -34,8 +34,8 @@ class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(own
pack() pack()
size = Dimension(max(380, size.width), size.height) size = Dimension(max(380, size.width), size.height)
preferredSize = size
setLocationRelativeTo(null) minimumSize = size
publicKeyComboBox.renderer = object : DefaultListCellRenderer() { publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent( override fun getListCellRendererComponent(
@@ -65,6 +65,10 @@ class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(own
} }
} }
if (host.authentication.type != AuthenticationType.No) {
authenticationTypeComboBox.selectedItem = host.authentication.type
}
usernameTextField.text = host.username usernameTextField.text = host.username
} }

View File

@@ -8,6 +8,7 @@ import org.apache.commons.io.Charsets
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.sshd.client.ClientBuilder import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession import org.apache.sshd.client.session.ClientSession
@@ -28,6 +29,12 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
private var sshSession: ClientSession? = null private var sshSession: ClientSession? = null
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand 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 { companion object {
val canSupports by lazy { val canSupports by lazy {
@@ -61,7 +68,7 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
) )
) )
val sshClient = SshClients.openClient(host).apply { sshClient = this } val sshClient = SshClients.openClient(host, owner).apply { sshClient = this }
val sshSession = SshClients.openSession(host, sshClient).apply { sshSession = this } val sshSession = SshClients.openSession(host, sshClient).apply { sshSession = this }
// 打开通道 // 打开通道
@@ -115,16 +122,21 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
if (envs.containsKey("CurrentDir")) { if (envs.containsKey("CurrentDir")) {
val currentDir = envs.getValue("CurrentDir") val currentDir = envs.getValue("CurrentDir")
commands.add("${host.username}@${host.host}:${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 { } else {
commands.add("${host.username}@${host.host}") commands.add("${host.username}@${host.host}")
} }
val directory = FileUtils.getFile(StringUtils.defaultIfBlank(defaultDirectory, SystemUtils.USER_HOME))
val winSize = terminalPanel.winSize() val winSize = terminalPanel.winSize()
val ptyConnector = ptyConnectorFactory.createPtyConnector( val ptyConnector = ptyConnectorFactory.createPtyConnector(
commands.toTypedArray(), commands = commands.toTypedArray(),
winSize.rows, winSize.cols, rows = winSize.rows, cols = winSize.cols,
host.options.envs(), env = host.options.envs(),
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8), charset = Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
directory = if (directory.exists()) directory.absolutePath else SystemUtils.USER_HOME
) )
return ptyConnector return ptyConnector

View File

@@ -1,73 +0,0 @@
package app.termora
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import app.termora.transport.TransportDataProviders
import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
private val sftp get() = Database.getDatabase().sftp
private val transportPanel = TransportPanel()
init {
Disposer.register(this, transportPanel)
}
override fun getTitle(): String {
return "SFTP"
}
override fun getIcon(): Icon {
return Icons.folder
}
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
}
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
}
override fun getJComponent(): JComponent {
return transportPanel
}
override fun canClone(): Boolean {
return false
}
override fun canClose(): Boolean {
assertEventDispatchThread()
if (sftp.pinTab) {
return false
}
val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true
if (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
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == TransportDataProviders.TransportPanel) {
return transportPanel as T
}
return null
}
}

View File

@@ -4,7 +4,6 @@ import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
import app.termora.actions.TabReconnectAction import app.termora.actions.TabReconnectAction
import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.keymap.KeyShortcut import app.termora.keymap.KeyShortcut
import app.termora.keymap.KeymapManager import app.termora.keymap.KeymapManager
import app.termora.terminal.ControlCharacters import app.termora.terminal.ControlCharacters
@@ -53,6 +52,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
init { init {
terminalPanel.dropFiles = false terminalPanel.dropFiles = false
terminalPanel.dataProviderSupport.addData(DataProviders.TerminalTab, this)
} }
override fun getJComponent(): JComponent { override fun getJComponent(): JComponent {
@@ -89,35 +89,8 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
terminal.write("SSH client is opening...\r\n") terminal.write("SSH client is opening...\r\n")
} }
var host =
this.host.copy(authentication = this.host.authentication.copy(), updateDate = System.currentTimeMillis())
val owner = SwingUtilities.getWindowAncestor(terminalPanel) val owner = SwingUtilities.getWindowAncestor(terminalPanel)
val client = SshClients.openClient(host).also { sshClient = it } val client = SshClients.openClient(host, owner).also { sshClient = it }
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// keyboard interactive
client.userInteraction = TerminalUserInteraction(owner)
if (host.authentication.type == AuthenticationType.No) {
withContext(Dispatchers.Swing) {
val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication()
host = host.copy(
authentication = authentication,
username = dialog.getUsername(),
updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(
tab.host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
}
}
val sessionListener = MySessionListener() val sessionListener = MySessionListener()
val channelListener = MyChannelListener() val channelListener = MyChannelListener()
@@ -250,6 +223,11 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
} }
} }
override fun willBeClose(): Boolean {
// 保存窗口状态
terminalPanel.storeVisualWindows(host.id)
return super.willBeClose()
}
private inner class MySessionListener : SessionListener, Disposable { private inner class MySessionListener : SessionListener, Disposable {
override fun sessionEvent(session: Session, event: Event) { override fun sessionEvent(session: Session, event: Event) {

View File

@@ -1,5 +1,10 @@
package app.termora 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 org.slf4j.LoggerFactory
import java.awt.Component import java.awt.Component
import java.awt.Window import java.awt.Window
@@ -8,6 +13,8 @@ import javax.swing.JPopupMenu
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import kotlin.reflect.KClass import kotlin.reflect.KClass
val swingCoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Swing)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
open class Scope( open class Scope(
private val beans: MutableMap<KClass<*>, Any> = ConcurrentHashMap(), private val beans: MutableMap<KClass<*>, Any> = ConcurrentHashMap(),
@@ -144,6 +151,7 @@ class ApplicationScope private constructor() : Scope() {
} }
fun windowScopes(): List<WindowScope> { fun windowScopes(): List<WindowScope> {
if (scopes.isEmpty()) return emptyList()
return scopes.values.toList() return scopes.values.toList()
} }
@@ -151,6 +159,7 @@ class ApplicationScope private constructor() : Scope() {
if (log.isInfoEnabled) { if (log.isInfoEnabled) {
log.info("ApplicationScope disposed") log.info("ApplicationScope disposed")
} }
swingCoroutineScope.cancel()
super.dispose() super.dispose()
} }

View File

@@ -15,15 +15,14 @@ import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro import app.termora.macro.Macro
import app.termora.macro.MacroManager import app.termora.macro.MacroManager
import app.termora.native.FileChooser import app.termora.native.FileChooser
import app.termora.sync.SyncConfig import app.termora.sftp.SFTPTab
import app.termora.sync.SyncRange import app.termora.snippet.Snippet
import app.termora.sync.SyncType import app.termora.snippet.SnippetManager
import app.termora.sync.SyncerProvider import app.termora.sync.*
import app.termora.terminal.CursorStyle import app.termora.terminal.CursorStyle
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.terminal.panel.FloatingToolbarPanel import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalPanel import app.termora.terminal.panel.TerminalPanel
import app.termora.transport.SFTPAction
import cash.z.ecc.android.bip39.Mnemonics import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.FlatSVGIcon
@@ -34,49 +33,55 @@ import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
import com.jthemedetecor.OsThemeDetector import com.jthemedetecor.OsThemeDetector
import com.sun.jna.LastErrorException import com.sun.jna.LastErrorException
import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import org.apache.commons.codec.binary.Base64 import org.apache.commons.codec.binary.Base64
import org.apache.commons.io.FileUtils
import org.apache.commons.io.FilenameUtils
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils import org.apache.commons.lang3.time.DateFormatUtils
import org.jdesktop.swingx.JXEditorPane import org.jdesktop.swingx.JXEditorPane
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
import java.awt.Dimension import java.awt.Dimension
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import java.awt.event.ActionEvent
import java.awt.event.ItemEvent import java.awt.event.ItemEvent
import java.awt.event.ItemListener
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.nio.file.StandardCopyOption
import java.util.* import java.util.*
import java.util.function.Consumer
import javax.swing.* import javax.swing.*
import javax.swing.JSpinner.NumberEditor
import javax.swing.event.DocumentEvent import javax.swing.event.DocumentEvent
import javax.swing.event.PopupMenuEvent import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener import javax.swing.event.PopupMenuListener
import kotlin.time.Duration.Companion.milliseconds
class SettingsOptionsPane : OptionsPane() { class SettingsOptionsPane : OptionsPane() {
private val owner get() = SwingUtilities.getWindowAncestor(this@SettingsOptionsPane) private val owner get() = SwingUtilities.getWindowAncestor(this@SettingsOptionsPane)
private val database get() = Database.getDatabase() private val database get() = Database.getDatabase()
private val hostManager get() = HostManager.getInstance() private val hostManager get() = HostManager.getInstance()
private val snippetManager get() = SnippetManager.getInstance()
private val keymapManager get() = KeymapManager.getInstance() private val keymapManager get() = KeymapManager.getInstance()
private val macroManager get() = MacroManager.getInstance() private val macroManager get() = MacroManager.getInstance()
private val actionManager get() = ActionManager.getInstance()
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance() private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
private val keyManager get() = KeyManager.getInstance() private val keyManager get() = KeyManager.getInstance()
companion object { companion object {
private val log = LoggerFactory.getLogger(SettingsOptionsPane::class.java) private val log = LoggerFactory.getLogger(SettingsOptionsPane::class.java)
private val localShells by lazy { loadShells() } private val localShells by lazy { loadShells() }
var pulled = false
private fun loadShells(): List<String> { private fun loadShells(): List<String> {
val shells = mutableListOf<String>() val shells = mutableListOf<String>()
@@ -126,9 +131,15 @@ class SettingsOptionsPane : OptionsPane() {
val themeManager = ThemeManager.getInstance() val themeManager = ThemeManager.getInstance()
val themeComboBox = FlatComboBox<String>() val themeComboBox = FlatComboBox<String>()
val languageComboBox = FlatComboBox<String>() val languageComboBox = FlatComboBox<String>()
val backgroundComBoBox = YesOrNoComboBox()
val followSystemCheckBox = JCheckBox(I18n.getString("termora.settings.appearance.follow-system")) val followSystemCheckBox = JCheckBox(I18n.getString("termora.settings.appearance.follow-system"))
val preferredThemeBtn = JButton(Icons.settings) val preferredThemeBtn = JButton(Icons.settings)
val opacitySpinner = NumberSpinner(100, 0, 100)
val backgroundImageTextField = OutlineTextField()
private val appearance get() = database.appearance private val appearance get() = database.appearance
private val backgroundButton = JButton(Icons.folder)
private val backgroundClearButton = FlatButton()
init { init {
initView() initView()
@@ -137,8 +148,38 @@ class SettingsOptionsPane : OptionsPane() {
private fun initView() { private fun initView() {
backgroundComBoBox.isEnabled = SystemInfo.isWindows || SystemInfo.isMacOS
backgroundImageTextField.isEditable = false
backgroundImageTextField.trailingComponent = backgroundButton
backgroundImageTextField.text = FilenameUtils.getName(appearance.backgroundImage)
backgroundImageTextField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
backgroundClearButton.isEnabled = backgroundImageTextField.text.isNotBlank()
}
})
backgroundClearButton.isFocusable = false
backgroundClearButton.isEnabled = backgroundImageTextField.text.isNotBlank()
backgroundClearButton.icon = Icons.delete
backgroundClearButton.buttonType = FlatButton.ButtonType.toolBarButton
opacitySpinner.isEnabled = SystemInfo.isMacOS || SystemInfo.isWindows
opacitySpinner.model = object : SpinnerNumberModel(appearance.opacity, 0.1, 1.0, 0.1) {
override fun getNextValue(): Any {
return super.getNextValue() ?: maximum
}
override fun getPreviousValue(): Any {
return super.getPreviousValue() ?: minimum
}
}
opacitySpinner.editor = NumberEditor(opacitySpinner, "#.##")
opacitySpinner.model.stepSize = 0.05
followSystemCheckBox.isSelected = appearance.followSystem followSystemCheckBox.isSelected = appearance.followSystem
preferredThemeBtn.isEnabled = followSystemCheckBox.isSelected preferredThemeBtn.isEnabled = followSystemCheckBox.isSelected
backgroundComBoBox.selectedItem = appearance.backgroundRunning
themeComboBox.isEnabled = !followSystemCheckBox.isSelected themeComboBox.isEnabled = !followSystemCheckBox.isSelected
themeManager.themes.keys.forEach { themeComboBox.addItem(it) } themeManager.themes.keys.forEach { themeComboBox.addItem(it) }
@@ -175,6 +216,20 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
opacitySpinner.addChangeListener {
val opacity = opacitySpinner.value
if (opacity is Double) {
TermoraFrameManager.getInstance().setOpacity(opacity)
appearance.opacity = opacity
}
}
backgroundComBoBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
appearance.backgroundRunning = backgroundComBoBox.selectedItem as Boolean
}
}
followSystemCheckBox.addActionListener { followSystemCheckBox.addActionListener {
appearance.followSystem = followSystemCheckBox.isSelected appearance.followSystem = followSystemCheckBox.isSelected
themeComboBox.isEnabled = !followSystemCheckBox.isSelected themeComboBox.isEnabled = !followSystemCheckBox.isSelected
@@ -204,6 +259,46 @@ class SettingsOptionsPane : OptionsPane() {
} }
preferredThemeBtn.addActionListener { showPreferredThemeContextmenu() } preferredThemeBtn.addActionListener { showPreferredThemeContextmenu() }
backgroundButton.addActionListener {
val chooser = FileChooser()
chooser.osxAllowedFileTypes = listOf("png", "jpg", "jpeg")
chooser.allowsMultiSelection = false
chooser.win32Filters.add(Pair("Image files", listOf("png", "jpg", "jpeg")))
chooser.fileSelectionMode = JFileChooser.FILES_ONLY
chooser.showOpenDialog(owner).thenAccept {
if (it.isNotEmpty()) {
onSelectedBackgroundImage(it.first())
}
}
}
backgroundClearButton.addActionListener {
BackgroundManager.getInstance().clearBackgroundImage()
backgroundImageTextField.text = StringUtils.EMPTY
}
}
private fun onSelectedBackgroundImage(file: File) {
try {
val destFile = FileUtils.getFile(Application.getBaseDataDir(), "background", file.name)
FileUtils.forceMkdirParent(destFile)
FileUtils.deleteQuietly(destFile)
FileUtils.copyFile(file, destFile, StandardCopyOption.REPLACE_EXISTING)
backgroundImageTextField.text = destFile.name
BackgroundManager.getInstance().setBackgroundImage(destFile)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
SwingUtilities.invokeLater {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
} }
override fun getIcon(isSelected: Boolean): Icon { override fun getIcon(isSelected: Boolean): Icon {
@@ -273,7 +368,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun getFormPanel(): JPanel { private fun getFormPanel(): JPanel {
val layout = FormLayout( val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, default, default:grow", "left:pref, $formMargin, default:grow, $formMargin, default, default:grow",
"pref, $formMargin, pref, $formMargin" "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
) )
val box = FlatToolBar() val box = FlatToolBar()
box.add(followSystemCheckBox) box.add(followSystemCheckBox)
@@ -282,7 +377,7 @@ class SettingsOptionsPane : OptionsPane() {
var rows = 1 var rows = 1
val step = 2 val step = 2
return FormBuilder.create().layout(layout) val builder = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.settings.appearance.theme")}:").xy(1, rows) .add("${I18n.getString("termora.settings.appearance.theme")}:").xy(1, rows)
.add(themeComboBox).xy(3, rows) .add(themeComboBox).xy(3, rows)
.add(box).xy(5, rows).apply { rows += step } .add(box).xy(5, rows).apply { rows += step }
@@ -293,7 +388,22 @@ class SettingsOptionsPane : OptionsPane() {
Application.browse(URI.create("https://github.com/TermoraDev/termora/tree/main/src/main/resources/i18n")) Application.browse(URI.create("https://github.com/TermoraDev/termora/tree/main/src/main/resources/i18n"))
} }
})).xy(5, rows).apply { rows += step } })).xy(5, rows).apply { rows += step }
.build()
val bgClearBox = Box.createHorizontalBox()
bgClearBox.add(backgroundClearButton)
builder.add("${I18n.getString("termora.settings.appearance.background-image")}:").xy(1, rows)
.add(backgroundImageTextField).xy(3, rows)
.add(bgClearBox).xy(5, rows)
.apply { rows += step }
builder.add("${I18n.getString("termora.settings.appearance.opacity")}:").xy(1, rows)
.add(opacitySpinner).xy(3, rows).apply { rows += step }
builder.add("${I18n.getString("termora.settings.appearance.background-running")}:").xy(1, rows)
.add(backgroundComBoBox).xy(3, rows)
return builder.build()
} }
@@ -337,7 +447,7 @@ class SettingsOptionsPane : OptionsPane() {
floatingToolbarComboBox.addItemListener { e -> floatingToolbarComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) { if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.floatingToolbar = floatingToolbarComboBox.selectedItem as Boolean terminalSetting.floatingToolbar = floatingToolbarComboBox.selectedItem as Boolean
TerminalPanelFactory.getAllTerminalPanel().forEach { tp -> TerminalPanelFactory.getInstance().getTerminalPanels().forEach { tp ->
if (terminalSetting.floatingToolbar && FloatingToolbarPanel.isPined) { if (terminalSetting.floatingToolbar && FloatingToolbarPanel.isPined) {
tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerShow() tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerShow()
} else { } else {
@@ -366,7 +476,7 @@ class SettingsOptionsPane : OptionsPane() {
if (it.stateChange == ItemEvent.SELECTED) { if (it.stateChange == ItemEvent.SELECTED) {
val style = cursorStyleComboBox.selectedItem as CursorStyle val style = cursorStyleComboBox.selectedItem as CursorStyle
terminalSetting.cursor = style terminalSetting.cursor = style
TerminalFactory.getInstance(ApplicationScope.forWindowScope(owner)).getTerminals().forEach { e -> TerminalFactory.getInstance().getTerminals().forEach { e ->
e.getTerminalModel().setData(DataKey.CursorStyle, style) e.getTerminalModel().setData(DataKey.CursorStyle, style)
} }
} }
@@ -376,7 +486,7 @@ class SettingsOptionsPane : OptionsPane() {
debugComboBox.addItemListener { e -> debugComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) { if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.debug = debugComboBox.selectedItem as Boolean terminalSetting.debug = debugComboBox.selectedItem as Boolean
TerminalFactory.getInstance(ApplicationScope.forWindowScope(owner)).getTerminals().forEach { TerminalFactory.getInstance().getTerminals().forEach {
it.getTerminalModel().setData(TerminalPanel.Debug, terminalSetting.debug) it.getTerminalModel().setData(TerminalPanel.Debug, terminalSetting.debug)
} }
} }
@@ -405,10 +515,8 @@ class SettingsOptionsPane : OptionsPane() {
} }
private fun fireFontChanged() { private fun fireFontChanged() {
ApplicationScope.windowScopes().forEach { TerminalPanelFactory.getInstance()
TerminalPanelFactory.getInstance(it) .fireResize()
.fireResize()
}
} }
private fun initView() { private fun initView() {
@@ -469,7 +577,7 @@ class SettingsOptionsPane : OptionsPane() {
shellComboBox.selectedItem = terminalSetting.localShell shellComboBox.selectedItem = terminalSetting.localShell
val fonts = linkedSetOf<String>("JetBrains Mono", "Source Code Pro", "Monospaced") val fonts = linkedSetOf("JetBrains Mono", "Source Code Pro", "Monospaced")
FontUtils.getAllFonts().forEach { FontUtils.getAllFonts().forEach {
if (!fonts.contains(it.family)) { if (!fonts.contains(it.family)) {
fonts.addLast(it.family) fonts.addLast(it.family)
@@ -552,15 +660,16 @@ class SettingsOptionsPane : OptionsPane() {
val typeComboBox = FlatComboBox<SyncType>() val typeComboBox = FlatComboBox<SyncType>()
val tokenTextField = OutlinePasswordField(255) val tokenTextField = OutlinePasswordField(255)
val gistTextField = OutlineTextField(255) val gistTextField = OutlineTextField(255)
val policyComboBox = JComboBox<SyncPolicy>()
val domainTextField = OutlineTextField(255) val domainTextField = OutlineTextField(255)
val uploadConfigButton = JButton(I18n.getString("termora.settings.sync.push"), Icons.upload) val syncConfigButton = JButton(I18n.getString("termora.settings.sync"), Icons.settingSync)
val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export) val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export)
val importConfigButton = JButton(I18n.getString("termora.settings.sync.import"), Icons.import) val importConfigButton = JButton(I18n.getString("termora.settings.sync.import"), Icons.import)
val downloadConfigButton = JButton(I18n.getString("termora.settings.sync.pull"), Icons.download)
val lastSyncTimeLabel = JLabel() val lastSyncTimeLabel = JLabel()
val sync get() = database.sync val sync get() = database.sync
val hostsCheckBox = JCheckBox(I18n.getString("termora.welcome.my-hosts")) val hostsCheckBox = JCheckBox(I18n.getString("termora.welcome.my-hosts"))
val keysCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keys")) val keysCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keys"))
val snippetsCheckBox = JCheckBox(I18n.getString("termora.snippet.title"))
val keywordHighlightsCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keyword-highlights")) val keywordHighlightsCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keyword-highlights"))
val macrosCheckBox = JCheckBox(I18n.getString("termora.macro")) val macrosCheckBox = JCheckBox(I18n.getString("termora.macro"))
val keymapCheckBox = JCheckBox(I18n.getString("termora.settings.keymap")) val keymapCheckBox = JCheckBox(I18n.getString("termora.settings.keymap"))
@@ -573,19 +682,23 @@ class SettingsOptionsPane : OptionsPane() {
add(getCenterComponent(), BorderLayout.CENTER) add(getCenterComponent(), BorderLayout.CENTER)
} }
@OptIn(DelicateCoroutinesApi::class)
private fun initEvents() { private fun initEvents() {
downloadConfigButton.addActionListener { syncConfigButton.addActionListener(object : AbstractAction() {
GlobalScope.launch(Dispatchers.IO) { override fun actionPerformed(e: ActionEvent) {
pushOrPull(false) if (typeComboBox.selectedItem == SyncType.WebDAV) {
if (tokenTextField.password.isEmpty()) {
tokenTextField.outline = FlatClientProperties.OUTLINE_ERROR
tokenTextField.requestFocusInWindow()
return
} else if (gistTextField.text.isEmpty()) {
gistTextField.outline = FlatClientProperties.OUTLINE_ERROR
gistTextField.requestFocusInWindow()
return
}
}
swingCoroutineScope.launch(Dispatchers.IO) { sync() }
} }
} })
uploadConfigButton.addActionListener {
GlobalScope.launch(Dispatchers.IO) {
pushOrPull(true)
}
}
typeComboBox.addItemListener { typeComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) { if (it.stateChange == ItemEvent.SELECTED) {
@@ -604,6 +717,12 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
policyComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
sync.policy = (policyComboBox.selectedItem as SyncPolicy).name
}
}
tokenTextField.document.addDocumentListener(object : DocumentAdaptor() { tokenTextField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) { override fun changedUpdate(e: DocumentEvent) {
sync.token = String(tokenTextField.password) sync.token = String(tokenTextField.password)
@@ -624,6 +743,7 @@ class SettingsOptionsPane : OptionsPane() {
} }
}) })
visitGistBtn.addActionListener { visitGistBtn.addActionListener {
if (typeComboBox.selectedItem == SyncType.GitLab) { if (typeComboBox.selectedItem == SyncType.GitLab) {
if (domainTextField.text.isNotBlank()) { if (domainTextField.text.isNotBlank()) {
@@ -665,20 +785,52 @@ class SettingsOptionsPane : OptionsPane() {
keysCheckBox.addActionListener { refreshButtons() } keysCheckBox.addActionListener { refreshButtons() }
hostsCheckBox.addActionListener { refreshButtons() } hostsCheckBox.addActionListener { refreshButtons() }
snippetsCheckBox.addActionListener { refreshButtons() }
keywordHighlightsCheckBox.addActionListener { refreshButtons() } keywordHighlightsCheckBox.addActionListener { refreshButtons() }
} }
private suspend fun sync() {
// 如果 gist 为空说明要创建一个 gist
if (gistTextField.text.isBlank()) {
if (!pushOrPull(true)) return
} else {
if (!pushOrPull(false)) return
if (!pushOrPull(true)) return
}
withContext(Dispatchers.Swing) {
if (hostsCheckBox.isSelected) {
for (window in TermoraFrameManager.getInstance().getWindows()) {
visit(window.rootPane) {
if (it is NewHostTree) it.refreshNode()
}
}
}
OptionPane.showMessageDialog(owner, message = I18n.getString("termora.settings.sync.done"))
}
}
private fun visit(c: JComponent, consumer: Consumer<JComponent>) {
for (e in c.components) {
if (e is JComponent) {
consumer.accept(e)
visit(e, consumer)
}
}
}
private fun refreshButtons() { private fun refreshButtons() {
sync.rangeKeyPairs = keysCheckBox.isSelected sync.rangeKeyPairs = keysCheckBox.isSelected
sync.rangeHosts = hostsCheckBox.isSelected sync.rangeHosts = hostsCheckBox.isSelected
sync.rangeSnippets = snippetsCheckBox.isSelected
sync.rangeKeywordHighlights = keywordHighlightsCheckBox.isSelected sync.rangeKeywordHighlights = keywordHighlightsCheckBox.isSelected
downloadConfigButton.isEnabled = keysCheckBox.isSelected || hostsCheckBox.isSelected syncConfigButton.isEnabled = keysCheckBox.isSelected || hostsCheckBox.isSelected
|| keywordHighlightsCheckBox.isSelected || keywordHighlightsCheckBox.isSelected
uploadConfigButton.isEnabled = downloadConfigButton.isEnabled exportConfigButton.isEnabled = syncConfigButton.isEnabled
exportConfigButton.isEnabled = downloadConfigButton.isEnabled importConfigButton.isEnabled = syncConfigButton.isEnabled
importConfigButton.isEnabled = downloadConfigButton.isEnabled
} }
private fun export() { private fun export() {
@@ -848,6 +1000,17 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
if (ranges.contains(SyncRange.Snippets)) {
val snippets = json["snippets"]
if (snippets is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<Snippet>>(snippets.jsonArray) }.onSuccess {
for (snippet in it) {
snippetManager.addSnippet(snippet)
}
}
}
}
if (ranges.contains(SyncRange.KeyPairs)) { if (ranges.contains(SyncRange.KeyPairs)) {
val keyPairs = json["keyPairs"] val keyPairs = json["keyPairs"]
if (keyPairs is JsonArray) { if (keyPairs is JsonArray) {
@@ -909,6 +1072,9 @@ class SettingsOptionsPane : OptionsPane() {
if (syncConfig.ranges.contains(SyncRange.Hosts)) { if (syncConfig.ranges.contains(SyncRange.Hosts)) {
put("hosts", ohMyJson.encodeToJsonElement(hostManager.hosts())) put("hosts", ohMyJson.encodeToJsonElement(hostManager.hosts()))
} }
if (syncConfig.ranges.contains(SyncRange.Snippets)) {
put("snippets", ohMyJson.encodeToJsonElement(snippetManager.snippets()))
}
if (syncConfig.ranges.contains(SyncRange.KeyPairs)) { if (syncConfig.ranges.contains(SyncRange.KeyPairs)) {
put("keyPairs", ohMyJson.encodeToJsonElement(keyManager.getOhKeyPairs())) put("keyPairs", ohMyJson.encodeToJsonElement(keyManager.getOhKeyPairs()))
} }
@@ -978,6 +1144,9 @@ class SettingsOptionsPane : OptionsPane() {
if (keymapCheckBox.isSelected) { if (keymapCheckBox.isSelected) {
range.add(SyncRange.Keymap) range.add(SyncRange.Keymap)
} }
if (snippetsCheckBox.isSelected) {
range.add(SyncRange.Snippets)
}
return SyncConfig( return SyncConfig(
type = typeComboBox.selectedItem as SyncType, type = typeComboBox.selectedItem as SyncType,
token = String(tokenTextField.password), token = String(tokenTextField.password),
@@ -987,8 +1156,11 @@ class SettingsOptionsPane : OptionsPane() {
) )
} }
/**
* @return true 同步成功
*/
@Suppress("DuplicatedCode") @Suppress("DuplicatedCode")
private suspend fun pushOrPull(push: Boolean) { private suspend fun pushOrPull(push: Boolean): Boolean {
if (typeComboBox.selectedItem == SyncType.GitLab) { if (typeComboBox.selectedItem == SyncType.GitLab) {
if (domainTextField.text.isBlank()) { if (domainTextField.text.isBlank()) {
@@ -996,7 +1168,7 @@ class SettingsOptionsPane : OptionsPane() {
domainTextField.outline = "error" domainTextField.outline = "error"
domainTextField.requestFocusInWindow() domainTextField.requestFocusInWindow()
} }
return return false
} }
} }
@@ -1005,7 +1177,7 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.outline = "error" tokenTextField.outline = "error"
tokenTextField.requestFocusInWindow() tokenTextField.requestFocusInWindow()
} }
return return false
} }
if (gistTextField.text.isBlank() && !push) { if (gistTextField.text.isBlank() && !push) {
@@ -1013,39 +1185,13 @@ class SettingsOptionsPane : OptionsPane() {
gistTextField.outline = "error" gistTextField.outline = "error"
gistTextField.requestFocusInWindow() gistTextField.requestFocusInWindow()
} }
return return false
}
// 没有拉取过 && 是推送 && gistId 不为空
if (!pulled && push && gistTextField.text.isNotBlank()) {
val code = withContext(Dispatchers.Swing) {
// 提示第一次推送
OptionPane.showConfirmDialog(
owner,
I18n.getString("termora.settings.sync.push-warning"),
messageType = JOptionPane.WARNING_MESSAGE,
optionType = JOptionPane.YES_NO_CANCEL_OPTION,
options = arrayOf(
uploadConfigButton.text,
downloadConfigButton.text,
I18n.getString("termora.cancel")
),
initialValue = I18n.getString("termora.cancel")
)
}
when (code) {
-1, JOptionPane.CANCEL_OPTION -> return
JOptionPane.NO_OPTION -> pushOrPull(false) // pull
JOptionPane.YES_OPTION -> pulled = true // force push
}
} }
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
exportConfigButton.isEnabled = false exportConfigButton.isEnabled = false
importConfigButton.isEnabled = false importConfigButton.isEnabled = false
downloadConfigButton.isEnabled = false syncConfigButton.isEnabled = false
uploadConfigButton.isEnabled = false
typeComboBox.isEnabled = false typeComboBox.isEnabled = false
gistTextField.isEnabled = false gistTextField.isEnabled = false
tokenTextField.isEnabled = false tokenTextField.isEnabled = false
@@ -1054,20 +1200,16 @@ class SettingsOptionsPane : OptionsPane() {
keymapCheckBox.isEnabled = false keymapCheckBox.isEnabled = false
keywordHighlightsCheckBox.isEnabled = false keywordHighlightsCheckBox.isEnabled = false
hostsCheckBox.isEnabled = false hostsCheckBox.isEnabled = false
snippetsCheckBox.isEnabled = false
domainTextField.isEnabled = false domainTextField.isEnabled = false
syncConfigButton.text = "${I18n.getString("termora.settings.sync")}..."
if (push) {
uploadConfigButton.text = "${I18n.getString("termora.settings.sync.push")}..."
} else {
downloadConfigButton.text = "${I18n.getString("termora.settings.sync.pull")}..."
}
} }
val syncConfig = getSyncConfig() val syncConfig = getSyncConfig()
// sync // sync
val syncResult = kotlin.runCatching { val syncResult = kotlin.runCatching {
val syncer = SyncerProvider.getInstance().getSyncer(syncConfig.type) val syncer = SyncManager.getInstance()
if (push) { if (push) {
syncer.push(syncConfig) syncer.push(syncConfig)
} else { } else {
@@ -1077,12 +1219,12 @@ class SettingsOptionsPane : OptionsPane() {
// 恢复状态 // 恢复状态
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
downloadConfigButton.isEnabled = true syncConfigButton.isEnabled = true
exportConfigButton.isEnabled = true exportConfigButton.isEnabled = true
importConfigButton.isEnabled = true importConfigButton.isEnabled = true
uploadConfigButton.isEnabled = true
keysCheckBox.isEnabled = true keysCheckBox.isEnabled = true
hostsCheckBox.isEnabled = true hostsCheckBox.isEnabled = true
snippetsCheckBox.isEnabled = true
typeComboBox.isEnabled = true typeComboBox.isEnabled = true
macrosCheckBox.isEnabled = true macrosCheckBox.isEnabled = true
keymapCheckBox.isEnabled = true keymapCheckBox.isEnabled = true
@@ -1090,11 +1232,7 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.isEnabled = true tokenTextField.isEnabled = true
domainTextField.isEnabled = true domainTextField.isEnabled = true
keywordHighlightsCheckBox.isEnabled = true keywordHighlightsCheckBox.isEnabled = true
if (push) { syncConfigButton.text = I18n.getString("termora.settings.sync")
uploadConfigButton.text = I18n.getString("termora.settings.sync.push")
} else {
downloadConfigButton.text = I18n.getString("termora.settings.sync.pull")
}
} }
// 如果失败,提示错误 // 如果失败,提示错误
@@ -1114,10 +1252,8 @@ class SettingsOptionsPane : OptionsPane() {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(owner, message, messageType = JOptionPane.ERROR_MESSAGE) OptionPane.showMessageDialog(owner, message, messageType = JOptionPane.ERROR_MESSAGE)
} }
} else {
// pulled
if (!pulled) pulled = !push
} else {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
sync.lastSyncTime = now sync.lastSyncTime = now
@@ -1126,14 +1262,10 @@ class SettingsOptionsPane : OptionsPane() {
if (push && gistTextField.text.isBlank()) { if (push && gistTextField.text.isBlank()) {
gistTextField.text = syncResult.map { it.config }.getOrDefault(syncConfig).gistId gistTextField.text = syncResult.map { it.config }.getOrDefault(syncConfig).gistId
} }
OptionPane.showMessageDialog(
owner,
message = I18n.getString("termora.settings.sync.done"),
duration = 1500.milliseconds,
)
} }
} }
return syncResult.isSuccess
} }
@@ -1143,18 +1275,29 @@ class SettingsOptionsPane : OptionsPane() {
typeComboBox.addItem(SyncType.Gitee) typeComboBox.addItem(SyncType.Gitee)
typeComboBox.addItem(SyncType.WebDAV) typeComboBox.addItem(SyncType.WebDAV)
policyComboBox.addItem(SyncPolicy.Manual)
policyComboBox.addItem(SyncPolicy.OnChange)
hostsCheckBox.isFocusable = false hostsCheckBox.isFocusable = false
snippetsCheckBox.isFocusable = false
keysCheckBox.isFocusable = false keysCheckBox.isFocusable = false
keywordHighlightsCheckBox.isFocusable = false keywordHighlightsCheckBox.isFocusable = false
macrosCheckBox.isFocusable = false macrosCheckBox.isFocusable = false
keymapCheckBox.isFocusable = false keymapCheckBox.isFocusable = false
hostsCheckBox.isSelected = sync.rangeHosts hostsCheckBox.isSelected = sync.rangeHosts
snippetsCheckBox.isSelected = sync.rangeSnippets
keysCheckBox.isSelected = sync.rangeKeyPairs keysCheckBox.isSelected = sync.rangeKeyPairs
keywordHighlightsCheckBox.isSelected = sync.rangeKeywordHighlights keywordHighlightsCheckBox.isSelected = sync.rangeKeywordHighlights
macrosCheckBox.isSelected = sync.rangeMacros macrosCheckBox.isSelected = sync.rangeMacros
keymapCheckBox.isSelected = sync.rangeKeymap keymapCheckBox.isSelected = sync.rangeKeymap
if (sync.policy == SyncPolicy.Manual.name) {
policyComboBox.selectedItem = SyncPolicy.Manual
} else if (sync.policy == SyncPolicy.OnChange.name) {
policyComboBox.selectedItem = SyncPolicy.OnChange
}
typeComboBox.selectedItem = sync.type typeComboBox.selectedItem = sync.type
gistTextField.text = sync.gist gistTextField.text = sync.gist
tokenTextField.text = sync.token tokenTextField.text = sync.token
@@ -1202,6 +1345,23 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
policyComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value?.toString() ?: StringUtils.EMPTY
if (value == SyncPolicy.Manual) {
text = I18n.getString("termora.settings.sync.policy.manual")
} else if (value == SyncPolicy.OnChange) {
text = I18n.getString("termora.settings.sync.policy.on-change")
}
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
}
}
val lastSyncTime = sync.lastSyncTime val lastSyncTime = sync.lastSyncTime
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${ lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${
@@ -1212,6 +1372,7 @@ class SettingsOptionsPane : OptionsPane() {
refreshButtons() refreshButtons()
} }
override fun getIcon(isSelected: Boolean): Icon { override fun getIcon(isSelected: Boolean): Icon {
@@ -1229,14 +1390,14 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent { private fun getCenterComponent(): JComponent {
val layout = FormLayout( val layout = FormLayout(
"left:pref, $formMargin, default:grow, 30dlu", "left:pref, $formMargin, default:grow, 30dlu",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
) )
val rangeBox = FormBuilder.create() val rangeBox = FormBuilder.create()
.layout( .layout(
FormLayout( FormLayout(
"left:pref, $formMargin, left:pref, $formMargin, left:pref", "left:pref, $formMargin, left:pref, $formMargin, left:pref",
"pref, $formMargin, pref" "pref, 2dlu, pref"
) )
) )
.add(hostsCheckBox).xy(1, 1) .add(hostsCheckBox).xy(1, 1)
@@ -1244,6 +1405,7 @@ class SettingsOptionsPane : OptionsPane() {
.add(keywordHighlightsCheckBox).xy(5, 1) .add(keywordHighlightsCheckBox).xy(5, 1)
.add(macrosCheckBox).xy(1, 3) .add(macrosCheckBox).xy(1, 3)
.add(keymapCheckBox).xy(3, 3) .add(keymapCheckBox).xy(3, 3)
.add(snippetsCheckBox).xy(5, 3)
.build() .build()
var rows = 1 var rows = 1
@@ -1278,20 +1440,26 @@ class SettingsOptionsPane : OptionsPane() {
gistTextField.trailingComponent = visitGistBtn gistTextField.trailingComponent = visitGistBtn
} }
val syncPolicyBox = Box.createHorizontalBox()
syncPolicyBox.add(policyComboBox)
syncPolicyBox.add(Box.createHorizontalGlue())
syncPolicyBox.add(Box.createHorizontalGlue())
builder.add("${tokenText}:").xy(1, rows) builder.add("${tokenText}:").xy(1, rows)
.add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step } .add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step }
.add("${gistText}:").xy(1, rows) .add("${gistText}:").xy(1, rows)
.add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step } .add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.policy")}:").xy(1, rows)
.add(syncPolicyBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows) .add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows)
.add(rangeBox).xy(3, rows).apply { rows += step } .add(rangeBox).xy(3, rows).apply { rows += step }
// Sync buttons // Sync buttons
.add( .add(
FormBuilder.create() FormBuilder.create()
.layout(FormLayout("pref, 2dlu, pref, 2dlu, pref, 2dlu, pref", "pref")) .layout(FormLayout("pref, 2dlu, pref, 2dlu, pref", "pref"))
.add(uploadConfigButton).xy(1, 1) .add(syncConfigButton).xy(1, 1)
.add(downloadConfigButton).xy(3, 1) .add(exportConfigButton).xy(3, 1)
.add(exportConfigButton).xy(5, 1) .add(importConfigButton).xy(5, 1)
.add(importConfigButton).xy(7, 1)
.build() .build()
).xy(3, rows, "center, fill").apply { rows += step } ).xy(3, rows, "center, fill").apply { rows += step }
.add(lastSyncTimeLabel).xy(3, rows, "center, fill").apply { rows += step } .add(lastSyncTimeLabel).xy(3, rows, "center, fill").apply { rows += step }
@@ -1306,9 +1474,11 @@ class SettingsOptionsPane : OptionsPane() {
private val editCommandField = OutlineTextField(255) private val editCommandField = OutlineTextField(255)
private val sftpCommandField = OutlineTextField(255) private val sftpCommandField = OutlineTextField(255)
private val defaultDirectoryField = OutlineTextField(255)
private val browseDirectoryBtn = JButton(Icons.folder)
private val pinTabComboBox = YesOrNoComboBox() private val pinTabComboBox = YesOrNoComboBox()
private val preserveModificationTimeComboBox = YesOrNoComboBox()
private val sftp get() = database.sftp private val sftp get() = database.sftp
private val sftpAction get() = actionManager.getAction(Actions.SFTP) as SFTPAction
init { init {
initView() initView()
@@ -1330,25 +1500,53 @@ class SettingsOptionsPane : OptionsPane() {
} }
}) })
pinTabComboBox.addItemListener { defaultDirectoryField.document.addDocumentListener(object : DocumentAdaptor() {
if (it.stateChange == ItemEvent.SELECTED) { override fun changedUpdate(e: DocumentEvent) {
sftp.defaultDirectory = defaultDirectoryField.text
}
})
pinTabComboBox.addItemListener(object : ItemListener {
override fun itemStateChanged(e: ItemEvent) {
if (e.stateChange != ItemEvent.SELECTED) return
sftp.pinTab = pinTabComboBox.selectedItem as Boolean sftp.pinTab = pinTabComboBox.selectedItem as Boolean
for (window in TermoraFrameManager.getInstance().getWindows()) { for (window in TermoraFrameManager.getInstance().getWindows()) {
val evt = AnActionEvent(window, StringUtils.EMPTY, EventObject(window)) val evt = AnActionEvent(window, StringUtils.EMPTY, EventObject(window))
if (pinTabComboBox.selectedItem == true) {
sftpAction.openOrCreateSFTPTerminalTab(evt)
}
val tabbed = evt.getData(DataProviders.TabbedPane) ?: continue
val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue
for ((index, tab) in manager.getTerminalTabs().withIndex()) {
if (tab is SFTPTerminalTab) { if (sftp.pinTab) {
tabbed.setTabClosable(index, pinTabComboBox.selectedItem != true) if (manager.getTerminalTabs().none { it is SFTPTab }) {
break manager.addTerminalTab(1, SFTPTab(), false)
} }
} }
// 刷新状态
manager.refreshTerminalTabs()
} }
} }
})
preserveModificationTimeComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
sftp.preserveModificationTime = preserveModificationTimeComboBox.selectedItem as Boolean
}
} }
browseDirectoryBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val chooser = FileChooser()
chooser.allowsMultiSelection = false
chooser.defaultDirectory = StringUtils.defaultIfBlank(
defaultDirectoryField.text,
SystemUtils.USER_HOME
)
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.showOpenDialog(owner).thenAccept { files ->
if (files.isNotEmpty()) defaultDirectoryField.text = files.first().absolutePath
}
}
})
} }
@@ -1365,9 +1563,14 @@ class SettingsOptionsPane : OptionsPane() {
sftpCommandField.placeholderText = "sftp" sftpCommandField.placeholderText = "sftp"
} }
defaultDirectoryField.placeholderText = SystemUtils.USER_HOME
defaultDirectoryField.trailingComponent = browseDirectoryBtn
defaultDirectoryField.text = sftp.defaultDirectory
editCommandField.text = sftp.editCommand editCommandField.text = sftp.editCommand
sftpCommandField.text = sftp.sftpCommand sftpCommandField.text = sftp.sftpCommand
pinTabComboBox.selectedItem = sftp.pinTab pinTabComboBox.selectedItem = sftp.pinTab
preserveModificationTimeComboBox.selectedItem = sftp.preserveModificationTime
} }
override fun getIcon(isSelected: Boolean): Icon { override fun getIcon(isSelected: Boolean): Icon {
@@ -1388,13 +1591,23 @@ class SettingsOptionsPane : OptionsPane() {
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
) )
val box = Box.createHorizontalBox()
box.add(JLabel("${I18n.getString("termora.settings.sftp.preserve-time")}:"))
box.add(Box.createHorizontalStrut(8))
box.add(preserveModificationTimeComboBox)
var rows = 1
val builder = FormBuilder.create().layout(layout).debug(false) val builder = FormBuilder.create().layout(layout).debug(false)
builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, 1) builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, rows)
builder.add(pinTabComboBox).xy(3, 1) builder.add(pinTabComboBox).xy(3, rows).apply { rows += 2 }
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, 3) builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, rows)
builder.add(editCommandField).xy(3, 3) builder.add(editCommandField).xy(3, rows).apply { rows += 2 }
builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, 5) builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, rows)
builder.add(sftpCommandField).xy(3, 5) builder.add(sftpCommandField).xy(3, rows).apply { rows += 2 }
builder.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
builder.add(defaultDirectoryField).xy(3, rows).apply { rows += 2 }
builder.add(box).xyw(1, rows, 3).apply { rows += 2 }
return builder.build() return builder.build()
@@ -1612,13 +1825,15 @@ class SettingsOptionsPane : OptionsPane() {
val hosts = hostManager.hosts() val hosts = hostManager.hosts()
val keyPairs = keyManager.getOhKeyPairs() val keyPairs = keyManager.getOhKeyPairs()
val snippets = snippetManager.snippets()
// 获取到安全的属性,如果设置密码那表示之前并未加密 // 获取到安全的属性,如果设置密码那表示之前并未加密
// 这里取出来之后重新存储加密 // 这里取出来之后重新存储加密
val properties = database.getSafetyProperties().map { Pair(it, it.getProperties()) } val properties = database.getSafetyProperties().map { Pair(it, it.getProperties()) }
val key = doorman.work(passwordTextField.password) val key = doorman.work(passwordTextField.password)
hosts.forEach { hostManager.addHost(it) } hosts.forEach { hostManager.addHost(it) }
snippets.forEach { snippetManager.addSnippet(it) }
keyPairs.forEach { keyManager.addOhKeyPair(it) } keyPairs.forEach { keyManager.addOhKeyPair(it) }
for (e in properties) { for (e in properties) {
for ((k, v) in e.second) { for ((k, v) in e.second) {

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,11 +1,19 @@
package app.termora package app.termora
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize 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.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.ClientBuilder import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient 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.ChannelShell
import org.apache.sshd.client.channel.ClientChannelEvent import org.apache.sshd.client.channel.ClientChannelEvent
import org.apache.sshd.client.config.hosts.HostConfigEntry import org.apache.sshd.client.config.hosts.HostConfigEntry
@@ -15,40 +23,72 @@ import org.apache.sshd.client.kex.DHGClient
import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier
import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
import org.apache.sshd.client.keyverifier.ServerKeyVerifier 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.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.SshException
import org.apache.sshd.common.channel.ChannelFactory
import org.apache.sshd.common.channel.PtyChannelConfiguration 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.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.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.kex.BuiltinDHFactories
import org.apache.sshd.common.keyprovider.KeyIdentityProvider 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.common.util.net.SshdSocketAddress
import org.apache.sshd.core.CoreModuleProperties import org.apache.sshd.core.CoreModuleProperties
import org.apache.sshd.server.forward.AcceptAllForwardingFilter import org.apache.sshd.server.forward.AcceptAllForwardingFilter
import org.apache.sshd.server.forward.RejectAllForwardingFilter import org.apache.sshd.server.forward.RejectAllForwardingFilter
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.eclipse.jgit.internal.transport.sshd.JGitSshClient 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.CredentialsProvider
import org.eclipse.jgit.transport.SshConstants.IDENTITY_AGENT
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider 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 org.slf4j.LoggerFactory
import java.awt.Font
import java.awt.Window import java.awt.Window
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy
import java.net.SocketAddress import java.net.SocketAddress
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import java.security.PublicKey import java.security.PublicKey
import java.time.Duration import java.time.Duration
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JOptionPane import java.util.concurrent.atomic.AtomicReference
import javax.swing.SwingUtilities import javax.swing.*
import kotlin.math.max import kotlin.math.max
@Suppress("CascadeIf")
object SshClients { object SshClients {
val HOST_KEY = AttributeRepository.AttributeKey<Host>()
private val timeout = Duration.ofSeconds(30) private val timeout = Duration.ofSeconds(30)
private val hostManager get() = HostManager.getInstance()
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) } private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
/** /**
@@ -71,6 +111,12 @@ object SshClients {
env.putAll(host.options.envs()) env.putAll(host.options.envs())
val channel = session.createShellChannel(configuration, env) 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()) { if (!channel.open().verify(timeout).await()) {
throw SshException("Failed to open Shell") throw SshException("Failed to open Shell")
} }
@@ -111,16 +157,16 @@ object SshClients {
* 打开一个会话 * 打开一个会话
*/ */
fun openSession(host: Host, client: SshClient): ClientSession { fun openSession(host: Host, client: SshClient): ClientSession {
val h = hostManager.getHost(host.id) ?: host
// 如果没有跳板机直接连接 // 如果没有跳板机直接连接
if (host.options.jumpHosts.isEmpty()) { if (h.options.jumpHosts.isEmpty()) {
return doOpenSession(host, client) return doOpenSession(h, client)
} }
val jumpHosts = mutableListOf<Host>() val jumpHosts = mutableListOf<Host>()
val hosts = HostManager.getInstance().hosts().associateBy { it.id } val hosts = HostManager.getInstance().hosts().associateBy { it.id }
for (jumpHostId in host.options.jumpHosts) { for (jumpHostId in h.options.jumpHosts) {
val e = hosts[jumpHostId] val e = hosts[jumpHostId]
if (e == null) { if (e == null) {
if (log.isWarnEnabled) { if (log.isWarnEnabled) {
@@ -132,7 +178,7 @@ object SshClients {
} }
// 最后一跳是目标机器 // 最后一跳是目标机器
jumpHosts.add(host) jumpHosts.add(h)
val sessions = mutableListOf<ClientSession>() val sessions = mutableListOf<ClientSession>()
for (i in 0 until jumpHosts.size) { for (i in 0 until jumpHosts.size) {
@@ -151,7 +197,8 @@ object SshClients {
log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}") log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
} }
// 映射完毕之后修改Host和端口 // 映射完毕之后修改Host和端口
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis()) jumpHosts[i + 1] =
nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
} }
} }
@@ -177,20 +224,54 @@ object SshClients {
entry.username = host.username entry.username = host.username
entry.hostName = host.host entry.hostName = host.host
entry.setProperty("Middleware", middleware.toString()) entry.setProperty("Middleware", middleware.toString())
entry.setProperty("Host", host.id)
val session = client.connect(entry) // 设置代理
.verify(timeout).session // 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) { if (host.authentication.type == AuthenticationType.Password) {
session.addPasswordIdentity(host.authentication.password) session.addPasswordIdentity(host.authentication.password)
} else if (host.authentication.type == AuthenticationType.PublicKey) { } else if (host.authentication.type == AuthenticationType.PublicKey) {
session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password) session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password)
} }
val verifyTimeout = Duration.ofSeconds(timeout.seconds * 5) if (host.options.enableX11Forwarding) {
if (!session.auth().verify(verifyTimeout).await(verifyTimeout)) { val segments = host.options.x11Forwarding.split(":")
throw SshException("Authentication failed") 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 authentication = ask(host, owner) ?: throw e
if (authentication.type == AuthenticationType.No) throw e
return doOpenSession(host.copy(authentication = authentication), client)
}
session.setAttribute(HOST_KEY, host)
return session return session
} }
@@ -230,17 +311,27 @@ object SshClients {
return sshdSocketAddress 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 { fun openClient(host: Host): SshClient {
val builder = ClientBuilder.builder() val builder = ClientBuilder.builder()
builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE)) builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE))
.factory { JGitSshClient() } .factory { MyJGitSshClient() }
val keyExchangeFactories = ClientBuilder.setUpDefaultKeyExchanges(true).toMutableList() val keyExchangeFactories = ClientBuilder.setUpDefaultKeyExchanges(true).toMutableList()
// https://github.com/TermoraDev/termora/issues/123 // https://github.com/TermoraDev/termora/issues/123
@Suppress("DEPRECATION")
keyExchangeFactories.addAll( keyExchangeFactories.addAll(
listOf( listOf(
DHGClient.newFactory(BuiltinDHFactories.dhg1), DHGClient.newFactory(BuiltinDHFactories.dhg1),
@@ -250,6 +341,24 @@ object SshClients {
) )
builder.keyExchangeFactories(keyExchangeFactories) 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()) { if (host.tunnelings.isEmpty() && host.options.jumpHosts.isEmpty()) {
builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE) builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE)
} else { } else {
@@ -258,129 +367,338 @@ object SshClients {
builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY) 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 val sshClient = builder.build() as JGitSshClient
// https://github.com/TermoraDev/termora/issues/180 // https://github.com/TermoraDev/termora/issues/180
// JGit 会尝试读取本地的私钥或缓存的私钥 // JGit 会尝试读取本地的私钥或缓存的私钥
sshClient.keyIdentityProvider = KeyIdentityProvider { mutableListOf() } 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) val heartbeatInterval = max(host.options.heartbeatInterval, 3)
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong())) CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong()))
CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true) CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true)
sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) } sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) }
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.start() sshClient.start()
return sshClient return sshClient
} }
}
private fun ask(host: Host, owner: Window): Authentication? {
val ref = AtomicReference<Authentication>(null)
SwingUtilities.invokeAndWait {
val dialog = RequestAuthenticationDialog(owner, host)
dialog.setLocationRelativeTo(owner)
val authentication = dialog.getAuthentication().apply { ref.set(this) }
// save
if (dialog.isRemembered()) {
hostManager.addHost(
host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
}
return ref.get()
}
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor { private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
override fun verifyServerKey( override fun verifyServerKey(
clientSession: ClientSession, clientSession: ClientSession,
remoteAddress: SocketAddress, remoteAddress: SocketAddress,
serverKey: PublicKey serverKey: PublicKey
): Boolean { ): Boolean {
if (SshClients.isMiddleware(clientSession)) {
return true return true
} }
val result = AtomicBoolean(false) override fun acceptModifiedServerKey(
clientSession: ClientSession?,
SwingUtilities.invokeAndWait { remoteAddress: SocketAddress?,
result.set( entry: KnownHostEntry?,
OptionPane.showConfirmDialog( expected: PublicKey?,
parentComponent = owner, actual: PublicKey?
message = I18n.getString( ): Boolean {
"termora.host.verify-server-key", val result = AtomicBoolean(false)
remoteAddress.toString().replace("/", StringUtils.EMPTY), SwingUtilities.invokeAndWait { result.set(ask(remoteAddress, expected, actual) == JOptionPane.OK_OPTION) }
KeyUtils.getKeyType(serverKey), return result.get()
KeyUtils.getFingerPrint(serverKey)
),
optionType = JOptionPane.OK_CANCEL_OPTION,
messageType = JOptionPane.WARNING_MESSAGE,
) == 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
)
}
} }
override fun acceptModifiedServerKey( private class DialogServerKeyVerifier(
clientSession: ClientSession?, owner: Window,
remoteAddress: SocketAddress?, ) : KnownHostsServerKeyVerifier(
entry: KnownHostEntry?, MyDialogServerKeyVerifier(owner),
expected: PublicKey?, Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts")
actual: PublicKey? ) {
): Boolean { init {
val result = AtomicBoolean(false) modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor
SwingUtilities.invokeAndWait {
result.set(
OptionPane.showConfirmDialog(
parentComponent = owner,
message = I18n.getString(
"termora.host.modified-server-key",
remoteAddress.toString().replace("/", StringUtils.EMPTY),
KeyUtils.getKeyType(expected),
KeyUtils.getFingerPrint(expected),
KeyUtils.getKeyType(actual),
KeyUtils.getFingerPrint(actual),
),
optionType = JOptionPane.OK_CANCEL_OPTION,
messageType = JOptionPane.WARNING_MESSAGE,
) == JOptionPane.OK_OPTION
)
} }
return result.get() override fun updateKnownHostsFile(
} clientSession: ClientSession?,
} remoteAddress: SocketAddress?,
serverKey: PublicKey?,
class DialogServerKeyVerifier( file: Path?,
owner: Window, knownHosts: Collection<HostEntryPair?>?
) : KnownHostsServerKeyVerifier( ): KnownHostEntry? {
MyDialogServerKeyVerifier(owner), if (clientSession is JGitClientSession) {
Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts") if (isMiddleware(clientSession)) {
) { return null
init { }
modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor }
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts)
}
} }
override fun updateKnownHostsFile(
clientSession: ClientSession?, @Suppress("UNCHECKED_CAST")
remoteAddress: SocketAddress?, private class MyJGitSshClient : JGitSshClient() {
serverKey: PublicKey?, companion object {
file: Path?, private val HOST_CONFIG_ENTRY: AttributeRepository.AttributeKey<HostConfigEntry> by lazy {
knownHosts: Collection<HostEntryPair?>? JGitSshClient::class.java.getDeclaredField("HOST_CONFIG_ENTRY").apply { isAccessible = true }
): KnownHostEntry? { .get(null) as AttributeRepository.AttributeKey<HostConfigEntry>
if (clientSession is JGitClientSession) { }
if (SshClients.isMiddleware(clientSession)) { private const val CLIENT_PROXY_CONNECTOR = "ClientProxyConnectorId"
return null }
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
}
}
}
} }
} }
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts)
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

@@ -10,8 +10,8 @@ class TerminalFactory private constructor() : Disposable {
private val terminals = mutableListOf<Terminal>() private val terminals = mutableListOf<Terminal>()
companion object { companion object {
fun getInstance(scope: WindowScope): TerminalFactory { fun getInstance(): TerminalFactory {
return scope.getOrCreate(TerminalFactory::class) { TerminalFactory() } return ApplicationScope.forApplicationScope().getOrCreate(TerminalFactory::class) { TerminalFactory() }
} }
} }

View File

@@ -1,14 +1,22 @@
package app.termora 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.highlight.KeywordHighlightPaintListener
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector import app.termora.terminal.PtyConnector
import app.termora.terminal.Terminal import app.termora.terminal.Terminal
import app.termora.terminal.panel.TerminalHyperlinkPaintListener import app.termora.terminal.panel.TerminalHyperlinkPaintListener
import app.termora.terminal.panel.TerminalPanel import app.termora.terminal.panel.TerminalPanel
import app.termora.terminal.panel.TerminalWriter
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.awt.event.ComponentEvent import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener import java.awt.event.ComponentListener
import java.nio.charset.Charset
import java.util.*
import javax.swing.JComponent
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -17,16 +25,9 @@ class TerminalPanelFactory : Disposable {
companion object { companion object {
private val Factory = DataKey(TerminalPanelFactory::class) fun getInstance(): TerminalPanelFactory {
return ApplicationScope.forApplicationScope()
fun getInstance(scope: Scope): TerminalPanelFactory { .getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
}
fun getAllTerminalPanel(): Array<TerminalPanel> {
return ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }
.flatMap { it.terminalPanels }.toTypedArray()
} }
} }
@@ -37,17 +38,15 @@ class TerminalPanelFactory : Disposable {
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel { fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
val terminalPanel = TerminalPanel(terminal, ptyConnector) val writer = MyTerminalWriter(ptyConnector)
val terminalPanel = TerminalPanel(terminal, writer)
terminalPanel.addTerminalPaintListener(MultipleTerminalListener()) terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance()) terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance()) terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
terminal.getTerminalModel().setData(Factory, this)
Disposer.register(terminalPanel, object : Disposable { Disposer.register(terminalPanel, object : Disposable {
override fun dispose() { override fun dispose() {
if (terminal.getTerminalModel().hasData(Factory)) { removeTerminalPanel(terminalPanel)
terminal.getTerminalModel().getData(Factory).removeTerminalPanel(terminalPanel)
}
} }
}) })
@@ -75,13 +74,12 @@ class TerminalPanelFactory : Disposable {
} }
} }
fun removeTerminalPanel(terminalPanel: TerminalPanel) { private fun removeTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.remove(terminalPanel) terminalPanels.remove(terminalPanel)
} }
fun addTerminalPanel(terminalPanel: TerminalPanel) { private fun addTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.add(terminalPanel) terminalPanels.add(terminalPanel)
terminalPanel.terminal.getTerminalModel().setData(Factory, this)
} }
private class Painter : Disposable { private class Painter : Disposable {
@@ -91,16 +89,13 @@ class TerminalPanelFactory : Disposable {
} }
} }
private val coroutineScope = CoroutineScope(Dispatchers.IO) private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init { init {
coroutineScope.launch { coroutineScope.launch {
while (coroutineScope.isActive) { while (coroutineScope.isActive) {
delay(500.milliseconds) delay(500.milliseconds)
SwingUtilities.invokeLater { SwingUtilities.invokeLater { TerminalPanelFactory.getInstance().repaintAll() }
ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }.forEach { it.repaintAll() }
}
} }
} }
} }
@@ -110,4 +105,58 @@ class TerminalPanelFactory : Disposable {
} }
} }
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,11 @@
package app.termora package app.termora
import app.termora.actions.DataProvider
import java.beans.PropertyChangeListener import java.beans.PropertyChangeListener
import javax.swing.Icon import javax.swing.Icon
import javax.swing.JComponent import javax.swing.JComponent
interface TerminalTab : Disposable { interface TerminalTab : Disposable, DataProvider {
/** /**
* 标题 * 标题
@@ -42,6 +43,11 @@ interface TerminalTab : Disposable {
*/ */
fun canClose(): Boolean = true fun canClose(): Boolean = true
/**
* 返回 true 表示可以关闭
*/
fun willBeClose(): Boolean = true
/** /**
* 是否可以克隆 * 是否可以克隆
*/ */

View File

@@ -6,7 +6,6 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProvider import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult import app.termora.findeverywhere.FindEverywhereResult
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.transport.TransportPanel
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.extras.components.FlatTabbedPane
@@ -78,21 +77,14 @@ class TerminalTabbed(
tabs[oldIndex].onLostFocus() tabs[oldIndex].onLostFocus()
} }
tabbedPane.getComponentAt(newIndex).requestFocusInWindow()
if (newIndex >= 0 && tabs.size > newIndex) { if (newIndex >= 0 && tabs.size > newIndex) {
tabs[newIndex].onGrabFocus() tabs[newIndex].onGrabFocus()
} }
SwingUtilities.invokeLater { tabbedPane.getComponentAt(newIndex).requestFocusInWindow() }
} }
// 选择变动
tabbedPane.addChangeListener {
if (tabbedPane.selectedIndex >= 0) {
val c = tabbedPane.getComponentAt(tabbedPane.selectedIndex)
c.requestFocusInWindow()
}
}
// 右键菜单 // 右键菜单
tabbedPane.addMouseListener(object : MouseAdapter() { tabbedPane.addMouseListener(object : MouseAdapter() {
@@ -128,7 +120,7 @@ class TerminalTabbed(
val results = mutableListOf<FindEverywhereResult>() val results = mutableListOf<FindEverywhereResult>()
for (i in 0 until tabbedPane.tabCount) { for (i in 0 until tabbedPane.tabCount) {
val c = tabbedPane.getComponentAt(i) val c = tabbedPane.getComponentAt(i)
if (c is WelcomePanel || c is TransportPanel) { if (c is JComponent && c.getClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE) != null) {
continue continue
} }
results.add( results.add(
@@ -162,7 +154,7 @@ class TerminalTabbed(
val tab = tabs[index] val tab = tabs[index]
if (disposable) { if (disposable) {
if (!tab.canClose()) { if (!tab.willBeClose()) {
return return
} }
} }
@@ -198,12 +190,11 @@ class TerminalTabbed(
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename")) val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
rename.addActionListener { rename.addActionListener {
if (tabIndex > 0) { if (tabIndex > 0) {
val dialog = InputDialog( val text = OptionPane.showInputDialog(
SwingUtilities.getWindowAncestor(this), SwingUtilities.getWindowAncestor(this),
title = rename.text, title = rename.text,
text = tabbedPane.getTitleAt(tabIndex), value = tabbedPane.getTitleAt(tabIndex)
) )
val text = dialog.getText()
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
tabbedPane.setTitleAt(tabIndex, text) tabbedPane.setTitleAt(tabIndex, text)
c.putClientProperty(titleProperty, text) c.putClientProperty(titleProperty, text)
@@ -222,6 +213,21 @@ class TerminalTabbed(
} }
} }
// 编辑
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")) val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window"))
openInNewWindow.addActionListener(object : AnAction() { openInNewWindow.addActionListener(object : AnAction() {
@@ -283,6 +289,7 @@ class TerminalTabbed(
close.isEnabled = tab.canClose() close.isEnabled = tab.canClose()
rename.isEnabled = close.isEnabled rename.isEnabled = close.isEnabled
clone.isEnabled = close.isEnabled clone.isEnabled = close.isEnabled
edit.isEnabled = tab is HostTerminalTab && tab.host.id != "local"
openInNewWindow.isEnabled = close.isEnabled openInNewWindow.isEnabled = close.isEnabled
// 如果不允许克隆 // 如果不允许克隆
@@ -334,6 +341,13 @@ class TerminalTabbed(
Disposer.register(this, tab) 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) { private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
if (!SFTPPtyTerminalTab.canSupports) { if (!SFTPPtyTerminalTab.canSupports) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(

View File

@@ -7,4 +7,5 @@ interface TerminalTabbedManager {
fun getTerminalTabs(): List<TerminalTab> fun getTerminalTabs(): List<TerminalTab>
fun setSelectedTerminalTab(tab: TerminalTab) fun setSelectedTerminalTab(tab: TerminalTab)
fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true) fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true)
fun refreshTerminalTabs()
} }

View File

@@ -4,23 +4,28 @@ package app.termora
import app.termora.actions.DataProvider import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
import app.termora.sftp.SFTPTab
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.ui.FlatRootPaneUI
import com.formdev.flatlaf.ui.FlatTitlePane
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR import com.jetbrains.JBR
import java.awt.Dimension import org.apache.commons.lang3.ArrayUtils
import java.awt.Insets import java.awt.*
import java.awt.event.MouseAdapter import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.awt.event.MouseListener
import java.awt.event.MouseMotionListener
import java.util.* import java.util.*
import javax.imageio.ImageIO import javax.imageio.ImageIO
import javax.swing.Box import javax.swing.JComponent
import javax.swing.JFrame import javax.swing.JFrame
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import javax.swing.SwingUtilities.isEventDispatchThread import javax.swing.SwingUtilities.isEventDispatchThread
import javax.swing.UIManager import javax.swing.UIManager
import kotlin.math.max
fun assertEventDispatchThread() { fun assertEventDispatchThread() {
if (!isEventDispatchThread()) throw WrongThreadException("AWT EventQueue") if (!isEventDispatchThread()) throw WrongThreadException("AWT EventQueue")
@@ -32,14 +37,13 @@ class TermoraFrame : JFrame(), DataProvider {
private val id = UUID.randomUUID().toString() private val id = UUID.randomUUID().toString()
private val windowScope = ApplicationScope.forWindowScope(this) private val windowScope = ApplicationScope.forWindowScope(this)
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
private val tabbedPane = MyTabbedPane() private val tabbedPane = MyTabbedPane()
private val toolbar = TermoraToolBar(titleBar, tabbedPane) private val toolbar = TermoraToolBar(windowScope, this, tabbedPane)
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane) private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
private val dataProviderSupport = DataProviderSupport() private val dataProviderSupport = DataProviderSupport()
private val welcomePanel = WelcomePanel(windowScope) private val welcomePanel = WelcomePanel(windowScope)
private val sftp get() = Database.getDatabase().sftp private val sftp get() = Database.getDatabase().sftp
private var notifyListeners = emptyArray<NotifyListener>()
init { init {
@@ -48,44 +52,144 @@ class TermoraFrame : JFrame(), DataProvider {
} }
private fun initEvents() { private fun initEvents() {
if (SystemInfo.isLinux) {
val mouseAdapter = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
getMouseHandler()?.mouseClicked(e)
}
forceHitTest() override fun mousePressed(e: MouseEvent) {
getMouseHandler()?.mousePressed(e)
}
// macos 需要判断是否全部删除 override fun mouseDragged(e: MouseEvent) {
// 当 Tab 为 0 的时候,需要加一个边距,避开控制栏 getMouseMotionListener()?.mouseDragged(
if (SystemInfo.isMacOS && isWindowDecorationsSupported) { MouseEvent(
tabbedPane.addChangeListener { e.component,
tabbedPane.leadingComponent = if (tabbedPane.tabCount == 0) { e.id,
Box.createHorizontalStrut(titleBar.leftInset.toInt()) e.`when`,
} else { e.modifiersEx,
null e.x,
e.y,
e.clickCount,
e.isPopupTrigger,
e.button
)
)
}
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() {
if (SystemInfo.isWindows && isWindowDecorationsSupported) {
ThemeManager.getInstance().addThemeChangeListener(object : ThemeChangeListener { private fun hit(e: MouseEvent) {
override fun onChanged() { if (e.source == tabbedPane) {
titleBar.putProperty("controls.dark", FlatLaf.isLafDark()) val index = tabbedPane.indexAtLocation(e.x, e.y)
if (index >= 0) {
return
}
}
customTitleBar.forceHitTest(false)
}
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)
}
} }
})
}
terminalTabbed.addMouseListener(mouseAdapter)
terminalTabbed.addMouseMotionListener(mouseAdapter)
tabbedPane.addMouseListener(mouseAdapter)
tabbedPane.addMouseMotionListener(mouseAdapter)
toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
JBR.getWindowDecorations().setCustomTitleBar(this, customTitleBar)
}
}
} }
private fun initView() { private fun initView() {
if (isWindowDecorationsSupported) {
titleBar.height = UIManager.getInt("TabbedPane.tabHeight").toFloat() // macOS 要避开左边的控制栏
titleBar.putProperty("controls.dark", FlatLaf.isLafDark()) if (SystemInfo.isMacOS) {
JBR.getWindowDecorations().setCustomTitleBar(this, titleBar) 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.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) { if (SystemInfo.isWindows || SystemInfo.isLinux) {
@@ -101,88 +205,26 @@ class TermoraFrame : JFrame(), DataProvider {
terminalTabbed.addTerminalTab(welcomePanel) terminalTabbed.addTerminalTab(welcomePanel)
// 下一次事件循环检测是否固定 SFTP // 下一次事件循环检测是否固定 SFTP
SwingUtilities.invokeLater { if (sftp.pinTab) {
if (sftp.pinTab) { SwingUtilities.invokeLater {
terminalTabbed.addTerminalTab(SFTPTerminalTab(), false) terminalTabbed.addTerminalTab(SFTPTab(), false)
} }
} }
// macOS 要避开左边的控制栏 val glassPane = GlassPane()
if (SystemInfo.isMacOS) { rootPane.glassPane = glassPane
val left = max(titleBar.leftInset.toInt(), 76) glassPane.isOpaque = false
if (tabbedPane.tabCount == 0) { glassPane.isVisible = true
tabbedPane.leadingComponent = Box.createHorizontalStrut(left)
} else {
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0)
}
}
Disposer.register(windowScope, terminalTabbed) Disposer.register(windowScope, terminalTabbed)
add(terminalTabbed) add(terminalTabbed, BorderLayout.CENTER)
dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane) dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane)
dataProviderSupport.addData(DataProviders.TermoraFrame, this) dataProviderSupport.addData(DataProviders.TermoraFrame, this)
dataProviderSupport.addData(DataProviders.WindowScope, windowScope) dataProviderSupport.addData(DataProviders.WindowScope, windowScope)
} }
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.getJToolBar()) {
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.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? { override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey) return dataProviderSupport.getData(dataKey)
?: terminalTabbed.getData(dataKey) ?: terminalTabbed.getData(dataKey)
@@ -203,5 +245,31 @@ class TermoraFrame : JFrame(), DataProvider {
return id.hashCode() return id.hashCode()
} }
fun addNotifyListener(listener: NotifyListener) {
notifyListeners += listener
}
fun removeNotifyListener(listener: NotifyListener) {
notifyListeners = ArrayUtils.removeElements(notifyListeners, listener)
}
override fun addNotify() {
super.addNotify()
notifyListeners.forEach { it.addNotify() }
}
private class GlassPane : JComponent() {
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)
}
}
} }

View File

@@ -1,14 +1,31 @@
package app.termora package app.termora
import app.termora.native.osx.NativeMacLibrary
import com.formdev.flatlaf.ui.FlatNativeWindowsLibrary
import com.formdev.flatlaf.util.SystemInfo 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 org.slf4j.LoggerFactory
import java.awt.Frame
import java.awt.Window
import java.awt.event.WindowAdapter import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent import java.awt.event.WindowEvent
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JFrame
import javax.swing.JOptionPane import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import javax.swing.UIManager
import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
import kotlin.math.max
import kotlin.system.exitProcess import kotlin.system.exitProcess
class TermoraFrameManager {
class TermoraFrameManager : Disposable {
companion object { companion object {
private val log = LoggerFactory.getLogger(TermoraFrameManager::class.java) private val log = LoggerFactory.getLogger(TermoraFrameManager::class.java)
@@ -20,16 +37,43 @@ class TermoraFrameManager {
} }
private val frames = mutableListOf<TermoraFrame>() 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 { fun createWindow(): TermoraFrame {
val frame = TermoraFrame() val frame = TermoraFrame().apply { registerCloseCallback(this) }
registerCloseCallback(frame)
frame.title = if (SystemInfo.isLinux) null else Application.getName() frame.title = if (SystemInfo.isLinux) null else Application.getName()
frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
frame.setSize(1280, 800)
frame.setLocationRelativeTo(null) val rectangle = getFrameRectangle() ?: FrameRectangle(-1, -1, 1280, 800, 0)
frames.add(frame) if (rectangle.isMaximized) {
return frame 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> { fun getWindows(): Array<TermoraFrame> {
@@ -38,9 +82,13 @@ class TermoraFrameManager {
private fun registerCloseCallback(window: TermoraFrame) { private fun registerCloseCallback(window: TermoraFrame) {
val manager = this
window.addWindowListener(object : WindowAdapter() { window.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) { override fun windowClosed(e: WindowEvent) {
// 存储位置信息
saveFrameRectangle(window)
// 删除 // 删除
frames.remove(window) frames.remove(window)
@@ -50,41 +98,132 @@ class TermoraFrameManager {
Disposer.dispose(windowScope) Disposer.dispose(windowScope)
val windowScopes = ApplicationScope.windowScopes() val windowScopes = ApplicationScope.windowScopes()
if (windowScopes.isNotEmpty()) {
return
}
// 如果已经没有 Window 域了,那么就可以退出程序了 // 如果已经没有 Window 域了,那么就可以退出程序了
if (windowScopes.isEmpty()) { if (SystemInfo.isWindows || SystemInfo.isLinux) {
this@TermoraFrameManager.dispose() Disposer.dispose(manager)
} else if (SystemInfo.isMacOS) {
// 如果 macOS 开启了后台运行,那么尽管所有窗口都没了,也不会退出
if (isBackgroundRunning) {
return
}
Disposer.dispose(manager)
} }
} }
override fun windowClosing(e: WindowEvent) { override fun windowClosing(e: WindowEvent) {
if (ApplicationScope.windowScopes().size == 1) { if (ApplicationScope.windowScopes().size != 1) {
if (OptionPane.showConfirmDialog( window.dispose()
window, return
I18n.getString("termora.quit-confirm", Application.getName()), }
optionType = JOptionPane.YES_NO_OPTION,
) == JOptionPane.YES_OPTION // 如果 Windows 开启了后台运行,那么最小化
) { if (SystemInfo.isWindows && isBackgroundRunning) {
window.dispose() // 最小化
} window.extendedState = window.extendedState or JFrame.ICONIFIED
} else { // 隐藏
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() window.dispose()
} }
} }
}) })
} }
private fun dispose() { fun tick() {
Disposer.dispose(ApplicationScope.forApplicationScope()) 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() }
}
}
try { override fun dispose() {
Disposer.getTree().assertIsEmpty(true) if (isDisposed.compareAndSet(false, true)) {
} catch (e: Exception) { Disposer.dispose(ApplicationScope.forApplicationScope())
if (log.isErrorEnabled) {
log.error(e.message, e) try {
Disposer.getTree().assertIsEmpty(true)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
} }
} }
exitProcess(0) 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

@@ -1,18 +1,16 @@
package app.termora package app.termora
import app.termora.Application.ohMyJson import app.termora.Application.ohMyJson
import app.termora.actions.ActionManager import app.termora.actions.*
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import app.termora.findeverywhere.FindEverywhereAction import app.termora.findeverywhere.FindEverywhereAction
import app.termora.snippet.SnippetAction
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.WindowDecorations
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionContainerFactory import org.jdesktop.swingx.action.ActionContainerFactory
import java.awt.Insets import java.awt.Rectangle
import java.awt.event.ComponentAdapter import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent import java.awt.event.ComponentEvent
import javax.swing.Box import javax.swing.Box
@@ -26,7 +24,8 @@ data class ToolBarAction(
) )
class TermoraToolBar( class TermoraToolBar(
private val titleBar: WindowDecorations.CustomTitleBar, private val windowScope: WindowScope,
private val frame: TermoraFrame,
private val tabbedPane: FlatTabbedPane private val tabbedPane: FlatTabbedPane
) { ) {
private val properties by lazy { Database.getDatabase().properties } private val properties by lazy { Database.getDatabase().properties }
@@ -42,12 +41,13 @@ class TermoraToolBar(
*/ */
fun getAllActions(): List<ToolBarAction> { fun getAllActions(): List<ToolBarAction> {
return listOf( return listOf(
ToolBarAction(SnippetAction.SNIPPET, true),
ToolBarAction(Actions.SFTP, true), ToolBarAction(Actions.SFTP, true),
ToolBarAction(Actions.TERMINAL_LOGGER, true), ToolBarAction(Actions.TERMINAL_LOGGER, true),
ToolBarAction(Actions.MACRO, true), ToolBarAction(Actions.MACRO, true),
ToolBarAction(Actions.KEYWORD_HIGHLIGHT, true), ToolBarAction(Actions.KEYWORD_HIGHLIGHT, true),
ToolBarAction(Actions.KEY_MANAGER, true), ToolBarAction(Actions.KEY_MANAGER, true),
ToolBarAction(Actions.MULTIPLE, true), ToolBarAction(MultipleAction.MULTIPLE, true),
ToolBarAction(FindEverywhereAction.FIND_EVERYWHERE, true), ToolBarAction(FindEverywhereAction.FIND_EVERYWHERE, true),
ToolBarAction(SettingsAction.SETTING, true), ToolBarAction(SettingsAction.SETTING, true),
) )
@@ -124,8 +124,13 @@ class TermoraToolBar(
// 获取显示的Action如果不是 false 那么就是显示出来 // 获取显示的Action如果不是 false 那么就是显示出来
for (action in getActions()) { for (action in getActions()) {
if (action.visible) { if (action.visible) {
actionManager.getAction(action.id)?.let { val ac = actionManager.getAction(action.id)
toolbar.add(actionContainerFactory.createButton(it)) if (ac == null) {
if (action.id == MultipleAction.MULTIPLE) {
toolbar.add(actionContainerFactory.createButton(MultipleAction.getInstance(windowScope)))
}
} else {
toolbar.add(actionContainerFactory.createButton(ac))
} }
} }
} }
@@ -150,14 +155,11 @@ class TermoraToolBar(
} }
fun adjust() { fun adjust() {
if (SystemInfo.isMacOS) { if (SystemInfo.isWindows || SystemInfo.isLinux) {
val left = titleBar.leftInset.toInt() val rectangle =
if (tabbedPane.tabAreaInsets.left != left) { frame.rootPane.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0) as? Rectangle ?: return
} val right = rectangle.width
} else if (SystemInfo.isWindows || SystemInfo.isLinux) {
val right = titleBar.rightInset.toInt()
val toolbar = this@MyToolBar val toolbar = this@MyToolBar
for (i in 0 until toolbar.componentCount) { for (i in 0 until toolbar.componentCount) {
val c = toolbar.getComponent(i) val c = toolbar.getComponent(i)

View File

@@ -51,7 +51,7 @@ class OutlineTextArea : FlatTextArea() {
} }
} }
class OutlineComboBox<T> : JComboBox<T>() { class OutlineComboBox<T> : FlatComboBox<T>() {
init { init {
addItemListener { addItemListener {
if (it.stateChange == ItemEvent.SELECTED) { if (it.stateChange == ItemEvent.SELECTED) {
@@ -146,7 +146,7 @@ open class EmailFormattedTextField(var maxLength: Int = Int.MAX_VALUE) : Outline
} }
abstract class NumberSpinner( open class NumberSpinner(
value: Int, value: Int,
minimum: Int, minimum: Int,
maximum: Int, maximum: Int,

View File

@@ -4,7 +4,6 @@ import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatAnimatedLafChange import com.formdev.flatlaf.extras.FlatAnimatedLafChange
import com.jthemedetecor.OsThemeDetector import com.jthemedetecor.OsThemeDetector
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.util.* import java.util.*
@@ -76,8 +75,7 @@ class ThemeManager private constructor() {
init { init {
@Suppress("OPT_IN_USAGE") swingCoroutineScope.launch(Dispatchers.IO) {
GlobalScope.launch(Dispatchers.IO) {
OsThemeDetector.getDetector().registerListener(object : Consumer<Boolean> { OsThemeDetector.getDetector().registerListener(object : Consumer<Boolean> {
override fun accept(isDark: Boolean) { override fun accept(isDark: Boolean) {
if (!appearance.followSystem) { if (!appearance.followSystem) {

View File

@@ -1,8 +1,8 @@
package app.termora package app.termora
import org.apache.commons.lang3.StringUtils
import javax.swing.JTree import javax.swing.JTree
import javax.swing.tree.TreeModel import javax.swing.tree.TreeModel
import javax.swing.tree.TreeNode
object TreeUtils { object TreeUtils {
/** /**
@@ -31,16 +31,6 @@ object TreeUtils {
return nodes return nodes
} }
fun parents(node: TreeNode): List<Any> {
val parents = mutableListOf<Any>()
var p = node.parent
while (p != null) {
parents.add(p)
p = p.parent
}
return parents
}
fun saveExpansionState(tree: JTree): String { fun saveExpansionState(tree: JTree): String {
val rows = mutableListOf<Int>() val rows = mutableListOf<Int>()
for (i in 0 until tree.rowCount) { for (i in 0 until tree.rowCount) {
@@ -63,15 +53,15 @@ object TreeUtils {
} }
} }
fun expandAll(tree: JTree) { fun saveSelectionRows(tree: JTree): String {
var j = tree.rowCount return tree.selectionRows?.joinToString(",") ?: StringUtils.EMPTY
var i = 0 }
while (i < j) {
tree.expandRow(i) fun loadSelectionRows(tree: JTree, state: String) {
i += 1 if (state.isBlank()) return
j = tree.rowCount for (row in state.split(",").mapNotNull { it.toIntOrNull() }) {
tree.addSelectionRow(row)
} }
} }
} }

View File

@@ -60,7 +60,6 @@ class UpdaterManager private constructor() {
val isSelf get() = this == self val isSelf get() = this == self
} }
private val properties get() = Database.getDatabase().properties
var lastVersion = LatestVersion.self var lastVersion = LatestVersion.self
fun fetchLatestVersion(): LatestVersion { fun fetchLatestVersion(): LatestVersion {
@@ -146,12 +145,4 @@ class UpdaterManager private constructor() {
return LatestVersion.self return LatestVersion.self
} }
fun isIgnored(version: String): Boolean {
return properties.getString("ignored.version.$version", "false").toBoolean()
}
fun ignore(version: String) {
properties.putString("ignored.version.$version", "true")
}
} }

View File

@@ -13,10 +13,10 @@ import com.formdev.flatlaf.extras.components.FlatTextField
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension import java.awt.Dimension
import java.awt.event.ActionEvent import java.awt.KeyboardFocusManager
import java.awt.event.ComponentAdapter import java.awt.event.*
import java.awt.event.ComponentEvent
import javax.swing.* import javax.swing.*
import javax.swing.event.DocumentEvent import javax.swing.event.DocumentEvent
import kotlin.math.max import kotlin.math.max
@@ -32,6 +32,7 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean() private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean()
private val dataProviderSupport = DataProviderSupport() private val dataProviderSupport = DataProviderSupport()
private val hostTreeModel = hostTree.model as NewHostTreeModel private val hostTreeModel = hostTree.model as NewHostTreeModel
private var lastFocused: Component? = null
private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) { private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) {
searchTextField.text.isBlank() searchTextField.text.isBlank()
} }
@@ -44,6 +45,7 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
private fun initView() { private fun initView() {
putClientProperty(FlatClientProperties.TABBED_PANE_TAB_CLOSABLE, false) putClientProperty(FlatClientProperties.TABBED_PANE_TAB_CLOSABLE, false)
putClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE, true)
val panel = JPanel(BorderLayout()) val panel = JPanel(BorderLayout())
panel.add(createSearchPanel(), BorderLayout.NORTH) panel.add(createSearchPanel(), BorderLayout.NORTH)
@@ -215,6 +217,26 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
} }
} }
}) })
searchTextField.addKeyListener(object : KeyAdapter() {
private val event = ActionEvent(hostTree, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY)
private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
override fun keyPressed(e: KeyEvent) {
if (e.keyCode == KeyEvent.VK_DOWN || e.keyCode == KeyEvent.VK_ENTER || e.keyCode == KeyEvent.VK_UP) {
when (e.keyCode) {
KeyEvent.VK_UP -> hostTree.actionMap.get("selectPrevious")?.actionPerformed(event)
KeyEvent.VK_DOWN -> hostTree.actionMap.get("selectNext")?.actionPerformed(event)
else -> {
for (node in hostTree.getSelectionSimpleTreeNodes(true)) {
openHostAction?.actionPerformed(OpenHostActionEvent(hostTree, node.host, e))
}
}
}
e.consume()
}
}
})
} }
private fun perform() { private fun perform() {
@@ -258,6 +280,14 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
return false return false
} }
override fun onLostFocus() {
lastFocused = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
}
override fun onGrabFocus() {
SwingUtilities.invokeLater { lastFocused?.requestFocusInWindow() }
}
override fun dispose() { override fun dispose() {
properties.putString("WelcomeFullContent", fullContent.toString()) properties.putString("WelcomeFullContent", fullContent.toString())
properties.putString("Welcome.HostTree.state", TreeUtils.saveExpansionState(hostTree)) properties.putString("Welcome.HostTree.state", TreeUtils.saveExpansionState(hostTree))

View File

@@ -6,8 +6,9 @@ import app.termora.findeverywhere.FindEverywhereAction
import app.termora.highlight.KeywordHighlightAction import app.termora.highlight.KeywordHighlightAction
import app.termora.keymgr.KeyManagerAction import app.termora.keymgr.KeyManagerAction
import app.termora.macro.MacroAction import app.termora.macro.MacroAction
import app.termora.sftp.SFTPAction
import app.termora.snippet.SnippetAction
import app.termora.tlog.TerminalLoggerAction import app.termora.tlog.TerminalLoggerAction
import app.termora.transport.SFTPAction
import javax.swing.Action import javax.swing.Action
class ActionManager : org.jdesktop.swingx.action.ActionManager() { class ActionManager : org.jdesktop.swingx.action.ActionManager() {
@@ -28,12 +29,12 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() {
addAction(NewWindowAction.NEW_WINDOW, NewWindowAction()) addAction(NewWindowAction.NEW_WINDOW, NewWindowAction())
addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction()) addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction())
addAction(Actions.MULTIPLE, MultipleAction())
addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance()) addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance())
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction()) addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction()) addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
addAction(Actions.SFTP, SFTPAction()) addAction(Actions.SFTP, SFTPAction())
addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction()) addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction())
addAction(SnippetAction.SNIPPET, SnippetAction.getInstance())
addAction(Actions.MACRO, MacroAction()) addAction(Actions.MACRO, MacroAction())
addAction(Actions.KEY_MANAGER, KeyManagerAction()) addAction(Actions.KEY_MANAGER, KeyManagerAction())

View File

@@ -36,6 +36,7 @@ class AppUpdateAction private constructor() : AnAction(
StringUtils.EMPTY, StringUtils.EMPTY,
Icons.ideUpdate Icons.ideUpdate
) { ) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Swing)
companion object { companion object {
private val log = LoggerFactory.getLogger(AppUpdateAction::class.java) private val log = LoggerFactory.getLogger(AppUpdateAction::class.java)
@@ -47,6 +48,7 @@ class AppUpdateAction private constructor() : AnAction(
} }
private val updaterManager get() = UpdaterManager.getInstance() private val updaterManager get() = UpdaterManager.getInstance()
private var isRemindMeNextTime = false
init { init {
isEnabled = false isEnabled = false
@@ -58,18 +60,22 @@ class AppUpdateAction private constructor() : AnAction(
} }
@OptIn(DelicateCoroutinesApi::class)
private fun scheduleUpdate() { private fun scheduleUpdate() {
fixedRateTimer( fixedRateTimer(
name = "check-update-timer", name = "check-update-timer",
initialDelay = 3.minutes.inWholeMilliseconds, initialDelay = 3.minutes.inWholeMilliseconds,
period = 5.hours.inWholeMilliseconds, daemon = true period = 5.hours.inWholeMilliseconds, daemon = true
) { ) {
GlobalScope.launch(Dispatchers.IO) { supervisorScope { launch { checkUpdate() } } } if (!isRemindMeNextTime) {
coroutineScope.launch(Dispatchers.IO) { checkUpdate() }
}
} }
} }
private suspend fun checkUpdate() { private suspend fun checkUpdate() {
if (Application.isUnknownVersion()) {
return
}
val latestVersion = updaterManager.fetchLatestVersion() val latestVersion = updaterManager.fetchLatestVersion()
if (latestVersion.isSelf) { if (latestVersion.isSelf) {
@@ -82,10 +88,6 @@ class AppUpdateAction private constructor() : AnAction(
return return
} }
if (updaterManager.isIgnored(latestVersion.version)) {
return
}
try { try {
downloadLatestPkg(latestVersion) downloadLatestPkg(latestVersion)
} catch (e: Exception) { } catch (e: Exception) {
@@ -194,7 +196,7 @@ class AppUpdateAction private constructor() : AnAction(
return return
} else if (option == JOptionPane.NO_OPTION) { } else if (option == JOptionPane.NO_OPTION) {
isEnabled = false isEnabled = false
updaterManager.ignore(lastVersion.version) isRemindMeNextTime = true
} else if (option == JOptionPane.YES_OPTION) { } else if (option == JOptionPane.YES_OPTION) {
updateSelf(lastVersion) updateSelf(lastVersion)
} }
@@ -221,7 +223,10 @@ class AppUpdateAction private constructor() : AnAction(
// 没有安装过 则打开安装向导 // 没有安装过 则打开安装向导
else listOf(file.absolutePath) else listOf(file.absolutePath)
println(commands) if (log.isInfoEnabled) {
log.info("restart {}", commands.joinToString(StringUtils.SPACE))
}
TermoraRestarter.getInstance().scheduleRestart(owner, commands) TermoraRestarter.getInstance().scheduleRestart(owner, commands)
} }

View File

@@ -17,5 +17,5 @@ interface DataProvider {
/** /**
* 数据提供 * 数据提供
*/ */
fun <T : Any> getData(dataKey: DataKey<T>): T? fun <T : Any> getData(dataKey: DataKey<T>): T? = null
} }

View File

@@ -5,7 +5,7 @@ import app.termora.terminal.DataKey
object DataProviders { object DataProviders {
val TerminalPanel = DataKey(app.termora.terminal.panel.TerminalPanel::class) val TerminalPanel = DataKey(app.termora.terminal.panel.TerminalPanel::class)
val Terminal = DataKey(app.termora.terminal.Terminal::class) val Terminal = DataKey(app.termora.terminal.Terminal::class)
val PtyConnector = DataKey(app.termora.terminal.PtyConnector::class) val TerminalWriter get() = DataKey.TerminalWriter
val TabbedPane = DataKey(app.termora.MyTabbedPane::class) val TabbedPane = DataKey(app.termora.MyTabbedPane::class)
val TerminalTabbed = DataKey(app.termora.TerminalTabbed::class) val TerminalTabbed = DataKey(app.termora.TerminalTabbed::class)

View File

@@ -1,17 +1,32 @@
package app.termora.actions package app.termora.actions
import app.termora.* import app.termora.I18n
import app.termora.Icons
import app.termora.TerminalPanelFactory
import app.termora.WindowScope
class MultipleAction : AnAction( class MultipleAction private constructor() : AnAction(
I18n.getString("termora.tools.multiple"), I18n.getString("termora.tools.multiple"),
Icons.vcs Icons.vcs
) { ) {
companion object {
/**
* 将命令发送到多个会话
*/
const val MULTIPLE = "MultipleAction"
fun getInstance(windowScope: WindowScope): MultipleAction {
return windowScope.getOrCreate(MultipleAction::class) { MultipleAction() }
}
}
init { init {
setStateAction() setStateAction()
} }
override fun actionPerformed(evt: AnActionEvent) { override fun actionPerformed(evt: AnActionEvent) {
ApplicationScope.windowScopes().map { TerminalPanelFactory.getInstance(it) } TerminalPanelFactory.getInstance().repaintAll()
.forEach { it.repaintAll() }
} }
} }

View File

@@ -1,6 +1,19 @@
package app.termora.actions package app.termora.actions
import app.termora.* import app.termora.*
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.net.URI
import java.util.*
import javax.swing.JOptionPane
import kotlin.time.Duration.Companion.seconds
class OpenHostAction : AnAction() { class OpenHostAction : AnAction() {
companion object { companion object {
@@ -26,10 +39,70 @@ class OpenHostAction : AnAction() {
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host) Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host) Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
Protocol.SFTPPty -> SFTPPtyTerminalTab(windowScope, evt.host) Protocol.SFTPPty -> SFTPPtyTerminalTab(windowScope, evt.host)
Protocol.RDP -> openRDP(windowScope, evt.host)
else -> LocalTerminalTab(windowScope, evt.host) else -> LocalTerminalTab(windowScope, evt.host)
} }
terminalTabbedManager.addTerminalTab(tab) if (tab is TerminalTab) {
tab.start() terminalTabbedManager.addTerminalTab(tab)
if (tab is PtyHostTerminalTab) {
tab.start()
}
}
}
private fun openRDP(windowScope: WindowScope, host: Host) {
if (SystemInfo.isLinux) {
OptionPane.showMessageDialog(
windowScope.window,
"Linux cannot connect to Windows Remote Server, Supported only for macOS and Windows",
messageType = JOptionPane.WARNING_MESSAGE
)
return
}
if (SystemInfo.isMacOS) {
if (!FileUtils.getFile("/Applications/Windows App.app").exists()) {
val option = OptionPane.showConfirmDialog(
windowScope.window,
"If you want to connect to a Windows Remote Server, You have to install the Windows App",
optionType = JOptionPane.OK_CANCEL_OPTION
)
if (option == JOptionPane.OK_OPTION) {
Application.browse(URI.create("https://apps.apple.com/app/windows-app/id1295203466"))
}
return
}
}
val sb = StringBuilder()
sb.append("full address:s:").append(host.host).append(':').append(host.port).appendLine()
sb.append("username:s:").append(host.username).appendLine()
val file = FileUtils.getFile(Application.getTemporaryDir(), UUID.randomUUID().toSimpleString() + ".rdp")
file.outputStream().use { IOUtils.write(sb.toString(), it, Charsets.UTF_8) }
if (host.authentication.type == AuthenticationType.Password) {
val systemClipboard = windowScope.window.toolkit.systemClipboard
val password = host.authentication.password
systemClipboard.setContents(StringSelection(password), null)
// clear password
swingCoroutineScope.launch(Dispatchers.IO) {
delay(30.seconds)
if (systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
if (systemClipboard.getData(DataFlavor.stringFlavor) == password) {
systemClipboard.setContents(StringSelection(StringUtils.EMPTY), null)
}
}
}
}
if (SystemInfo.isMacOS) {
ProcessBuilder("open", file.absolutePath).start()
} else if (SystemInfo.isWindows) {
ProcessBuilder("mstsc", file.absolutePath).start()
}
} }
} }

View File

@@ -22,6 +22,7 @@ class OpenLocalTerminalAction : AnAction(
OpenHostActionEvent( OpenHostActionEvent(
evt.source, evt.source,
Host( Host(
id = "local",
name = name, name = name,
protocol = Protocol.Local protocol = Protocol.Local
), ),

View File

@@ -32,7 +32,6 @@ class TerminalCopyAction : AnAction() {
} }
systemClipboard.setContents(StringSelection(text), null) systemClipboard.setContents(StringSelection(text), null)
terminalPanel.toast(I18n.getString("termora.terminal.copied"))
if (log.isTraceEnabled) { if (log.isTraceEnabled) {
log.trace("Copy to clipboard. {}", text) log.trace("Copy to clipboard. {}", text)
} }

View File

@@ -1,6 +1,5 @@
package app.termora.actions package app.termora.actions
import app.termora.ApplicationScope
import app.termora.Database import app.termora.Database
import app.termora.TerminalPanelFactory import app.termora.TerminalPanelFactory
@@ -13,10 +12,8 @@ abstract class TerminalZoomAction : AnAction() {
evt.getData(DataProviders.TerminalPanel) ?: return evt.getData(DataProviders.TerminalPanel) ?: return
if (zoom()) { if (zoom()) {
ApplicationScope.windowScopes().forEach { TerminalPanelFactory.getInstance()
TerminalPanelFactory.getInstance(it)
.fireResize() .fireResize()
}
evt.consume() evt.consume()
} }
} }

View File

@@ -6,6 +6,7 @@ import org.jdesktop.swingx.action.AbstractActionExt
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import javax.swing.Action import javax.swing.Action
import javax.swing.Icon import javax.swing.Icon
import javax.swing.SwingUtilities
open class ActionFindEverywhereResult(private val action: Action) : FindEverywhereResult { open class ActionFindEverywhereResult(private val action: Action) : FindEverywhereResult {
private val isState: Boolean private val isState: Boolean
@@ -26,7 +27,7 @@ open class ActionFindEverywhereResult(private val action: Action) : FindEverywhe
if (isState) { if (isState) {
action.putValue(Action.SELECTED_KEY, !isSelected) action.putValue(Action.SELECTED_KEY, !isSelected)
} }
action.actionPerformed(e) SwingUtilities.invokeLater { action.actionPerformed(e) }
} }
override fun getIcon(isSelected: Boolean): Icon { override fun getIcon(isSelected: Boolean): Icon {

View File

@@ -3,12 +3,11 @@ package app.termora.findeverywhere
import app.termora.DialogWrapper import app.termora.DialogWrapper
import app.termora.DynamicColor import app.termora.DynamicColor
import app.termora.I18n import app.termora.I18n
import app.termora.WindowScope
import app.termora.actions.AnAction import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.macro.MacroFindEverywhereProvider import app.termora.macro.MacroFindEverywhereProvider
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextField import com.formdev.flatlaf.extras.components.FlatTextField
import com.jetbrains.JBR
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Dimension import java.awt.Dimension
import java.awt.Insets import java.awt.Insets
@@ -18,7 +17,7 @@ import javax.swing.*
import javax.swing.event.DocumentEvent import javax.swing.event.DocumentEvent
import javax.swing.event.DocumentListener import javax.swing.event.DocumentListener
class FindEverywhere(owner: Window) : DialogWrapper(owner) { class FindEverywhere(owner: Window, windowScope: WindowScope) : DialogWrapper(owner) {
private val searchTextField = FlatTextField() private val searchTextField = FlatTextField()
private val model = DefaultListModel<FindEverywhereResult>() private val model = DefaultListModel<FindEverywhereResult>()
private val resultList = FindEverywhereXList(model) private val resultList = FindEverywhereXList(model)
@@ -26,7 +25,7 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
private val providers = mutableListOf<FindEverywhereProvider>( private val providers = mutableListOf<FindEverywhereProvider>(
BasicFilterFindEverywhereProvider(QuickCommandFindEverywhereProvider()), BasicFilterFindEverywhereProvider(QuickCommandFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(SettingsFindEverywhereProvider()), BasicFilterFindEverywhereProvider(SettingsFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(QuickActionsFindEverywhereProvider()), BasicFilterFindEverywhereProvider(QuickActionsFindEverywhereProvider(windowScope)),
BasicFilterFindEverywhereProvider(MacroFindEverywhereProvider()), BasicFilterFindEverywhereProvider(MacroFindEverywhereProvider()),
) )
@@ -44,17 +43,10 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
minimumSize = Dimension(size.width / 2, size.height / 2) minimumSize = Dimension(size.width / 2, size.height / 2)
isModal = false isModal = false
lostFocusDispose = true lostFocusDispose = true
controlsVisible = false
defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
setLocationRelativeTo(null) setLocationRelativeTo(null)
// 不支持装饰,铺满
if (!JBR.isWindowDecorationsSupported()) {
rootPane.putClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT, true)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_CLOSE, false)
}
rootPane.background = DynamicColor("desktop") val desktopBackground = DynamicColor("desktop")
centerPanel.background = DynamicColor("desktop") centerPanel.background = DynamicColor("desktop")
centerPanel.border = BorderFactory.createEmptyBorder(12, 12, 12, 12) centerPanel.border = BorderFactory.createEmptyBorder(12, 12, 12, 12)
@@ -69,7 +61,7 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
resultList.isRolloverEnabled = false resultList.isRolloverEnabled = false
resultList.selectionMode = ListSelectionModel.SINGLE_SELECTION resultList.selectionMode = ListSelectionModel.SINGLE_SELECTION
resultList.border = BorderFactory.createEmptyBorder(5, 0, 0, 0) resultList.border = BorderFactory.createEmptyBorder(5, 0, 0, 0)
resultList.background = rootPane.background resultList.background = desktopBackground
val scrollPane = JScrollPane(resultList) val scrollPane = JScrollPane(resultList)
@@ -225,5 +217,11 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
super.setVisible(visible) super.setVisible(visible)
} }
override fun addNotify() {
super.addNotify()
controlsVisible = false
fullWindowContent = true
}
} }

View File

@@ -46,7 +46,7 @@ class FindEverywhereAction : AnAction(StringUtils.EMPTY, Icons.find) {
return return
} }
val dialog = FindEverywhere(owner) val dialog = FindEverywhere(owner, scope)
for (provider in FindEverywhereProvider.getFindEverywhereProviders(scope)) { for (provider in FindEverywhereProvider.getFindEverywhereProviders(scope)) {
dialog.registerProvider(provider) dialog.registerProvider(provider)
} }

View File

@@ -5,6 +5,9 @@ import app.termora.Scope
interface FindEverywhereProvider { interface FindEverywhereProvider {
companion object { companion object {
const val SKIP_FIND_EVERYWHERE = "SKIP_FIND_EVERYWHERE"
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun getFindEverywhereProviders(scope: Scope): MutableList<FindEverywhereProvider> { fun getFindEverywhereProviders(scope: Scope): MutableList<FindEverywhereProvider> {
var list = scope.getAnyOrNull("FindEverywhereProviders") var list = scope.getAnyOrNull("FindEverywhereProviders")

View File

@@ -2,21 +2,32 @@ package app.termora.findeverywhere
import app.termora.Actions import app.termora.Actions
import app.termora.I18n import app.termora.I18n
import app.termora.WindowScope
import app.termora.actions.MultipleAction
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
class QuickActionsFindEverywhereProvider : FindEverywhereProvider { class QuickActionsFindEverywhereProvider(private val windowScope: WindowScope) : FindEverywhereProvider {
private val actions = listOf( private val actions = listOf(
Actions.KEY_MANAGER, Actions.KEY_MANAGER,
Actions.KEYWORD_HIGHLIGHT, Actions.KEYWORD_HIGHLIGHT,
Actions.MULTIPLE, MultipleAction.MULTIPLE,
) )
override fun find(pattern: String): List<FindEverywhereResult> { override fun find(pattern: String): List<FindEverywhereResult> {
val actionManager = ActionManager.getInstance() val actionManager = ActionManager.getInstance()
return actions val results = ArrayList<FindEverywhereResult>()
.mapNotNull { actionManager.getAction(it) } for (action in actions) {
.map { ActionFindEverywhereResult(it) } val ac = actionManager.getAction(action)
if (ac == null) {
if (action == MultipleAction.MULTIPLE) {
results.add(ActionFindEverywhereResult(MultipleAction.getInstance(windowScope)))
}
} else {
results.add(ActionFindEverywhereResult(ac))
}
}
return results
} }

View File

@@ -5,6 +5,7 @@ import app.termora.I18n
import app.termora.Icons import app.termora.Icons
import app.termora.actions.NewHostAction import app.termora.actions.NewHostAction
import app.termora.actions.OpenLocalTerminalAction import app.termora.actions.OpenLocalTerminalAction
import app.termora.snippet.SnippetAction
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
import javax.swing.Icon import javax.swing.Icon
@@ -21,6 +22,11 @@ class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
list.add(ActionFindEverywhereResult(it)) list.add(ActionFindEverywhereResult(it))
} }
// Snippet
actionManager.getAction(SnippetAction.SNIPPET)?.let {
list.add(ActionFindEverywhereResult(it))
}
// SFTP // SFTP
actionManager.getAction(Actions.SFTP)?.let { actionManager.getAction(Actions.SFTP)?.let {
list.add(ActionFindEverywhereResult(it)) list.add(ActionFindEverywhereResult(it))

View File

@@ -1,6 +1,5 @@
package app.termora.highlight package app.termora.highlight
import app.termora.ApplicationScope
import app.termora.DialogWrapper import app.termora.DialogWrapper
import app.termora.TerminalFactory import app.termora.TerminalFactory
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
@@ -31,7 +30,7 @@ class ChooseColorTemplateDialog(owner: Window, title: String) : DialogWrapper(ow
override fun createCenterPanel(): JComponent { override fun createCenterPanel(): JComponent {
val panel = JPanel(GridLayout(2, 8, 4, 4)) val panel = JPanel(GridLayout(2, 8, 4, 4))
val colorPalette = TerminalFactory.getInstance(ApplicationScope.forWindowScope(this)) val colorPalette = TerminalFactory.getInstance()
.createTerminal().getTerminalModel().getColorPalette() .createTerminal().getTerminalModel().getColorPalette()
for (i in 1..16) { for (i in 1..16) {
val c = JPanel() val c = JPanel()
@@ -52,6 +51,7 @@ class ChooseColorTemplateDialog(owner: Window, title: String) : DialogWrapper(ow
val customBtn = JButton("Custom") val customBtn = JButton("Custom")
customBtn.addActionListener { customBtn.addActionListener {
val dialog = MyColorPickerDialog(this) val dialog = MyColorPickerDialog(this)
dialog.setLocationRelativeTo(this)
dialog.colorPicker.color = defaultColor dialog.colorPicker.color = defaultColor
dialog.isVisible = true dialog.isVisible = true
val color = dialog.color val color = dialog.color

View File

@@ -24,6 +24,11 @@ data class KeywordHighlight(
*/ */
val matchCase: Boolean = false, val matchCase: Boolean = false,
/**
* 是否是正则表达式
*/
val regex: Boolean = false,
/** /**
* 0 是取前景色 * 0 是取前景色
*/ */
@@ -62,5 +67,10 @@ data class KeywordHighlight(
/** /**
* 排序 * 排序
*/ */
val sort: Long = System.currentTimeMillis() val sort: Long = System.currentTimeMillis(),
/**
* 更新时间
*/
val updateDate: Long = System.currentTimeMillis(),
) )

View File

@@ -20,10 +20,8 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
private val model = KeywordHighlightTableModel() private val model = KeywordHighlightTableModel()
private val table = FlatTable() private val table = FlatTable()
private val keywordHighlightManager by lazy { KeywordHighlightManager.getInstance() } private val keywordHighlightManager by lazy { KeywordHighlightManager.getInstance() }
private val colorPalette by lazy { private val terminal by lazy { TerminalFactory.getInstance().createTerminal() }
TerminalFactory.getInstance(ApplicationScope.forWindowScope(this)).createTerminal().getTerminalModel() private val colorPalette by lazy { terminal.getTerminalModel().getColorPalette() }
.getColorPalette()
}
private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add")) private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add"))
private val editBtn = JButton(I18n.getString("termora.keymgr.edit")) private val editBtn = JButton(I18n.getString("termora.keymgr.edit"))
@@ -130,6 +128,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
addBtn.addActionListener { addBtn.addActionListener {
val dialog = NewKeywordHighlightDialog(this, colorPalette) val dialog = NewKeywordHighlightDialog(this, colorPalette)
dialog.setLocationRelativeTo(this)
dialog.isVisible = true dialog.isVisible = true
val keywordHighlight = dialog.keywordHighlight val keywordHighlight = dialog.keywordHighlight
if (keywordHighlight != null) { if (keywordHighlight != null) {
@@ -143,6 +142,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
if (row > -1) { if (row > -1) {
var keywordHighlight = model.getKeywordHighlight(row) var keywordHighlight = model.getKeywordHighlight(row)
val dialog = NewKeywordHighlightDialog(this, colorPalette) val dialog = NewKeywordHighlightDialog(this, colorPalette)
dialog.setLocationRelativeTo(this)
dialog.keywordTextField.text = keywordHighlight.keyword dialog.keywordTextField.text = keywordHighlight.keyword
dialog.descriptionTextField.text = keywordHighlight.description dialog.descriptionTextField.text = keywordHighlight.description
@@ -176,6 +176,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
dialog.underlineCheckBox.isSelected = keywordHighlight.underline dialog.underlineCheckBox.isSelected = keywordHighlight.underline
dialog.lineThroughCheckBox.isSelected = keywordHighlight.lineThrough dialog.lineThroughCheckBox.isSelected = keywordHighlight.lineThrough
dialog.matchCaseBtn.isSelected = keywordHighlight.matchCase dialog.matchCaseBtn.isSelected = keywordHighlight.matchCase
dialog.regexBtn.isSelected = keywordHighlight.regex
dialog.isVisible = true dialog.isVisible = true
@@ -201,7 +202,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
for (row in rows) { for (row in rows) {
val id = model.getKeywordHighlight(row).id val id = model.getKeywordHighlight(row).id
keywordHighlightManager.removeKeywordHighlight(id) keywordHighlightManager.removeKeywordHighlight(id)
model.removeRow(row) model.fireTableRowsDeleted(row, row)
} }
} }
} }
@@ -211,6 +212,12 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
editBtn.isEnabled = table.selectedRowCount > 0 editBtn.isEnabled = table.selectedRowCount > 0
deleteBtn.isEnabled = editBtn.isEnabled deleteBtn.isEnabled = editBtn.isEnabled
} }
Disposer.register(disposable, object : Disposable {
override fun dispose() {
terminal.close()
}
})
} }
override fun createCenterPanel(): JComponent { override fun createCenterPanel(): JComponent {

View File

@@ -1,8 +1,9 @@
package app.termora.highlight package app.termora.highlight
import app.termora.ApplicationScope import app.termora.ApplicationScope
import app.termora.TerminalPanelFactory
import app.termora.Database import app.termora.Database
import app.termora.DeleteDataManager
import app.termora.TerminalPanelFactory
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
class KeywordHighlightManager private constructor() { class KeywordHighlightManager private constructor() {
@@ -27,7 +28,7 @@ class KeywordHighlightManager private constructor() {
fun addKeywordHighlight(keywordHighlight: KeywordHighlight) { fun addKeywordHighlight(keywordHighlight: KeywordHighlight) {
database.addKeywordHighlight(keywordHighlight) database.addKeywordHighlight(keywordHighlight)
keywordHighlights[keywordHighlight.id] = keywordHighlight keywordHighlights[keywordHighlight.id] = keywordHighlight
ApplicationScope.windowScopes().forEach { TerminalPanelFactory.getInstance(it).repaintAll() } TerminalPanelFactory.getInstance().repaintAll()
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Keyword highlighter added. {}", keywordHighlight) log.debug("Keyword highlighter added. {}", keywordHighlight)
@@ -37,7 +38,8 @@ class KeywordHighlightManager private constructor() {
fun removeKeywordHighlight(id: String) { fun removeKeywordHighlight(id: String) {
database.removeKeywordHighlight(id) database.removeKeywordHighlight(id)
keywordHighlights.remove(id) keywordHighlights.remove(id)
ApplicationScope.windowScopes().forEach { TerminalPanelFactory.getInstance(it).repaintAll() } TerminalPanelFactory.getInstance().repaintAll()
DeleteDataManager.getInstance().removeKeywordHighlight(id)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Keyword highlighter removed. {}", id) log.debug("Keyword highlighter removed. {}", id)

View File

@@ -5,6 +5,7 @@ import app.termora.terminal.*
import app.termora.terminal.panel.TerminalDisplay import app.termora.terminal.panel.TerminalDisplay
import app.termora.terminal.panel.TerminalPaintListener import app.termora.terminal.panel.TerminalPaintListener
import app.termora.terminal.panel.TerminalPanel import app.termora.terminal.panel.TerminalPanel
import org.slf4j.LoggerFactory
import java.awt.Graphics import java.awt.Graphics
import kotlin.math.min import kotlin.math.min
import kotlin.random.Random import kotlin.random.Random
@@ -18,9 +19,10 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
} }
private val tag = Random.nextInt() private val tag = Random.nextInt()
private val log = LoggerFactory.getLogger(KeywordHighlightPaintListener::class.java)
} }
private val keywordHighlightManager by lazy { KeywordHighlightManager.getInstance() } private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
override fun before( override fun before(
offset: Int, offset: Int,
@@ -36,7 +38,8 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
} }
val document = terminal.getDocument() val document = terminal.getDocument()
val kinds = SubstrFinder(object : Iterator<TerminalLine> { val kinds = mutableListOf<FindKind>()
val iterator = object : Iterator<TerminalLine> {
private var index = offset + 1 private var index = offset + 1
private val maxCount = min(index + count, document.getLineCount()) private val maxCount = min(index + count, document.getLineCount())
override fun hasNext(): Boolean { override fun hasNext(): Boolean {
@@ -46,8 +49,24 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
override fun next(): TerminalLine { override fun next(): TerminalLine {
return document.getLine(index++) return document.getLine(index++)
} }
}
}, CharArraySubstr(highlight.keyword.toCharArray())).find(!highlight.matchCase) if (highlight.regex) {
try {
val regex = if (highlight.matchCase)
highlight.keyword.toRegex()
else highlight.keyword.toRegex(RegexOption.IGNORE_CASE)
RegexFinder(regex, iterator).find()
.apply { kinds.addAll(this) }
} catch (e: Exception) {
if (log.isDebugEnabled) {
log.error(e.message, e)
}
}
} else {
SubstrFinder(iterator, CharArraySubstr(highlight.keyword.toCharArray())).find(!highlight.matchCase)
.apply { kinds.addAll(this) }
}
for (kind in kinds) { for (kind in kinds) {
terminal.getMarkupModel().addHighlighter( terminal.getMarkupModel().addHighlighter(
@@ -77,6 +96,74 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
terminal.getMarkupModel().removeAllHighlighters(tag) terminal.getMarkupModel().removeAllHighlighters(tag)
} }
private class RegexFinder(
private val regex: Regex,
private val iterator: Iterator<TerminalLine>
) {
private data class Coords(val row: Int, val col: Int)
private data class MatchResultWithCoords(
val match: String,
val coords: List<Coords>
)
fun find(): List<FindKind> {
val lines = mutableListOf<TerminalLine>()
val kinds = mutableListOf<FindKind>()
for ((index, line) in iterator.withIndex()) {
lines.add(line)
if (line.wrapped) continue
val data = mutableListOf<MutableList<Char>>()
for (e in lines) {
data.add(mutableListOf())
for (c in e.chars()) {
if (c.first.isNull) break
data.last().add(c.first)
}
}
lines.clear()
val resultWithCoords = findMatchesWithCoords(data)
if (resultWithCoords.isEmpty()) continue
val offset = index - data.size + 1
for (e in resultWithCoords) {
val coords = e.coords
if (coords.isEmpty()) continue
kinds.add(
FindKind(
startPosition = Position(coords.first().row + offset + 1, coords.first().col + 1),
endPosition = Position(coords.last().row + offset + 1, coords.last().col + 1)
)
)
}
}
return kinds
}
private fun findMatchesWithCoords(data: List<List<Char>>): List<MatchResultWithCoords> {
val flatChars = StringBuilder()
val indexMap = mutableListOf<Coords>()
// 拉平成字符串,并记录每个字符的位置
for ((rowIndex, row) in data.withIndex()) {
for ((colIndex, char) in row.withIndex()) {
flatChars.append(char)
indexMap.add(Coords(rowIndex, colIndex))
}
}
return regex.findAll(flatChars.toString())
.map { MatchResultWithCoords(it.value, indexMap.subList(it.range.first, it.range.last + 1)) }
.toList()
}
}
private class KeywordHighlightHighlighter( private class KeywordHighlightHighlighter(
range: HighlighterRange, terminal: Terminal, range: HighlighterRange, terminal: Terminal,
@@ -93,4 +180,6 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
) )
} }
} }
} }

View File

@@ -1,10 +1,6 @@
package app.termora.highlight package app.termora.highlight
import app.termora.DialogWrapper import app.termora.*
import app.termora.DynamicColor
import app.termora.I18n
import app.termora.Icons
import app.termora.Database
import app.termora.terminal.ColorPalette import app.termora.terminal.ColorPalette
import app.termora.terminal.TerminalColor import app.termora.terminal.TerminalColor
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
@@ -46,6 +42,7 @@ class NewKeywordHighlightDialog(
I18n.getString("termora.highlight.background-color") I18n.getString("termora.highlight.background-color")
) )
val matchCaseBtn = JToggleButton(Icons.matchCase) val matchCaseBtn = JToggleButton(Icons.matchCase)
val regexBtn = JToggleButton(Icons.regex)
private val textColorRevert = JButton(Icons.revert) private val textColorRevert = JButton(Icons.revert)
@@ -85,6 +82,7 @@ class NewKeywordHighlightDialog(
val box = FlatToolBar() val box = FlatToolBar()
box.add(matchCaseBtn) box.add(matchCaseBtn)
box.add(regexBtn)
keywordTextField.trailingComponent = box keywordTextField.trailingComponent = box
repaintKeywordHighlightView() repaintKeywordHighlightView()
@@ -187,6 +185,7 @@ class NewKeywordHighlightDialog(
} }
private fun createColorPanel(color: Color, title: String): ColorPanel { private fun createColorPanel(color: Color, title: String): ColorPanel {
val owner = this
val arc = UIManager.getInt("Component.arc") val arc = UIManager.getInt("Component.arc")
val lineBorder = FlatLineBorder(Insets(1, 1, 1, 1), DynamicColor.BorderColor, 1f, arc) val lineBorder = FlatLineBorder(Insets(1, 1, 1, 1), DynamicColor.BorderColor, 1f, arc)
val colorPanel = ColorPanel(color) val colorPanel = ColorPanel(color)
@@ -195,7 +194,8 @@ class NewKeywordHighlightDialog(
colorPanel.addMouseListener(object : MouseAdapter() { colorPanel.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e)) { if (SwingUtilities.isLeftMouseButton(e)) {
val dialog = ChooseColorTemplateDialog(this@NewKeywordHighlightDialog, title) val dialog = ChooseColorTemplateDialog(owner, title)
dialog.setLocationRelativeTo(owner)
dialog.defaultColor = colorPanel.color dialog.defaultColor = colorPanel.color
dialog.isVisible = true dialog.isVisible = true
colorPanel.color = dialog.color ?: return colorPanel.color = dialog.color ?: return
@@ -218,6 +218,7 @@ class NewKeywordHighlightDialog(
keyword = keywordTextField.text, keyword = keywordTextField.text,
description = descriptionTextField.text, description = descriptionTextField.text,
matchCase = matchCaseBtn.isSelected, matchCase = matchCaseBtn.isSelected,
regex = regexBtn.isSelected,
textColor = if (textColor.colorIndex != -1) textColor.colorIndex else textColor.color.toRGB(), textColor = if (textColor.colorIndex != -1) textColor.colorIndex else textColor.color.toRGB(),
backgroundColor = if (backgroundColor.colorIndex != -1) backgroundColor.colorIndex else backgroundColor.color.toRGB(), backgroundColor = if (backgroundColor.colorIndex != -1) backgroundColor.colorIndex else backgroundColor.color.toRGB(),
bold = boldCheckBox.isSelected, bold = boldCheckBox.isSelected,

View File

@@ -1,5 +1,6 @@
package app.termora.keymap package app.termora.keymap
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import java.awt.event.KeyEvent import java.awt.event.KeyEvent
import javax.swing.KeyStroke import javax.swing.KeyStroke
@@ -23,7 +24,14 @@ class KeyShortcut(val keyStroke: KeyStroke) : Shortcut() {
text = text.replace("MINUS", "-") text = text.replace("MINUS", "-")
} }
return text.toCharArray().joinToString(" + ") text = text.toCharArray().joinToString(" + ")
if (SystemInfo.isWindows || SystemInfo.isLinux) {
text = text.replace("", "Shift")
text = text.replace("", "Ctrl")
text = text.replace("", "Alt")
}
return text
} }
} }

View File

@@ -1,7 +1,6 @@
package app.termora.keymap package app.termora.keymap
import app.termora.Application.ohMyJson import app.termora.Application.ohMyJson
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import javax.swing.KeyStroke import javax.swing.KeyStroke
@@ -12,6 +11,10 @@ open class Keymap(
*/ */
private val parent: Keymap?, private val parent: Keymap?,
val isReadonly: Boolean = false, val isReadonly: Boolean = false,
/**
* 修改时间
*/
var updateDate: Long = 0L,
) { ) {
companion object { companion object {
@@ -23,7 +26,8 @@ open class Keymap(
val shortcuts = mutableListOf<Keymap>() val shortcuts = mutableListOf<Keymap>()
val name = json["name"]?.jsonPrimitive?.content ?: return null val name = json["name"]?.jsonPrimitive?.content ?: return null
val readonly = json["readonly"]?.jsonPrimitive?.booleanOrNull ?: return null val readonly = json["readonly"]?.jsonPrimitive?.booleanOrNull ?: return null
val keymap = Keymap(name, null, readonly) val updateDate = json["updateDate"]?.jsonPrimitive?.longOrNull ?: 0
val keymap = Keymap(name, null, readonly, updateDate)
for (shortcut in (json["shortcuts"]?.jsonArray ?: emptyList()).map { it.jsonObject }) { for (shortcut in (json["shortcuts"]?.jsonArray ?: emptyList()).map { it.jsonObject }) {
val keyStroke = shortcut["keyStroke"]?.jsonPrimitive?.contentOrNull ?: continue val keyStroke = shortcut["keyStroke"]?.jsonPrimitive?.contentOrNull ?: continue
@@ -40,6 +44,9 @@ open class Keymap(
} }
} }
// 最后设置修改时间
keymap.updateDate = updateDate
shortcuts.add(keymap) shortcuts.add(keymap)
return keymap return keymap
} }
@@ -51,6 +58,7 @@ open class Keymap(
val actionIds = shortcuts.getOrPut(shortcut) { mutableListOf() } val actionIds = shortcuts.getOrPut(shortcut) { mutableListOf() }
actionIds.removeIf { it == actionId } actionIds.removeIf { it == actionId }
actionIds.add(actionId) actionIds.add(actionId)
updateDate = System.currentTimeMillis()
} }
open fun removeAllActionShortcuts(actionId: Any) { open fun removeAllActionShortcuts(actionId: Any) {
@@ -62,6 +70,7 @@ open class Keymap(
iterator.remove() iterator.remove()
} }
} }
updateDate = System.currentTimeMillis()
} }
open fun getShortcut(actionId: Any): List<Shortcut> { open fun getShortcut(actionId: Any): List<Shortcut> {
@@ -102,6 +111,7 @@ open class Keymap(
return buildJsonObject { return buildJsonObject {
put("name", name) put("name", name)
put("readonly", isReadonly) put("readonly", isReadonly)
put("updateDate", updateDate)
parent?.let { put("parent", it.name) } parent?.let { put("parent", it.name) }
put("shortcuts", buildJsonArray { put("shortcuts", buildJsonArray {
for (entry in shortcuts.entries) { for (entry in shortcuts.entries) {

View File

@@ -89,6 +89,7 @@ class KeymapManager private constructor() : Disposable {
fun removeKeymap(name: String) { fun removeKeymap(name: String) {
keymaps.remove(name) keymaps.remove(name)
database.removeKeymap(name) database.removeKeymap(name)
DeleteDataManager.getInstance().removeKeymap(name)
} }
private inner class KeymapKeyEventDispatcher : KeyEventDispatcher { private inner class KeymapKeyEventDispatcher : KeyEventDispatcher {

View File

@@ -119,10 +119,11 @@ class KeymapPanel : JPanel(BorderLayout()) {
val keymap = getCurrentKeymap() val keymap = getCurrentKeymap()
val index = keymapComboBox.selectedIndex val index = keymapComboBox.selectedIndex
if (keymap != null && !keymap.isReadonly && index >= 0) { if (keymap != null && !keymap.isReadonly && index >= 0) {
val text = InputDialog( val text = OptionPane.showInputDialog(
SwingUtilities.getWindowAncestor(this@KeymapPanel), SwingUtilities.getWindowAncestor(this@KeymapPanel),
title = renameBtn.toolTipText, text = keymap.name title = renameBtn.toolTipText,
).getText() value = keymap.name
)
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
if (text != keymap.name) { if (text != keymap.name) {
keymapManager.removeKeymap(keymap.name) keymapManager.removeKeymap(keymap.name)

View File

@@ -2,6 +2,7 @@ package app.termora.keymgr
import app.termora.ApplicationScope import app.termora.ApplicationScope
import app.termora.Database import app.termora.Database
import app.termora.DeleteDataManager
class KeyManager private constructor() { class KeyManager private constructor() {
companion object { companion object {
@@ -29,6 +30,7 @@ class KeyManager private constructor() {
fun removeOhKeyPair(id: String) { fun removeOhKeyPair(id: String) {
keyPairs.removeIf { it.id == id } keyPairs.removeIf { it.id == id }
database.removeKeyPair(id) database.removeKeyPair(id)
DeleteDataManager.getInstance().removeKeyPair(id)
} }
fun getOhKeyPairs(): List<OhKeyPair> { fun getOhKeyPairs(): List<OhKeyPair> {
@@ -39,9 +41,4 @@ class KeyManager private constructor() {
return keyPairs.findLast { it.id == id } return keyPairs.findLast { it.id == id }
} }
fun removeAll() {
keyPairs.clear()
database.removeAllKeyPair()
}
} }

View File

@@ -1,10 +1,8 @@
package app.termora.keymgr package app.termora.keymgr
import app.termora.* import app.termora.*
import app.termora.AES.decodeBase64
import app.termora.actions.AnAction import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.native.FileChooser import app.termora.native.FileChooser
import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatTable import com.formdev.flatlaf.extras.components.FlatTable
@@ -14,7 +12,6 @@ import com.formdev.flatlaf.ui.FlatTextBorder
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
import net.i2p.crypto.eddsa.EdDSAPublicKey
import org.apache.commons.codec.binary.Base64 import org.apache.commons.codec.binary.Base64
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.io.file.PathUtils import org.apache.commons.io.file.PathUtils
@@ -34,7 +31,6 @@ import java.io.File
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.nio.file.Files import java.nio.file.Files
import java.security.KeyPair import java.security.KeyPair
import java.security.spec.X509EncodedKeySpec
import java.util.* import java.util.*
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
@@ -104,7 +100,9 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
private fun initEvents() { private fun initEvents() {
generateBtn.addActionListener { generateBtn.addActionListener {
val dialog = GenerateKeyDialog(SwingUtilities.getWindowAncestor(this)) val owner = SwingUtilities.getWindowAncestor(this)
val dialog = GenerateKeyDialog(owner)
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true dialog.isVisible = true
if (dialog.ohKeyPair != OhKeyPair.empty) { if (dialog.ohKeyPair != OhKeyPair.empty) {
val keyPair = dialog.ohKeyPair val keyPair = dialog.ohKeyPair
@@ -143,12 +141,14 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
editBtn.addActionListener { editBtn.addActionListener {
val row = keyPairTable.selectedRow val row = keyPairTable.selectedRow
if (row >= 0) { if (row >= 0) {
val owner = SwingUtilities.getWindowAncestor(this)
var ohKeyPair = keyPairTableModel.getOhKeyPair(row) var ohKeyPair = keyPairTableModel.getOhKeyPair(row)
val dialog = GenerateKeyDialog( val dialog = GenerateKeyDialog(
SwingUtilities.getWindowAncestor(this), owner,
ohKeyPair, ohKeyPair,
true true
) )
dialog.setLocationRelativeTo(owner)
dialog.title = ohKeyPair.name dialog.title = ohKeyPair.name
dialog.isVisible = true dialog.isVisible = true
ohKeyPair = dialog.ohKeyPair ohKeyPair = dialog.ohKeyPair
@@ -196,7 +196,6 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
} }
private fun sshCopyId(evt: AnActionEvent) { private fun sshCopyId(evt: AnActionEvent) {
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
val keyPairs = keyPairTable.selectedRows.map { keyPairTableModel.getOhKeyPair(it) } val keyPairs = keyPairTable.selectedRows.map { keyPairTableModel.getOhKeyPair(it) }
val publicKeys = mutableListOf<Pair<String, String>>() val publicKeys = mutableListOf<Pair<String, String>>()
for (keyPair in keyPairs) { for (keyPair in keyPairs) {
@@ -220,7 +219,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
return return
} }
SSHCopyIdDialog(owner, windowScope, hosts, publicKeys).start() SSHCopyIdDialog(owner, hosts, publicKeys).start()
} }
private fun exportKeyPairs(file: File, keyPairs: List<OhKeyPair>) { private fun exportKeyPairs(file: File, keyPairs: List<OhKeyPair>) {
@@ -311,15 +310,9 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
nameTextField.text = ohKeyPair.name nameTextField.text = ohKeyPair.name
remarkTextField.text = ohKeyPair.remark remarkTextField.text = ohKeyPair.remark
val baos = ByteArrayOutputStream() val baos = ByteArrayOutputStream()
if (ohKeyPair.type == "RSA") { val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(ohKeyPair)
OpenSSHKeyPairResourceWriter.INSTANCE OpenSSHKeyPairResourceWriter.INSTANCE
.writePublicKey(RSA.generatePublic(ohKeyPair.publicKey.decodeBase64()), null, baos) .writePublicKey(keyPair.public, null, baos)
} else if (ohKeyPair.type == "ED25519") {
OpenSSHKeyPairResourceWriter.INSTANCE.writePublicKey(
EdDSAPublicKey(X509EncodedKeySpec(ohKeyPair.publicKey.decodeBase64())),
null, baos
)
}
publicKeyTextArea.text = baos.toString() publicKeyTextArea.text = baos.toString()
savePublicKeyBtn.isEnabled = true savePublicKeyBtn.isEnabled = true
} else { } else {
@@ -344,7 +337,6 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
pack() pack()
size = Dimension(UIManager.getInt("Dialog.width") - 300, size.height) size = Dimension(UIManager.getInt("Dialog.width") - 300, size.height)
setLocationRelativeTo(null)
} }
@@ -356,7 +348,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
var rows = 1 var rows = 1
val step = 2 val step = 2
return FormBuilder.create().layout(layout).padding("0dlu, $formMargin, $formMargin, $formMargin") return FormBuilder.create().layout(layout).padding("2dlu, $formMargin, $formMargin, $formMargin")
.add("${I18n.getString("termora.keymgr.table.type")}:").xy(1, rows) .add("${I18n.getString("termora.keymgr.table.type")}:").xy(1, rows)
.add(typeComboBox).xy(3, rows).apply { rows += step } .add(typeComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.keymgr.table.length")}:").xy(1, rows) .add("${I18n.getString("termora.keymgr.table.length")}:").xy(1, rows)
@@ -514,7 +506,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
var rows = 1 var rows = 1
val step = 2 val step = 2
return FormBuilder.create().layout(layout).padding("0dlu, $formMargin, $formMargin, $formMargin") return FormBuilder.create().layout(layout).padding("2dlu, $formMargin, $formMargin, $formMargin")
.add("File:").xy(1, rows) .add("File:").xy(1, rows)
.add(fileTextField).xy(3, rows).apply { rows += step } .add(fileTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.keymgr.table.type")}:").xy(1, rows) .add("${I18n.getString("termora.keymgr.table.type")}:").xy(1, rows)
@@ -589,8 +581,10 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
try { try {
val provider = FileKeyPairProvider(file.toPath()) val provider = FileKeyPairProvider(file.toPath())
provider.passwordFinder = FilePasswordProvider { _, _, _ -> provider.passwordFinder = FilePasswordProvider { _, _, _ ->
val dialog = InputDialog(owner = this@ImportKeyDialog, title = "Password") OptionPane.showInputDialog(
dialog.getText() ?: String() SwingUtilities.getWindowAncestor(this),
title = I18n.getString("termora.new-host.general.password"),
) ?: String()
} }
val keyPair = provider.loadKeys(null).firstOrNull() val keyPair = provider.loadKeys(null).firstOrNull()
?: throw IllegalStateException("Failed to load the key file") ?: throw IllegalStateException("Failed to load the key file")

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