Compare commits

...

121 Commits
1.0.11 ... main

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-28 10:12:10 +08:00
hstyi
757bc1c001 chore: improve dependencies 2025-03-28 10:10:48 +08:00
hstyi
a19222dc60 chore: add dependabot 2025-03-27 17:40:18 +08:00
hstyi
24677ca4a6 feat: Welcome search supports keyboard navigation (#404) 2025-03-27 17:33:24 +08:00
hstyi
0c5b6f8112 feat: support system tray (#403) 2025-03-27 17:22:13 +08:00
146 changed files with 4860 additions and 1450 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-b895.91.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-linux-aarch64-b1034.51.tar.gz
# appimagetool # appimagetool
- run: sudo apt install libfuse2 - run: sudo apt install libfuse2
@@ -22,7 +22,7 @@ jobs:
with: with:
distribution: 'jdkfile' distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.6' java-version: '21.0.7'
architecture: aarch64 architecture: aarch64
- uses: actions/cache@v4 - uses: actions/cache@v4

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-b895.91.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-linux-x64-b1034.51.tar.gz
# appimagetool # appimagetool
- run: sudo apt install libfuse2 - run: sudo apt install libfuse2
@@ -22,7 +22,7 @@ jobs:
with: with:
distribution: 'jdkfile' distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.6' java-version: '21.0.7'
architecture: x64 architecture: x64
- uses: actions/cache@v4 - uses: actions/cache@v4

View File

@@ -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-b895.91.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-osx-aarch64-b1034.51.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -52,7 +52,7 @@ jobs:
with: with:
distribution: 'jdkfile' distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.6' java-version: '21.0.7'
architecture: aarch64 architecture: aarch64
- uses: actions/cache@v4 - uses: actions/cache@v4

View File

@@ -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-b895.91.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-osx-x64-b1034.51.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -52,7 +52,7 @@ jobs:
with: with:
distribution: 'jdkfile' distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.6' java-version: '21.0.7'
architecture: x64 architecture: x64

View File

@@ -21,9 +21,9 @@ jobs:
- name: Installing Java - name: Installing Java
run: | run: |
curl -s --output ${{ runner.temp }}\java_package.zip -L https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-windows-x64-b895.91.zip curl -s --output ${{ runner.temp }}\java_package.zip -L https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.7-windows-x64-b1034.51.zip
unzip -q ${{ runner.temp }}\java_package.zip -d ${{ runner.temp }}\jbr unzip -q ${{ runner.temp }}\java_package.zip -d ${{ runner.temp }}\jbr
echo "JAVA_HOME=${{ runner.temp }}\jbr\jbrsdk-21.0.6-windows-x64-b895.91" >> $env:GITHUB_ENV echo "JAVA_HOME=${{ runner.temp }}\jbr\jbrsdk-21.0.7-windows-x64-b1034.51" >> $env:GITHUB_ENV
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:

View File

@@ -10,5 +10,5 @@ jobs:
if: github.repository == 'TermoraDev/termora' if: github.repository == 'TermoraDev/termora'
with: with:
identifier: TermoraDev.Termora identifier: TermoraDev.Termora
installers-regex: 'x86-64\.exe$' # Only x86-64.exe files installers-regex: '\.exe$'
token: ${{ secrets.WINGET_TOKEN }} token: ${{ secrets.WINGET_TOKEN }}

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

@@ -14,13 +14,14 @@ plugins {
java java
idea idea
application application
`maven-publish`
alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.kotlinx.serialization)
} }
group = "app.termora" group = "app.termora"
version = "1.0.11" version = "1.0.17"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture() val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
@@ -56,64 +57,67 @@ dependencies {
// implementation(platform(libs.koin.bom)) // implementation(platform(libs.koin.bom))
// implementation(libs.koin.core) // implementation(libs.koin.core)
implementation(libs.slf4j.api) api(libs.slf4j.api)
implementation(libs.pty4j) api(libs.pty4j)
implementation(libs.slf4j.tinylog) api(libs.slf4j.tinylog)
implementation(libs.tinylog.impl) api(libs.tinylog.impl)
implementation(libs.commons.codec) api(libs.commons.codec)
implementation(libs.commons.io) api(libs.commons.io)
implementation(libs.commons.lang3) api(libs.commons.lang3)
implementation(libs.commons.csv) api(libs.commons.csv)
implementation(libs.commons.net) api(libs.commons.net)
implementation(libs.commons.text) api(libs.commons.text)
implementation(libs.commons.compress) api(libs.commons.compress)
implementation(libs.kotlinx.coroutines.swing) api(libs.commons.vfs2) { exclude(group = "*", module = "*") }
implementation(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.swing)
api(libs.kotlinx.coroutines.core)
implementation(libs.flatlaf) { api(libs.flatlaf) {
artifact { artifact {
if (useNoNativesFlatLaf) { if (useNoNativesFlatLaf) {
classifier = "no-natives" classifier = "no-natives"
} }
} }
} }
implementation(libs.flatlaf.extras) { api(libs.flatlaf.extras) {
if (useNoNativesFlatLaf) { if (useNoNativesFlatLaf) {
exclude(group = "com.formdev", module = "flatlaf") exclude(group = "com.formdev", module = "flatlaf")
} }
} }
implementation(libs.flatlaf.swingx) { api(libs.flatlaf.swingx) {
if (useNoNativesFlatLaf) { if (useNoNativesFlatLaf) {
exclude(group = "com.formdev", module = "flatlaf") exclude(group = "com.formdev", module = "flatlaf")
} }
} }
implementation(libs.kotlinx.serialization.json) api(libs.kotlinx.serialization.json)
implementation(libs.swingx) api(libs.swingx)
implementation(libs.jgoodies.forms) api(libs.jgoodies.forms)
implementation(libs.jna) api(libs.jna)
implementation(libs.jna.platform) api(libs.jna.platform)
implementation(libs.versioncompare) api(libs.versioncompare)
implementation(libs.oshi.core) api(libs.oshi.core)
implementation(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") } api(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") }
implementation(libs.jfa) { exclude(group = "*", module = "*") } api(libs.jfa) { exclude(group = "*", module = "*") }
implementation(libs.jbr.api) api(libs.jbr.api)
implementation(libs.okhttp) api(libs.okhttp)
implementation(libs.okhttp.logging) api(libs.okhttp.logging)
implementation(libs.sshd.core) api(libs.sshd.core)
implementation(libs.commonmark) api(libs.commonmark)
implementation(libs.jgit) api(libs.jgit)
implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") } api(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.jnafilechooser) api(libs.jgit.agent) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.xodus.vfs) api(libs.eddsa)
implementation(libs.xodus.openAPI) api(libs.jnafilechooser)
implementation(libs.xodus.environment) api(libs.xodus.vfs)
implementation(libs.bip39) api(libs.xodus.openAPI)
implementation(libs.colorpicker) api(libs.xodus.environment)
implementation(libs.mixpanel) api(libs.bip39)
implementation(libs.jSerialComm) api(libs.colorpicker)
implementation(libs.ini4j) api(libs.mixpanel)
implementation(libs.restart4j) api(libs.jSerialComm)
api(libs.ini4j)
api(libs.restart4j)
} }
application { application {
@@ -130,20 +134,48 @@ application {
args.add("--add-opens java.desktop/java.awt=ALL-UNNAMED") 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=ALL-UNNAMED")
args.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED") args.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
args.add("--add-exports java.desktop/com.apple.eawt=ALL-UNNAMED")
args.add("-Dsun.java2d.metal=true") args.add("-Dsun.java2d.metal=true")
args.add("-Dapple.awt.application.appearance=system") args.add("-Dapple.awt.application.appearance=system")
} }
args.add("-Dapp-version=${project.version}") args.add("-Dapp-version=${project.version}")
if (os.isLinux) {
args.add("-Dsun.java2d.opengl=true")
}
applicationDefaultJvmArgs = args applicationDefaultJvmArgs = args
mainClass = "app.termora.MainKt" mainClass = "app.termora.MainKt"
} }
publishing {
publications {
create<MavenPublication>("mavenJava") {
from(components["java"])
pom {
name = project.name
description = "Termora is a terminal emulator and SSH client for Windows, macOS and Linux"
url = "https://github.com/TermoraDev/termora"
licenses {
license {
name = "AGPL-3.0"
url = "https://opensource.org/license/agpl-v3"
}
}
developers {
developer {
name = "hstyi"
url = "https://github.com/hstyi"
}
}
scm {
url = "https://github.com/TermoraDev/termora"
}
}
}
}
}
tasks.test { tasks.test {
useJUnitPlatform() useJUnitPlatform()
} }
@@ -353,10 +385,7 @@ tasks.register<Exec>("jpackage") {
options.add("--add-opens java.desktop/sun.lwawt.macosx=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")
} options.add("--add-exports java.desktop/com.apple.eawt=ALL-UNNAMED")
if (os.isLinux) {
options.add("-Dsun.java2d.opengl=true")
} }
val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage") val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage")
@@ -455,33 +484,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")
}
} }
} }
} }

View File

@@ -1,50 +1,46 @@
[versions] [versions]
kotlin = "2.1.10" kotlin = "2.1.21"
slf4j = "2.0.16" slf4j = "2.0.17"
pty4j = "0.13.2" pty4j = "0.13.6"
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.1"
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 "1.0.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

@@ -14,6 +14,7 @@ import static com.formdev.flatlaf.util.UIScale.scale;
/** /**
* 如果要升级 FlatLaf 需要检查是否兼容 * 如果要升级 FlatLaf 需要检查是否兼容
*/ */
@Deprecated
public class MyFlatTabbedPaneUI extends FlatTabbedPaneUI { public class MyFlatTabbedPaneUI extends FlatTabbedPaneUI {
@Override @Override
protected void paintContentBorder(Graphics g, int tabPlacement, int selectedIndex) { protected void paintContentBorder(Graphics g, int tabPlacement, int selectedIndex) {

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

View File

@@ -2,6 +2,7 @@ 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
@@ -12,16 +13,29 @@ 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 java.awt.MenuItem
import java.awt.PopupMenu
import java.awt.SystemTray
import java.awt.TrayIcon
import java.awt.desktop.AppReopenedEvent
import java.awt.desktop.AppReopenedListener
import java.awt.desktop.SystemEventListener
import java.awt.event.ActionEvent
import java.awt.event.WindowEvent
import java.util.* import java.util.*
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
@@ -44,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
@@ -79,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())
} }
@@ -101,15 +123,66 @@ class ApplicationRunner {
TermoraFrameManager.getInstance().createWindow().isVisible = true TermoraFrameManager.getInstance().createWindow().isVisible = true
if (SystemUtils.IS_OS_MAC_OSX) { if (SystemInfo.isMacOS) {
SwingUtilities.invokeLater { FlatDesktop.setQuitHandler { quitHandler() } } SwingUtilities.invokeLater {
try {
// 设置 Dock
setupMacOSDock()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
// Command + Q
FlatDesktop.setQuitHandler { quitHandler() }
}
} else if (SystemInfo.isWindows) {
// 设置托盘
SwingUtilities.invokeLater { setupSystemTray() }
} }
} }
private fun setupSystemTray() {
if (!SystemInfo.isWindows || !SystemTray.isSupported()) return
val tray = SystemTray.getSystemTray()
val image = ImageIO.read(TermoraFrame::class.java.getResourceAsStream("/icons/termora_16x16.png"))
val trayIcon = TrayIcon(image)
val popupMenu = PopupMenu()
trayIcon.popupMenu = popupMenu
trayIcon.toolTip = Application.getName()
// PopupMenu 不支持中文
val exitMenu = MenuItem("Exit")
exitMenu.addActionListener { SwingUtilities.invokeLater { quitHandler() } }
popupMenu.add(exitMenu)
// double click
trayIcon.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
TermoraFrameManager.getInstance().tick()
}
})
tray.add(trayIcon)
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
override fun dispose() {
tray.remove(trayIcon)
}
})
}
private fun quitHandler() { private fun quitHandler() {
for (frame in TermoraFrameManager.getInstance().getWindows()) { val windows = TermoraFrameManager.getInstance().getWindows()
frame.dispose()
for (frame in windows) {
frame.dispatchEvent(WindowEvent(frame, WindowEvent.WINDOW_CLOSED))
} }
Disposer.dispose(TermoraFrameManager.getInstance())
} }
private fun loadSettings() { private fun loadSettings() {
@@ -191,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() {
@@ -232,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)
@@ -255,7 +355,27 @@ class ApplicationRunner {
.event(getAnalyticsUserID(), "launch", properties) .event(getAnalyticsUserID(), "launch", properties)
val delivery = ClientDelivery() val delivery = ClientDelivery()
delivery.addMessage(message) delivery.addMessage(message)
MixpanelAPI().deliver(delivery, true) val endpoints = listOf(
"https://api-eu.mixpanel.com",
"https://api-in.mixpanel.com",
"https://api.mixpanel.com",
"http://api.mixpanel.com",
)
for (endpoint in endpoints) {
try {
MixpanelAPI(
"$endpoint/track",
"$endpoint/engage",
"$endpoint/groups"
).deliver(delivery, true)
break
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
continue
}
}
} catch (e: Exception) { } catch (e: Exception) {
if (log.isErrorEnabled) { if (log.isErrorEnabled) {
log.error(e.message, e) log.error(e.message, e)

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

@@ -1,10 +1,9 @@
package app.termora package app.termora
import app.termora.Application.ohMyJson import app.termora.Application.ohMyJson
import app.termora.actions.MultipleAction
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.serialization.encodeToString
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.Component import java.awt.Component
@@ -20,6 +19,7 @@ import kotlin.math.min
class CustomizeToolBarDialog( class CustomizeToolBarDialog(
owner: Window, owner: Window,
private val windowScope: WindowScope,
private val toolbar: TermoraToolBar private val toolbar: TermoraToolBar
) : DialogWrapper(owner) { ) : DialogWrapper(owner) {
@@ -147,9 +147,7 @@ class CustomizeToolBarDialog(
leftList.model.removeAllElements() leftList.model.removeAllElements()
rightList.model.removeAllElements() rightList.model.removeAllElements()
for (action in toolbar.getAllActions()) { for (action in toolbar.getAllActions()) {
actionManager.getAction(action.id)?.let { getActionHolder(action.id)?.let { rightList.model.addElement(it) }
rightList.model.addElement(ActionHolder(action.id, it))
}
} }
} }
@@ -259,14 +257,11 @@ class CustomizeToolBarDialog(
override fun windowOpened(e: WindowEvent) { override fun windowOpened(e: WindowEvent) {
removeWindowListener(this) removeWindowListener(this)
for (action in toolbar.getActions()) { for (action in toolbar.getActions()) {
if (action.visible) { if (action.visible) {
actionManager.getAction(action.id) getActionHolder(action.id)?.let { rightList.model.addElement(it) }
?.let { rightList.model.addElement(ActionHolder(action.id, it)) }
} else { } else {
actionManager.getAction(action.id) getActionHolder(action.id)?.let { leftList.model.addElement(it) }
?.let { leftList.model.addElement(ActionHolder(action.id, it)) }
} }
} }
@@ -274,6 +269,17 @@ class CustomizeToolBarDialog(
}) })
} }
private fun getActionHolder(actionId: String): ActionHolder? {
var action = actionManager.getAction(actionId)
if (action == null) {
if (actionId == MultipleAction.MULTIPLE) {
action = MultipleAction.getInstance(windowScope)
}
}
if (action == null) return null
return ActionHolder(actionId, action)
}
private fun resetMoveButtons() { private fun resetMoveButtons() {
val indices = rightList.selectedIndices val indices = rightList.selectedIndices
if (indices.isEmpty()) { if (indices.isEmpty()) {

View File

@@ -6,12 +6,12 @@ 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.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 org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
@@ -30,6 +30,7 @@ class Database private constructor(private val env: Environment) : Disposable {
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)
@@ -142,6 +143,37 @@ class Database private constructor(private val env: Environment) : Disposable {
} }
} }
fun removeHost(id: String) {
env.executeInTransaction {
delete(it, HOST_STORE, id)
if (log.isDebugEnabled) {
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) { fun addSnippet(snippet: Snippet) {
var text = ohMyJson.encodeToString(snippet) var text = ohMyJson.encodeToString(snippet)
if (doorman.isWorking()) { if (doorman.isWorking()) {
@@ -155,6 +187,14 @@ class Database private constructor(private val env: Environment) : Disposable {
} }
} }
fun removeSnippet(id: String) {
env.executeInTransaction {
delete(it, SNIPPET_STORE, id)
if (log.isDebugEnabled) {
log.debug("Removed snippet: $id")
}
}
}
fun getSnippets(): Collection<Snippet> { fun getSnippets(): Collection<Snippet> {
val isWorking = doorman.isWorking() val isWorking = doorman.isWorking()
@@ -249,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) {
@@ -308,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? {
@@ -381,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) {
@@ -465,6 +523,11 @@ class Database private constructor(private val env: Environment) : Disposable {
*/ */
var beep by BooleanPropertyDelegate(true) var beep by BooleanPropertyDelegate(true)
/**
* 超链接
*/
var hyperlink by BooleanPropertyDelegate(true)
/** /**
* 光标闪烁 * 光标闪烁
*/ */
@@ -580,6 +643,21 @@ 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 confirmTabClose by BooleanPropertyDelegate(false)
/**
* 背景图片的地址
*/
var backgroundImage by StringPropertyDelegate(StringUtils.EMPTY)
/** /**
* 语言 * 语言
*/ */
@@ -587,6 +665,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)
} }
/** /**
@@ -662,6 +745,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

@@ -222,7 +222,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
return return
} }
doCancelAction() SwingUtilities.invokeLater { doCancelAction() }
} }
}) })

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 }

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,
} }
@@ -137,6 +139,16 @@ data class Options(
* SFTP 默认目录 * SFTP 默认目录
*/ */
val sftpDefaultDirectory: String = StringUtils.EMPTY, 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()
@@ -214,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) {
@@ -23,6 +25,7 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
isModal = true isModal = true
title = I18n.getString("termora.new-host.title") title = I18n.getString("termora.new-host.title")
setLocationRelativeTo(null) setLocationRelativeTo(null)
pane.setSelectedIndex(0)
init() init()
} }
@@ -52,9 +55,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 +106,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,22 +6,27 @@ 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.RegExUtils
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.datatransfer.DataFlavor
import java.awt.event.* import java.awt.event.*
import java.nio.charset.Charset import java.nio.charset.Charset
import javax.swing.* 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()
@@ -52,18 +57,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) {
@@ -94,7 +104,9 @@ open class HostOptionsPane : OptionsPane() {
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 sftpDefaultDirectory = sftpOption.defaultDirectoryField.text,
enableX11Forwarding = tunnelingOption.x11ForwardingCheckBox.isSelected,
x11Forwarding = tunnelingOption.x11ServerTextField.text,
) )
return Host( return Host(
@@ -160,6 +172,17 @@ open class HostOptionsPane : OptionsPane() {
} }
} }
// tunnel
if (tunnelingOption.x11ForwardingCheckBox.isSelected) {
if (validateField(tunnelingOption.x11ServerTextField)) {
return false
}
val segments = tunnelingOption.x11ServerTextField.text.split(":")
if (segments.size != 2 || segments[1].toIntOrNull() == null) {
setOutlineError(tunnelingOption.x11ServerTextField)
return false
}
}
return true return true
} }
@@ -169,14 +192,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 表示有错误
*/ */
@@ -196,10 +223,28 @@ open class HostOptionsPane : OptionsPane() {
val nameTextField = OutlineTextField(128) val nameTextField = OutlineTextField(128)
val protocolTypeComboBox = FlatComboBox<Protocol>() val protocolTypeComboBox = FlatComboBox<Protocol>()
val usernameTextField = OutlineTextField(128) val usernameTextField = OutlineTextField(128)
val hostTextField = OutlineTextField(255) val hostTextField = object : OutlineTextField(255) {
override fun paste() {
if (!toolkit.systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
return
}
var text = toolkit.systemClipboard.getData(DataFlavor.stringFlavor)?.toString() ?: return
if (text.isBlank()) {
return
}
// 移除所有不可见字符
text = RegExUtils.replaceAll(text, "[\\p{C}\\s]", StringUtils.EMPTY)
// text
replaceSelection(text)
}
}
private val passwordPanel = JPanel(BorderLayout()) private val 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>()
@@ -215,6 +260,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<*>?,
@@ -290,10 +339,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
@@ -457,6 +518,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)
} }
@@ -722,6 +785,8 @@ open class HostOptionsPane : OptionsPane() {
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 {
@@ -796,13 +861,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))
@@ -1054,8 +1142,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
@@ -19,7 +20,7 @@ abstract class HostTerminalTab(
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

@@ -49,6 +49,7 @@ class HostTreeNode(host: Host) : SimpleTreeNode<Host>(host) {
return when (host.protocol) { return when (host.protocol) {
Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
Protocol.Serial -> if (selected && hasFocus) Icons.plugin.dark else Icons.plugin 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 else -> if (selected && hasFocus) Icons.terminal.dark else Icons.terminal
} }
} }

View File

@@ -10,6 +10,7 @@ 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") }
@@ -63,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") }
@@ -93,6 +95,7 @@ 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") }

View File

@@ -1,12 +1,22 @@
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().createPtyConnector( val ptyConnector = PtyConnectorFactory.getInstance().createPtyConnector(
@@ -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,11 +0,0 @@
package app.termora
import com.formdev.flatlaf.ui.FlatRootPaneUI
import com.formdev.flatlaf.ui.FlatTitlePane
class MyFlatRootPaneUI : FlatRootPaneUI() {
fun getTitlePane(): FlatTitlePane? {
return super.titlePane
}
}

View File

@@ -9,7 +9,6 @@ import java.awt.event.*
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.util.* import java.util.*
import javax.swing.* import javax.swing.*
import javax.swing.plaf.TabbedPaneUI
import kotlin.math.abs import kotlin.math.abs
class MyTabbedPane : FlatTabbedPane() { class MyTabbedPane : FlatTabbedPane() {
@@ -21,18 +20,12 @@ class MyTabbedPane : FlatTabbedPane() {
private val owner private val owner
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
.getData(DataProviders.TermoraFrame) as TermoraFrame .getData(DataProviders.TermoraFrame) as TermoraFrame
private val myUI = MyFlatTabbedPaneUI()
init { init {
isFocusable = false isFocusable = false
super.setUI(myUI)
initEvents() initEvents()
} }
override fun setUI(ui: TabbedPaneUI?) {
super.setUI(myUI)
}
override fun updateUI() { override fun updateUI() {
styleMap = mapOf( styleMap = mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"), "focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),

View File

@@ -28,6 +28,8 @@ 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.PopupMenuEvent
import javax.swing.event.PopupMenuListener
import javax.swing.filechooser.FileNameExtensionFilter import javax.swing.filechooser.FileNameExtensionFilter
import javax.swing.tree.TreePath import javax.swing.tree.TreePath
import javax.swing.tree.TreeSelectionModel import javax.swing.tree.TreeSelectionModel
@@ -35,6 +37,7 @@ 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
@Suppress("CascadeIf")
class NewHostTree : SimpleTree() { class NewHostTree : SimpleTree() {
companion object { companion object {
@@ -50,7 +53,7 @@ class NewHostTree : SimpleTree() {
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
override val model = NewHostTreeModel() override val model = NewHostTreeModel()
/** /**
@@ -95,7 +98,7 @@ class NewHostTree : SimpleTree() {
// 是否显示更多信息 // 是否显示更多信息
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
@@ -108,20 +111,20 @@ class NewHostTree : SimpleTree() {
"""<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 = node.getIcon(sel, expanded, hasFocus) icon = node.getIcon(sel, expanded, tree.hasFocus() || isPopupMenu)
return c return c
} }
}) })
@@ -135,6 +138,9 @@ class NewHostTree : SimpleTree() {
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) {
val path = tree.getClosestPathForLocation(e.x, e.y) ?: return
val bounds = tree.getRowBounds(tree.getRowForPath(path)) ?: return
if ((e.y >= bounds.y && e.y < (bounds.y + bounds.height)).not()) return
openHostAction?.actionPerformed(OpenHostActionEvent(e.source, lastNode.host, e)) openHostAction?.actionPerformed(OpenHostActionEvent(e.source, lastNode.host, e))
} }
} }
@@ -327,6 +333,21 @@ class NewHostTree : SimpleTree() {
openWithSFTP.isEnabled = fullNodes.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 {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
isPopupMenu = true
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
isPopupMenu = false
}
override fun popupMenuCanceled(e: PopupMenuEvent?) {
}
})
popupMenu.show(this, evt.x, evt.y) popupMenu.show(this, evt.x, evt.y)
} }
@@ -835,7 +856,8 @@ class NewHostTree : SimpleTree() {
val port = map["Port"]?.toIntOrNull() ?: 22 val port = map["Port"]?.toIntOrNull() ?: 22
val username = map["Username"] ?: StringUtils.EMPTY val username = map["Username"] ?: StringUtils.EMPTY
val protocol = map["Protocol"] ?: "SSH" val protocol = map["Protocol"] ?: "SSH"
if (!StringUtils.equalsIgnoreCase(protocol, "SSH")) continue // 仅支持 SSH、RDP 协议
if (StringUtils.equalsAnyIgnoreCase(protocol, "SSH", "RDP").not()) continue
if (StringUtils.isAllBlank(hostname, label)) continue if (StringUtils.isAllBlank(hostname, label)) continue
var p: HostTreeNode? = null var p: HostTreeNode? = null
@@ -870,7 +892,7 @@ class NewHostTree : SimpleTree() {
host = hostname, host = hostname,
port = port, port = port,
username = username, username = username,
protocol = Protocol.SSH, protocol = runCatching { Protocol.valueOf(protocol) }.getOrNull() ?: Protocol.SSH,
parentId = p?.host?.id ?: StringUtils.EMPTY, parentId = p?.host?.id ?: StringUtils.EMPTY,
) )
) )

View File

@@ -40,7 +40,7 @@ class NewHostTreeDialog(
init() init()
setLocationRelativeTo(null) setLocationRelativeTo(owner)
} }

View File

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

View File

@@ -5,7 +5,9 @@ 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 org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout import java.awt.BorderLayout
@@ -20,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,
@@ -29,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) {
@@ -47,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()
} }
} }
@@ -58,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
@@ -99,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

View File

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

View File

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

@@ -30,6 +30,7 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
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 defaultDirectory get() = Database.getDatabase().sftp.defaultDirectory
private val owner get() = SwingUtilities.getWindowAncestor(terminalPanel)
init { init {
terminalPanel.dropFiles = true terminalPanel.dropFiles = true
@@ -67,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 }
// 打开通道 // 打开通道

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,10 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
} }
} }
override fun beforeClose() {
// 保存窗口状态
terminalPanel.storeVisualWindows(host.id)
}
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

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

View File

@@ -18,10 +18,7 @@ import app.termora.native.FileChooser
import app.termora.sftp.SFTPTab import app.termora.sftp.SFTPTab
import app.termora.snippet.Snippet import app.termora.snippet.Snippet
import app.termora.snippet.SnippetManager import app.termora.snippet.SnippetManager
import app.termora.sync.SyncConfig import app.termora.sync.*
import app.termora.sync.SyncRange
import app.termora.sync.SyncType
import app.termora.sync.SyncerProvider
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
@@ -36,10 +33,18 @@ 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 com.sun.jna.Native
import com.sun.jna.platform.win32.Shell32
import com.sun.jna.platform.win32.ShlObj
import com.sun.jna.platform.win32.WinDef
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
@@ -58,12 +63,14 @@ 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() {
@@ -79,7 +86,6 @@ class SettingsOptionsPane : OptionsPane() {
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>()
@@ -129,9 +135,16 @@ 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 confirmTabCloseComBoBox = 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()
@@ -140,8 +153,39 @@ 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
confirmTabCloseComBoBox.selectedItem = appearance.confirmTabClose
themeComboBox.isEnabled = !followSystemCheckBox.isSelected themeComboBox.isEnabled = !followSystemCheckBox.isSelected
themeManager.themes.keys.forEach { themeComboBox.addItem(it) } themeManager.themes.keys.forEach { themeComboBox.addItem(it) }
@@ -178,6 +222,27 @@ 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
}
}
confirmTabCloseComBoBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
appearance.confirmTabClose = confirmTabCloseComBoBox.selectedItem as Boolean
}
}
followSystemCheckBox.addActionListener { followSystemCheckBox.addActionListener {
appearance.followSystem = followSystemCheckBox.isSelected appearance.followSystem = followSystemCheckBox.isSelected
themeComboBox.isEnabled = !followSystemCheckBox.isSelected themeComboBox.isEnabled = !followSystemCheckBox.isSelected
@@ -207,6 +272,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 {
@@ -276,7 +381,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, $formMargin, pref"
) )
val box = FlatToolBar() val box = FlatToolBar()
box.add(followSystemCheckBox) box.add(followSystemCheckBox)
@@ -285,7 +390,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 }
@@ -296,7 +401,28 @@ 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).apply { rows += step }
val confirmTabCloseBox = Box.createHorizontalBox()
confirmTabCloseBox.add(JLabel("${I18n.getString("termora.settings.appearance.confirm-tab-close")}:"))
confirmTabCloseBox.add(Box.createHorizontalStrut(8))
confirmTabCloseBox.add(confirmTabCloseComBoBox)
builder.add(confirmTabCloseBox).xyw(1, rows, 3).apply { rows += step }
return builder.build()
} }
@@ -315,6 +441,7 @@ class SettingsOptionsPane : OptionsPane() {
private val selectCopyComboBox = YesOrNoComboBox() private val selectCopyComboBox = YesOrNoComboBox()
private val autoCloseTabComboBox = YesOrNoComboBox() private val autoCloseTabComboBox = YesOrNoComboBox()
private val floatingToolbarComboBox = YesOrNoComboBox() private val floatingToolbarComboBox = YesOrNoComboBox()
private val hyperlinkComboBox = YesOrNoComboBox()
init { init {
initView() initView()
@@ -392,6 +519,13 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
hyperlinkComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.hyperlink = hyperlinkComboBox.selectedItem as Boolean
TerminalPanelFactory.getInstance().repaintAll()
}
}
cursorBlinkComboBox.addItemListener { e -> cursorBlinkComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) { if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.cursorBlink = cursorBlinkComboBox.selectedItem as Boolean terminalSetting.cursorBlink = cursorBlinkComboBox.selectedItem as Boolean
@@ -408,8 +542,8 @@ class SettingsOptionsPane : OptionsPane() {
} }
private fun fireFontChanged() { private fun fireFontChanged() {
TerminalPanelFactory.getInstance() TerminalPanelFactory.getInstance()
.fireResize() .fireResize()
} }
private fun initView() { private fun initView() {
@@ -470,20 +604,33 @@ class SettingsOptionsPane : OptionsPane() {
shellComboBox.selectedItem = terminalSetting.localShell shellComboBox.selectedItem = terminalSetting.localShell
val fonts = linkedSetOf<String>("JetBrains Mono", "Source Code Pro", "Monospaced") fontComboBox.addItem(terminalSetting.font)
FontUtils.getAllFonts().forEach { var fontsLoaded = false
if (!fonts.contains(it.family)) {
fonts.addLast(it.family)
}
}
for (font in fonts) { fontComboBox.addPopupMenuListener(object : PopupMenuListener {
fontComboBox.addItem(font) override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
} if (!fontsLoaded) {
val selectedItem = fontComboBox.selectedItem
fontComboBox.removeAllItems();
fontComboBox.addItem("JetBrains Mono")
fontComboBox.addItem("Source Code Pro")
fontComboBox.addItem("Monospaced")
FontUtils.getAvailableFontFamilyNames().forEach {
fontComboBox.addItem(it)
}
fontComboBox.selectedItem = selectedItem
fontsLoaded = true
}
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {}
override fun popupMenuCanceled(e: PopupMenuEvent) {}
})
fontComboBox.selectedItem = terminalSetting.font fontComboBox.selectedItem = terminalSetting.font
debugComboBox.selectedItem = terminalSetting.debug debugComboBox.selectedItem = terminalSetting.debug
beepComboBox.selectedItem = terminalSetting.beep beepComboBox.selectedItem = terminalSetting.beep
hyperlinkComboBox.selectedItem = terminalSetting.hyperlink
cursorBlinkComboBox.selectedItem = terminalSetting.cursorBlink cursorBlinkComboBox.selectedItem = terminalSetting.cursorBlink
cursorStyleComboBox.selectedItem = terminalSetting.cursor cursorStyleComboBox.selectedItem = terminalSetting.cursor
selectCopyComboBox.selectedItem = terminalSetting.selectCopy selectCopyComboBox.selectedItem = terminalSetting.selectCopy
@@ -506,7 +653,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent { private fun getCenterComponent(): JComponent {
val layout = FormLayout( val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow", "left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
) )
val beepBtn = JButton(Icons.run) val beepBtn = JButton(Icons.run)
@@ -529,6 +676,8 @@ class SettingsOptionsPane : OptionsPane() {
.add("${I18n.getString("termora.settings.terminal.beep")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.beep")}:").xy(1, rows)
.add(beepComboBox).xy(3, rows) .add(beepComboBox).xy(3, rows)
.add(beepBtn).xy(5, rows).apply { rows += step } .add(beepBtn).xy(5, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.hyperlink")}:").xy(1, rows)
.add(hyperlinkComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.select-copy")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.select-copy")}:").xy(1, rows)
.add(selectCopyComboBox).xy(3, rows).apply { rows += step } .add(selectCopyComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows)
@@ -553,11 +702,11 @@ 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"))
@@ -575,19 +724,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) {
@@ -606,6 +759,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)
@@ -626,6 +785,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()) {
@@ -672,17 +832,47 @@ class SettingsOptionsPane : OptionsPane() {
} }
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.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() {
@@ -1008,8 +1198,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()) {
@@ -1017,7 +1210,7 @@ class SettingsOptionsPane : OptionsPane() {
domainTextField.outline = "error" domainTextField.outline = "error"
domainTextField.requestFocusInWindow() domainTextField.requestFocusInWindow()
} }
return return false
} }
} }
@@ -1026,7 +1219,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) {
@@ -1034,39 +1227,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
@@ -1077,19 +1244,14 @@ class SettingsOptionsPane : OptionsPane() {
hostsCheckBox.isEnabled = false hostsCheckBox.isEnabled = false
snippetsCheckBox.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 {
@@ -1099,10 +1261,9 @@ 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 snippetsCheckBox.isEnabled = true
@@ -1113,11 +1274,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")
}
} }
// 如果失败,提示错误 // 如果失败,提示错误
@@ -1137,10 +1294,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
@@ -1149,14 +1304,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
} }
@@ -1166,6 +1317,9 @@ 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 snippetsCheckBox.isFocusable = false
keysCheckBox.isFocusable = false keysCheckBox.isFocusable = false
@@ -1180,6 +1334,12 @@ class SettingsOptionsPane : OptionsPane() {
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
@@ -1227,6 +1387,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")}: ${
@@ -1237,6 +1414,7 @@ class SettingsOptionsPane : OptionsPane() {
refreshButtons() refreshButtons()
} }
override fun getIcon(isSelected: Boolean): Icon { override fun getIcon(isSelected: Boolean): Icon {
@@ -1254,7 +1432,7 @@ 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()
@@ -1304,20 +1482,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 }
@@ -1334,6 +1518,7 @@ class SettingsOptionsPane : OptionsPane() {
private val sftpCommandField = OutlineTextField(255) private val sftpCommandField = OutlineTextField(255)
private val defaultDirectoryField = OutlineTextField(255) private val defaultDirectoryField = OutlineTextField(255)
private val browseDirectoryBtn = JButton(Icons.folder) private val browseDirectoryBtn = JButton(Icons.folder)
private val browseEditCommandBtn = JButton(Icons.folder)
private val pinTabComboBox = YesOrNoComboBox() private val pinTabComboBox = YesOrNoComboBox()
private val preserveModificationTimeComboBox = YesOrNoComboBox() private val preserveModificationTimeComboBox = YesOrNoComboBox()
private val sftp get() = database.sftp private val sftp get() = database.sftp
@@ -1405,6 +1590,41 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
}) })
browseEditCommandBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val chooser = FileChooser()
chooser.allowsMultiSelection = false
chooser.fileSelectionMode = JFileChooser.FILES_ONLY
if (SystemInfo.isMacOS) {
chooser.defaultDirectory = "/Applications"
} else {
if (SystemInfo.isWindows) {
val pszPath = CharArray(WinDef.MAX_PATH)
Shell32.INSTANCE.SHGetFolderPath(
null,
ShlObj.CSIDL_DESKTOPDIRECTORY, null, ShlObj.SHGFP_TYPE_CURRENT,
pszPath
)
chooser.defaultDirectory = Native.toString(pszPath)
} else {
chooser.defaultDirectory = SystemUtils.USER_HOME
}
}
chooser.showOpenDialog(owner).thenAccept { files ->
if (files.isNotEmpty()) {
val file = files.first()
if (SystemInfo.isMacOS) {
editCommandField.text = "open -a ${file.absolutePath} {0}"
} else {
editCommandField.text = "${file.absolutePath} {0}"
}
}
}
}
})
} }
@@ -1421,6 +1641,8 @@ class SettingsOptionsPane : OptionsPane() {
sftpCommandField.placeholderText = "sftp" sftpCommandField.placeholderText = "sftp"
} }
editCommandField.trailingComponent = browseEditCommandBtn
defaultDirectoryField.placeholderText = SystemUtils.USER_HOME defaultDirectoryField.placeholderText = SystemUtils.USER_HOME
defaultDirectoryField.trailingComponent = browseDirectoryBtn defaultDirectoryField.trailingComponent = browseDirectoryBtn

View File

@@ -44,7 +44,7 @@ open class SimpleTree : JXTree() {
): Component { ): Component {
val node = value as SimpleTreeNode<*> val node = value as SimpleTreeNode<*>
val c = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus) val c = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus)
icon = node.getIcon(sel, expanded, hasFocus) icon = node.getIcon(sel, expanded, tree.hasFocus())
return c return c
} }
}) })
@@ -197,6 +197,7 @@ open class SimpleTree : JXTree() {
} }
override fun importData(support: TransferSupport): Boolean { override fun importData(support: TransferSupport): Boolean {
if (!support.isDrop) return false
val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false
val node = dropLocation.path.lastPathComponent as? SimpleTreeNode<*> ?: return false val node = dropLocation.path.lastPathComponent as? SimpleTreeNode<*> ?: return false
val nodes = (support.transferable.getTransferData(MoveNodeTransferable.dataFlavor) as? List<*>) val nodes = (support.transferable.getTransferData(MoveNodeTransferable.dataFlavor) as? List<*>)
@@ -277,7 +278,7 @@ open class SimpleTree : JXTree() {
protected open fun onRenamed(node: SimpleTreeNode<*>, text: String) {} protected open fun onRenamed(node: SimpleTreeNode<*>, text: String) {}
protected open fun refreshNode(node: SimpleTreeNode<*>) { open fun refreshNode(node: SimpleTreeNode<*> = model.root) {
val state = TreeUtils.saveExpansionState(tree) val state = TreeUtils.saveExpansionState(tree)
val rows = selectionRows val rows = selectionRows

View File

@@ -3,13 +3,17 @@ package app.termora
import app.termora.keyboardinteractive.TerminalUserInteraction 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 kotlinx.coroutines.Dispatchers import app.termora.x11.X11ChannelFactory
import kotlinx.coroutines.swing.Swing import com.formdev.flatlaf.FlatLaf
import kotlinx.coroutines.withContext 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
@@ -19,44 +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.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>() 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) }
/** /**
@@ -79,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")
} }
@@ -119,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) {
@@ -140,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) {
@@ -186,18 +224,55 @@ 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 askUserInfo = ask(host, entry, owner) ?: throw e
if (askUserInfo.authentication.type == AuthenticationType.No) throw e
return doOpenSession(
host.copy(
authentication = askUserInfo.authentication,
username = askUserInfo.username
), client
)
} }
session.setAttribute(HOST_KEY, host) session.setAttribute(HOST_KEY, host)
@@ -241,27 +316,13 @@ object SshClients {
return sshdSocketAddress return sshdSocketAddress
} }
suspend fun openClient(host: Host, owner: Window): Pair<SshClient, Host> { fun openClient(host: Host, owner: Window): SshClient {
val client = openClient(host) val h = hostManager.getHost(host.id) ?: host
var myHost = host val client = openClient(h)
withContext(Dispatchers.Swing) { client.userInteraction = TerminalUserInteraction(owner)
client.userInteraction = TerminalUserInteraction(owner) client.serverKeyVerifier = DialogServerKeyVerifier(owner)
client.serverKeyVerifier = DialogServerKeyVerifier(owner) client.properties["owner"] = owner
// 弹出授权框 return client
if (host.authentication.type == AuthenticationType.No) {
val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication()
myHost = myHost.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(myHost)
}
}
}
return client to myHost
} }
/** /**
@@ -270,11 +331,12 @@ object SshClients {
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),
@@ -284,6 +346,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 {
@@ -292,106 +372,350 @@ 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 class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor { private data class AskUserInfo(val username: String, val authentication: Authentication)
override fun verifyServerKey(
clientSession: ClientSession,
remoteAddress: SocketAddress,
serverKey: PublicKey
): Boolean {
return true
}
override fun acceptModifiedServerKey( private fun ask(host: Host, entry: HostConfigEntry, owner: Window): AskUserInfo? {
clientSession: ClientSession?, val ref = AtomicReference<AskUserInfo>(null)
remoteAddress: SocketAddress?,
entry: KnownHostEntry?,
expected: PublicKey?,
actual: PublicKey?
): Boolean {
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait { SwingUtilities.invokeAndWait {
result.set( val dialog = RequestAuthenticationDialog(owner, host)
OptionPane.showConfirmDialog( dialog.setLocationRelativeTo(owner)
parentComponent = owner, val authentication = dialog.getAuthentication()
message = I18n.getString( ref.set(AskUserInfo(dialog.getUsername(), authentication))
"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() // save
} if (dialog.isRemembered()) {
} // fix https://github.com/TermoraDev/termora/issues/609
val hostId = entry.getProperty("Host", host.id)
val h = hostManager.getHost(hostId)
if (h != null) {
hostManager.addHost(
h.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
class DialogServerKeyVerifier(
owner: Window,
) : KnownHostsServerKeyVerifier(
MyDialogServerKeyVerifier(owner),
Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts")
) {
init {
modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor
}
override fun updateKnownHostsFile(
clientSession: ClientSession?,
remoteAddress: SocketAddress?,
serverKey: PublicKey?,
file: Path?,
knownHosts: Collection<HostEntryPair?>?
): KnownHostEntry? {
if (clientSession is JGitClientSession) {
if (SshClients.isMiddleware(clientSession)) {
return null
} }
} }
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts) return ref.get()
} }
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
override fun verifyServerKey(
clientSession: ClientSession,
remoteAddress: SocketAddress,
serverKey: PublicKey
): Boolean {
return true
}
override fun acceptModifiedServerKey(
clientSession: ClientSession?,
remoteAddress: SocketAddress?,
entry: KnownHostEntry?,
expected: PublicKey?,
actual: PublicKey?
): Boolean {
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait { result.set(ask(remoteAddress, expected, actual) == JOptionPane.OK_OPTION) }
return result.get()
}
private fun ask(
remoteAddress: SocketAddress?,
expected: PublicKey?,
actual: PublicKey?
): Int {
val formMargin = "7dlu"
val layout = FormLayout(
"default:grow",
"pref, 12dlu, pref, 4dlu, pref, 2dlu, pref, $formMargin, pref, $formMargin, pref, pref, 12dlu, pref"
)
val errorColor = if (FlatLaf.isLafDark()) UIManager.getColor("Component.warning.focusedBorderColor") else
UIManager.getColor("Component.error.focusedBorderColor")
val font = FontUtils.getCompositeFont("JetBrains Mono", Font.PLAIN, 12)
val artBox = Box.createHorizontalBox()
artBox.add(Box.createHorizontalGlue())
val expectedBox = Box.createVerticalBox()
for (line in KeyRandomArt(expected).toString().lines()) {
val label = JLabel(line)
label.font = font
expectedBox.add(label)
}
artBox.add(expectedBox)
artBox.add(Box.createHorizontalGlue())
val actualBox = Box.createVerticalBox()
for (line in KeyRandomArt(actual).toString().lines()) {
val label = JLabel(line)
label.foreground = errorColor
label.font = font
actualBox.add(label)
}
artBox.add(actualBox)
artBox.add(Box.createHorizontalGlue())
var rows = 1
val step = 2
// @formatter:off
val address = remoteAddress.toString().replace("/", StringUtils.EMPTY)
val panel = FormBuilder.create().layout(layout)
.add("<html><b>${I18n.getString("termora.host.modified-server-key.title", address)}</b></html>").xy(1, rows).apply { rows += step }
.add("${I18n.getString("termora.host.modified-server-key.thumbprint")}:").xy(1, rows).apply { rows += step }
.add(" ${I18n.getString("termora.host.modified-server-key.expected")}: ${KeyUtils.getFingerPrint(expected)}").xy(1, rows).apply { rows += step }
.add("<html>&nbsp;&nbsp;${I18n.getString("termora.host.modified-server-key.actual")}: <font color=rgb(${errorColor.red},${errorColor.green},${errorColor.blue})>${KeyUtils.getFingerPrint(actual)}</font></html>").xy(1, rows).apply { rows += step }
.addSeparator(StringUtils.EMPTY).xy(1, rows).apply { rows += step }
.add(artBox).xy(1, rows).apply { rows += step }
.addSeparator(StringUtils.EMPTY).xy(1, rows).apply { rows += 1 }
.add(I18n.getString("termora.host.modified-server-key.are-you-sure")).xy(1, rows).apply { rows += step }
.build()
// @formatter:on
return OptionPane.showConfirmDialog(
owner,
panel,
"SSH Security Warning",
messageType = JOptionPane.WARNING_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
)
}
}
private class DialogServerKeyVerifier(
owner: Window,
) : KnownHostsServerKeyVerifier(
MyDialogServerKeyVerifier(owner),
Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts")
) {
init {
modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor
}
override fun updateKnownHostsFile(
clientSession: ClientSession?,
remoteAddress: SocketAddress?,
serverKey: PublicKey?,
file: Path?,
knownHosts: Collection<HostEntryPair?>?
): KnownHostEntry? {
if (clientSession is JGitClientSession) {
if (isMiddleware(clientSession)) {
return null
}
}
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts)
}
}
@Suppress("UNCHECKED_CAST")
private class MyJGitSshClient : JGitSshClient() {
companion object {
private val HOST_CONFIG_ENTRY: AttributeRepository.AttributeKey<HostConfigEntry> by lazy {
JGitSshClient::class.java.getDeclaredField("HOST_CONFIG_ENTRY").apply { isAccessible = true }
.get(null) as AttributeRepository.AttributeKey<HostConfigEntry>
}
private const val CLIENT_PROXY_CONNECTOR = "ClientProxyConnectorId"
}
private val sshClient = this
private val clientProxyConnectors = ConcurrentHashMap<String, ClientProxyConnector>()
override fun createConnector(): IoConnector {
return MyIoConnector(this, super.createConnector())
}
override fun createSessionFactory(): SessionFactory {
return object : SessionFactory(sshClient) {
override fun doCreateSession(ioSession: IoSession): ClientSessionImpl {
return object : JGitClientSession(sshClient, ioSession) {
override fun getClientProxyConnector(): ClientProxyConnector? {
val entry = getAttribute(HOST_CONFIG_ENTRY) ?: return null
val clientProxyConnectorId = entry.getProperty(CLIENT_PROXY_CONNECTOR) ?: return null
val clientProxyConnector = sshClient.clientProxyConnectors[clientProxyConnectorId]
if (clientProxyConnector != null) {
addSessionListener(object : SessionListener {
override fun sessionClosed(session: Session) {
clientProxyConnectors.remove(clientProxyConnectorId)
}
})
}
return clientProxyConnector
}
override fun createShellChannel(
ptyConfig: PtyChannelConfigurationHolder?,
env: MutableMap<String, *>?
): ChannelShell {
if (inCipher is CipherNone || outCipher is CipherNone)
throw IllegalStateException("Interactive channels are not supported with none cipher")
val channel = app.termora.x11.ChannelShell(ptyConfig, env)
val id = connectionService.registerChannel(channel)
if (log.isDebugEnabled) {
log.debug("createShellChannel({}) created id={} - PTY={}", this, id, ptyConfig)
}
return channel
}
}
}
}
}
override fun setClientProxyConnector(proxyConnector: ClientProxyConnector?) {
throw UnsupportedOperationException()
}
private class MyIoConnector(private val sshClient: MyJGitSshClient, private val ioConnector: IoConnector) :
IoConnector {
override fun close(immediately: Boolean): CloseFuture {
return ioConnector.close(immediately)
}
override fun addCloseFutureListener(listener: SshFutureListener<CloseFuture>?) {
return ioConnector.addCloseFutureListener(listener)
}
override fun removeCloseFutureListener(listener: SshFutureListener<CloseFuture>?) {
return ioConnector.removeCloseFutureListener(listener)
}
override fun isClosed(): Boolean {
return ioConnector.isClosed
}
override fun isClosing(): Boolean {
return ioConnector.isClosing
}
override fun getIoServiceEventListener(): IoServiceEventListener {
return ioConnector.ioServiceEventListener
}
override fun setIoServiceEventListener(listener: IoServiceEventListener?) {
return ioConnector.setIoServiceEventListener(listener)
}
override fun getManagedSessions(): MutableMap<Long, IoSession> {
return ioConnector.managedSessions
}
override fun connect(
targetAddress: SocketAddress,
context: AttributeRepository?,
localAddress: SocketAddress?
): IoConnectFuture {
var tAddress = targetAddress
val entry = context?.getAttribute(HOST_CONFIG_ENTRY)
if (entry != null) {
val host = hostManager.getHost(entry.getProperty("Host") ?: StringUtils.EMPTY)
if (host != null) {
tAddress = configureProxy(entry, host, tAddress)
}
}
return ioConnector.connect(tAddress, context, localAddress)
}
private fun configureProxy(
entry: HostConfigEntry,
host: Host,
targetAddress: SocketAddress
): SocketAddress {
if (host.proxy.type == ProxyType.No) return targetAddress
val address = targetAddress as? InetSocketAddress ?: return targetAddress
if (address.hostString == (SshdSocketAddress.LOCALHOST_IPV4)) return targetAddress
// 获取代理连接器
val clientProxyConnector = getClientProxyConnector(host, address) ?: return targetAddress
val id = UUID.randomUUID().toSimpleString()
entry.setProperty(CLIENT_PROXY_CONNECTOR, id)
sshClient.clientProxyConnectors[id] = clientProxyConnector
return InetSocketAddress(host.proxy.host, host.proxy.port)
}
private fun getClientProxyConnector(
host: Host,
remoteAddress: InetSocketAddress
): AbstractClientProxyConnector? {
if (host.proxy.type == ProxyType.HTTP) {
return HttpClientConnector(
InetSocketAddress(host.proxy.host, host.proxy.port),
remoteAddress,
host.proxy.username.ifBlank { null },
if (host.proxy.password.isBlank()) null else host.proxy.password.toCharArray()
)
} else if (host.proxy.type == ProxyType.SOCKS5) {
return Socks5ClientConnector(
InetSocketAddress(host.proxy.host, host.proxy.port),
remoteAddress,
host.proxy.username.ifBlank { null },
if (host.proxy.password.isBlank()) null else host.proxy.password.toCharArray()
)
}
return null
}
}
}
} }

View File

@@ -4,6 +4,7 @@ import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
import app.termora.actions.MultipleAction 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
@@ -40,6 +41,10 @@ class TerminalPanelFactory : Disposable {
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel { fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
val writer = MyTerminalWriter(ptyConnector) val writer = MyTerminalWriter(ptyConnector)
val terminalPanel = TerminalPanel(terminal, writer) val terminalPanel = TerminalPanel(terminal, writer)
// processDeviceStatusReport
terminal.getTerminalModel().setData(DataKey.TerminalWriter, writer)
terminalPanel.addTerminalPaintListener(MultipleTerminalListener()) terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance()) terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance()) terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
@@ -89,7 +94,7 @@ class TerminalPanelFactory : Disposable {
} }
} }
private val coroutineScope = CoroutineScope(Dispatchers.IO) private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init { init {
coroutineScope.launch { coroutineScope.launch {

View File

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

View File

@@ -32,6 +32,7 @@ class TerminalTabbed(
private val actionManager = ActionManager.getInstance() private val actionManager = ActionManager.getInstance()
private val dataProviderSupport = DataProviderSupport() private val dataProviderSupport = DataProviderSupport()
private val titleProperty = UUID.randomUUID().toSimpleString() private val titleProperty = UUID.randomUUID().toSimpleString()
private val appearance get() = Database.getDatabase().appearance
private val iconListener = PropertyChangeListener { e -> private val iconListener = PropertyChangeListener { e ->
val source = e.source val source = e.source
if (e.propertyName == "icon" && source is TerminalTab) { if (e.propertyName == "icon" && source is TerminalTab) {
@@ -153,8 +154,29 @@ class TerminalTabbed(
if (tabbedPane.isTabClosable(index)) { if (tabbedPane.isTabClosable(index)) {
val tab = tabs[index] val tab = tabs[index]
// 询问是否可以关闭
if (disposable) { if (disposable) {
if (!tab.willBeClose()) { // 如果开启了关闭确认,那么直接询问用户
if (appearance.confirmTabClose) {
if (OptionPane.showConfirmDialog(
windowScope.window,
I18n.getString("termora.tabbed.tab.close-prompt"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) != JOptionPane.OK_OPTION
) {
return
}
} else if (!tab.willBeClose()) { // 如果没有开启则询问用户
return
}
}
// 通知即将关闭
if (disposable) {
try {
tab.beforeClose()
} catch (_: Exception) {
return return
} }
} }
@@ -400,12 +422,11 @@ class TerminalTabbed(
private fun showContextMenu(event: MouseEvent) { private fun showContextMenu(event: MouseEvent) {
val popupMenu = FlatPopupMenu() val popupMenu = FlatPopupMenu()
popupMenu.add(I18n.getString("termora.toolbar.customize-toolbar")).addActionListener { popupMenu.add(I18n.getString("termora.toolbar.customize-toolbar")).addActionListener {
val dialog = CustomizeToolBarDialog( val owner = SwingUtilities.getWindowAncestor(this@TerminalTabbed)
SwingUtilities.getWindowAncestor(this@TerminalTabbed), val dialog = CustomizeToolBarDialog(owner, windowScope, termoraToolBar)
termoraToolBar dialog.setLocationRelativeTo(owner)
)
if (dialog.open()) { if (dialog.open()) {
termoraToolBar.rebuild() TermoraToolBar.rebuild()
} }
} }
popupMenu.show(event.component, event.x, event.y) popupMenu.show(event.component, event.x, event.y)

View File

@@ -7,11 +7,13 @@ import app.termora.actions.DataProviders
import app.termora.sftp.SFTPTab 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.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.BorderLayout import org.apache.commons.lang3.ArrayUtils
import java.awt.Dimension import java.awt.*
import java.awt.Insets
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.MouseListener
@@ -24,6 +26,7 @@ import javax.swing.SwingUtilities
import javax.swing.SwingUtilities.isEventDispatchThread import javax.swing.SwingUtilities.isEventDispatchThread
import javax.swing.UIManager import javax.swing.UIManager
fun assertEventDispatchThread() { fun assertEventDispatchThread() {
if (!isEventDispatchThread()) throw WrongThreadException("AWT EventQueue") if (!isEventDispatchThread()) throw WrongThreadException("AWT EventQueue")
} }
@@ -35,12 +38,12 @@ 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 tabbedPane = MyTabbedPane() private val tabbedPane = MyTabbedPane()
private val toolbar = TermoraToolBar(windowScope, this, tabbedPane) private val toolbar = TermoraToolBar(windowScope, this)
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane) private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
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 val myUI = MyFlatRootPaneUI() private var notifyListeners = emptyArray<NotifyListener>()
init { init {
@@ -60,10 +63,9 @@ class TermoraFrame : JFrame(), DataProvider {
} }
override fun mouseDragged(e: MouseEvent) { override fun mouseDragged(e: MouseEvent) {
val mouseLayer = getMouseLayer() ?: return
getMouseMotionListener()?.mouseDragged( getMouseMotionListener()?.mouseDragged(
MouseEvent( MouseEvent(
mouseLayer, e.component,
e.id, e.id,
e.`when`, e.`when`,
e.modifiersEx, e.modifiersEx,
@@ -84,19 +86,19 @@ class TermoraFrame : JFrame(), DataProvider {
return getHandler() as? MouseMotionListener return getHandler() as? MouseMotionListener
} }
private fun getMouseLayer(): JComponent? {
val titlePane = myUI.getTitlePane() ?: return null
val handlerField = titlePane.javaClass.getDeclaredField("mouseLayer") ?: return null
handlerField.isAccessible = true
return handlerField.get(titlePane) as? JComponent
}
private fun getHandler(): Any? { private fun getHandler(): Any? {
val titlePane = myUI.getTitlePane() ?: return null val titlePane = getTitlePane() ?: return null
val handlerField = titlePane.javaClass.getDeclaredField("handler") ?: return null val handlerField = titlePane.javaClass.getDeclaredField("handler") ?: return null
handlerField.isAccessible = true handlerField.isAccessible = true
return handlerField.get(titlePane) 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().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter) toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
@@ -170,7 +172,6 @@ class TermoraFrame : JFrame(), DataProvider {
// Windows 10 会有1像素误差 // Windows 10 会有1像素误差
tabbedPane.tabAreaInsets = Insets(if (SystemInfo.isWindows_11_orLater) 1 else 2, 2, 0, 0) tabbedPane.tabAreaInsets = Insets(if (SystemInfo.isWindows_11_orLater) 1 else 2, 2, 0, 0)
} else if (SystemInfo.isLinux) { } else if (SystemInfo.isLinux) {
rootPane.setUI(myUI)
tabbedPane.tabAreaInsets = Insets(1, 2, 0, 0) tabbedPane.tabAreaInsets = Insets(1, 2, 0, 0)
} }
@@ -210,6 +211,11 @@ class TermoraFrame : JFrame(), DataProvider {
} }
} }
val glassPane = GlassPane()
rootPane.glassPane = glassPane
glassPane.isOpaque = false
glassPane.isVisible = true
Disposer.register(windowScope, terminalTabbed) Disposer.register(windowScope, terminalTabbed)
add(terminalTabbed, BorderLayout.CENTER) add(terminalTabbed, BorderLayout.CENTER)
@@ -239,4 +245,39 @@ 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() {
init {
isFocusable = false
}
override fun paintComponent(g: Graphics) {
val img = BackgroundManager.getInstance().getBackgroundImage() ?: return
val g2d = g as Graphics2D
g2d.composite = AlphaComposite.getInstance(
AlphaComposite.SRC_OVER,
if (FlatLaf.isLafDark()) 0.2f else 0.1f
)
g2d.drawImage(img, 0, 0, width, height, null)
g2d.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER)
}
override fun contains(x: Int, y: Int): Boolean {
return false
}
}
} }

View File

@@ -1,10 +1,21 @@
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.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.JFrame
import javax.swing.JOptionPane import javax.swing.JOptionPane
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
@@ -13,7 +24,8 @@ import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
import kotlin.math.max 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)
@@ -26,10 +38,12 @@ class TermoraFrameManager {
private val frames = mutableListOf<TermoraFrame>() private val frames = mutableListOf<TermoraFrame>()
private val properties get() = Database.getDatabase().properties 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().apply { registerCloseCallback(this) } val frame = TermoraFrame().apply { registerCloseCallback(this) }
frame.title = if (SystemInfo.isLinux) null else Application.getName() frame.title = Application.getName()
frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
val rectangle = getFrameRectangle() ?: FrameRectangle(-1, -1, 1280, 800, 0) val rectangle = getFrameRectangle() ?: FrameRectangle(-1, -1, 1280, 800, 0)
@@ -50,6 +64,15 @@ class TermoraFrameManager {
} }
} }
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) } return frame.apply { frames.add(this) }
} }
@@ -59,6 +82,7 @@ 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) {
@@ -74,24 +98,49 @@ 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()
} }
} }
@@ -106,6 +155,7 @@ class TermoraFrameManager {
if (window.extendedState and JFrame.ICONIFIED == JFrame.ICONIFIED) { if (window.extendedState and JFrame.ICONIFIED == JFrame.ICONIFIED) {
window.extendedState = window.extendedState and JFrame.ICONIFIED.inv() window.extendedState = window.extendedState and JFrame.ICONIFIED.inv()
} }
window.isVisible = true
} }
windows.last().toFront() windows.last().toFront()
} else { } else {
@@ -113,14 +163,16 @@ class TermoraFrameManager {
} }
} }
private fun dispose() { override fun dispose() {
Disposer.dispose(ApplicationScope.forApplicationScope()) if (isDisposed.compareAndSet(false, true)) {
Disposer.dispose(ApplicationScope.forApplicationScope())
try { try {
Disposer.getTree().assertIsEmpty(true) Disposer.getTree().assertIsEmpty(true)
} catch (e: Exception) { } catch (e: Exception) {
if (log.isErrorEnabled) { if (log.isErrorEnabled) {
log.error(e.message, e) log.error(e.message, e)
}
} }
} }
@@ -144,6 +196,31 @@ class TermoraFrameManager {
return FrameRectangle(x, y, w, h, s) 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( private data class FrameRectangle(
val x: Int, val y: Int, val w: Int, val h: Int, val s: Int val x: Int, val y: Int, val w: Int, val h: Int, val s: Int
) { ) {

View File

@@ -5,7 +5,6 @@ import app.termora.actions.*
import app.termora.findeverywhere.FindEverywhereAction import app.termora.findeverywhere.FindEverywhereAction
import app.termora.snippet.SnippetAction import app.termora.snippet.SnippetAction
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
@@ -26,10 +25,21 @@ data class ToolBarAction(
class TermoraToolBar( class TermoraToolBar(
private val windowScope: WindowScope, private val windowScope: WindowScope,
private val frame: TermoraFrame, private val frame: TermoraFrame,
private val tabbedPane: FlatTabbedPane
) { ) {
companion object {
fun rebuild() {
for (frame in TermoraFrameManager.getInstance().getWindows()) {
val toolbars = SwingUtils.getDescendantsOfClass(MyToolBar::class.java, frame)
for (toolbar in toolbars) {
toolbar.rebuild()
}
}
}
}
private val properties by lazy { Database.getDatabase().properties } private val properties by lazy { Database.getDatabase().properties }
private val toolbar by lazy { MyToolBar().apply { rebuild(this) } } private val toolbar by lazy { MyToolBar().apply { rebuild() } }
fun getJToolBar(): JToolBar { fun getJToolBar(): JToolBar {
@@ -87,63 +97,6 @@ class TermoraToolBar(
return storageActions return storageActions
} }
fun rebuild() {
rebuild(this.toolbar)
}
private fun rebuild(toolbar: JToolBar) {
val actionManager = ActionManager.getInstance()
val actionContainerFactory = ActionContainerFactory(actionManager)
toolbar.removeAll()
toolbar.add(actionContainerFactory.createButton(object : AnAction(StringUtils.EMPTY, Icons.add) {
override fun actionPerformed(evt: AnActionEvent) {
actionManager.getAction(FindEverywhereAction.FIND_EVERYWHERE)?.actionPerformed(evt)
}
override fun isEnabled(): Boolean {
return actionManager.getAction(FindEverywhereAction.FIND_EVERYWHERE)?.isEnabled ?: false
}
}))
toolbar.add(Box.createHorizontalGlue())
if (SystemInfo.isLinux || SystemInfo.isWindows) {
toolbar.add(Box.createHorizontalStrut(16))
}
// update btn
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))
updateBtn.isVisible = updateBtn.isEnabled
updateBtn.addChangeListener { updateBtn.isVisible = updateBtn.isEnabled }
toolbar.add(updateBtn)
// 获取显示的Action如果不是 false 那么就是显示出来
for (action in getActions()) {
if (action.visible) {
val ac = actionManager.getAction(action.id)
if (ac == null) {
if (action.id == MultipleAction.MULTIPLE) {
toolbar.add(actionContainerFactory.createButton(MultipleAction.getInstance(windowScope)))
}
} else {
toolbar.add(actionContainerFactory.createButton(ac))
}
}
}
if (toolbar is MyToolBar) {
toolbar.adjust()
}
toolbar.revalidate()
toolbar.repaint()
}
private inner class MyToolBar : JToolBar() { private inner class MyToolBar : JToolBar() {
init { init {
// 监听窗口大小变动,然后修改边距避开控制按钮 // 监听窗口大小变动,然后修改边距避开控制按钮
@@ -179,5 +132,60 @@ class TermoraToolBar(
} }
} }
} }
fun rebuild() {
val toolbar: JToolBar = this
val actionManager = ActionManager.getInstance()
val actionContainerFactory = ActionContainerFactory(actionManager)
toolbar.removeAll()
toolbar.add(actionContainerFactory.createButton(object : AnAction(StringUtils.EMPTY, Icons.add) {
override fun actionPerformed(evt: AnActionEvent) {
actionManager.getAction(FindEverywhereAction.FIND_EVERYWHERE)?.actionPerformed(evt)
}
override fun isEnabled(): Boolean {
return actionManager.getAction(FindEverywhereAction.FIND_EVERYWHERE)?.isEnabled ?: false
}
}))
toolbar.add(Box.createHorizontalGlue())
if (SystemInfo.isLinux || SystemInfo.isWindows) {
toolbar.add(Box.createHorizontalStrut(16))
}
// update btn
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))
updateBtn.isVisible = updateBtn.isEnabled
updateBtn.addChangeListener { updateBtn.isVisible = updateBtn.isEnabled }
toolbar.add(updateBtn)
// 获取显示的Action如果不是 false 那么就是显示出来
for (action in getActions()) {
if (action.visible) {
val ac = actionManager.getAction(action.id)
if (ac == null) {
if (action.id == MultipleAction.MULTIPLE) {
toolbar.add(actionContainerFactory.createButton(MultipleAction.getInstance(windowScope)))
}
} else {
toolbar.add(actionContainerFactory.createButton(ac))
}
}
}
if (toolbar is MyToolBar) {
toolbar.adjust()
}
toolbar.revalidate()
toolbar.repaint()
}
} }
} }

View File

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

@@ -16,9 +16,7 @@ import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
import java.awt.Dimension import java.awt.Dimension
import java.awt.KeyboardFocusManager import java.awt.KeyboardFocusManager
import java.awt.event.ActionEvent import java.awt.event.*
import java.awt.event.ComponentAdapter
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
@@ -219,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() {

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)
@@ -59,7 +60,6 @@ class AppUpdateAction private constructor() : AnAction(
} }
@OptIn(DelicateCoroutinesApi::class)
private fun scheduleUpdate() { private fun scheduleUpdate() {
fixedRateTimer( fixedRateTimer(
name = "check-update-timer", name = "check-update-timer",
@@ -67,7 +67,7 @@ class AppUpdateAction private constructor() : AnAction(
period = 5.hours.inWholeMilliseconds, daemon = true period = 5.hours.inWholeMilliseconds, daemon = true
) { ) {
if (!isRemindMeNextTime) { if (!isRemindMeNextTime) {
GlobalScope.launch(Dispatchers.IO) { supervisorScope { launch { checkUpdate() } } } coroutineScope.launch(Dispatchers.IO) { checkUpdate() }
} }
} }
} }

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

@@ -20,6 +20,10 @@ class TerminalCopyAction : AnAction() {
override fun actionPerformed(evt: AnActionEvent) { override fun actionPerformed(evt: AnActionEvent) {
val terminalPanel = evt.getData(DataProviders.TerminalPanel) ?: return val terminalPanel = evt.getData(DataProviders.TerminalPanel) ?: return
val selectionModel = terminalPanel.terminal.getSelectionModel()
if (!selectionModel.hasSelection()) {
return
}
val text = terminalPanel.copy() val text = terminalPanel.copy()
val systemClipboard = terminalPanel.toolkit.systemClipboard val systemClipboard = terminalPanel.toolkit.systemClipboard
@@ -53,4 +57,4 @@ class TerminalCopyAction : AnAction() {
} }
} }

View File

@@ -51,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().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

@@ -2,6 +2,7 @@ package app.termora.highlight
import app.termora.ApplicationScope import app.termora.ApplicationScope
import app.termora.Database import app.termora.Database
import app.termora.DeleteDataManager
import app.termora.TerminalPanelFactory import app.termora.TerminalPanelFactory
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -38,6 +39,7 @@ class KeywordHighlightManager private constructor() {
database.removeKeywordHighlight(id) database.removeKeywordHighlight(id)
keywordHighlights.remove(id) keywordHighlights.remove(id)
TerminalPanelFactory.getInstance().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

@@ -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,7 +1,6 @@
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.native.FileChooser import app.termora.native.FileChooser
@@ -13,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
@@ -33,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
@@ -313,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 {
@@ -654,9 +645,13 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
return return
} }
ohKeyPair = ohKeyPair.copy(
name = nameTextField.text,
remark = remarkTextField.text,
)
if (ohKeyPair.remark.isEmpty()) { if (ohKeyPair.remark.isEmpty()) {
ohKeyPair = ohKeyPair.copy( ohKeyPair = ohKeyPair.copy(
name = nameTextField.text,
remark = "Import on " + DateFormatUtils.format(Date(), I18n.getString("termora.date-format")) remark = "Import on " + DateFormatUtils.format(Date(), I18n.getString("termora.date-format"))
) )
} }

View File

@@ -15,6 +15,7 @@ data class OhKeyPair(
val remark: String, val remark: String,
val length: Int, val length: Int,
val sort: Long, val sort: Long,
val updateDate: Long = System.currentTimeMillis(),
) { ) {
companion object { companion object {
val empty = OhKeyPair(String(), String(), String(), String(), String(), String(), 0, 0) val empty = OhKeyPair(String(), String(), String(), String(), String(), String(), 0, 0)

View File

@@ -2,10 +2,9 @@ package app.termora.keymgr
import app.termora.AES.decodeBase64 import app.termora.AES.decodeBase64
import app.termora.RSA import app.termora.RSA
import net.i2p.crypto.eddsa.EdDSAPrivateKey
import net.i2p.crypto.eddsa.EdDSAPublicKey
import org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider import org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider
import org.apache.sshd.common.session.SessionContext import org.apache.sshd.common.session.SessionContext
import org.apache.sshd.common.util.security.eddsa.Ed25519PublicKeyDecoder
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.security.Key import java.security.Key
import java.security.KeyPair import java.security.KeyPair
@@ -25,7 +24,7 @@ class OhKeyPairKeyPairProvider(private val id: String) : AbstractResourceKeyPair
val publicKey = cache.getOrPut(ohKeyPair.publicKey) { val publicKey = cache.getOrPut(ohKeyPair.publicKey) {
when (ohKeyPair.type) { when (ohKeyPair.type) {
"RSA" -> RSA.generatePublic(ohKeyPair.publicKey.decodeBase64()) "RSA" -> RSA.generatePublic(ohKeyPair.publicKey.decodeBase64())
"ED25519" -> EdDSAPublicKey(X509EncodedKeySpec(ohKeyPair.publicKey.decodeBase64())) "ED25519" -> Ed25519PublicKeyDecoder.INSTANCE.generatePublicKey((X509EncodedKeySpec(ohKeyPair.publicKey.decodeBase64())))
else -> throw UnsupportedOperationException("${ohKeyPair.type} is not supported") else -> throw UnsupportedOperationException("${ohKeyPair.type} is not supported")
} }
} as PublicKey } as PublicKey
@@ -33,7 +32,7 @@ class OhKeyPairKeyPairProvider(private val id: String) : AbstractResourceKeyPair
val privateKey = cache.getOrPut(ohKeyPair.privateKey) { val privateKey = cache.getOrPut(ohKeyPair.privateKey) {
when (ohKeyPair.type) { when (ohKeyPair.type) {
"RSA" -> RSA.generatePrivate(ohKeyPair.privateKey.decodeBase64()) "RSA" -> RSA.generatePrivate(ohKeyPair.privateKey.decodeBase64())
"ED25519" -> EdDSAPrivateKey(PKCS8EncodedKeySpec(ohKeyPair.privateKey.decodeBase64())) "ED25519" -> Ed25519PublicKeyDecoder.INSTANCE.generatePrivateKey(PKCS8EncodedKeySpec(ohKeyPair.privateKey.decodeBase64()))
else -> throw UnsupportedOperationException("${ohKeyPair.type} is not supported") else -> throw UnsupportedOperationException("${ohKeyPair.type} is not supported")
} }
} as PrivateKey } as PrivateKey

View File

@@ -43,7 +43,7 @@ class SSHCopyIdDialog(
terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate()) terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate())
.apply { enableFloatingToolbar = false } .apply { enableFloatingToolbar = false }
} }
private val coroutineScope = CoroutineScope(Job() + Dispatchers.IO) private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init { init {
size = Dimension(UIManager.getInt("Dialog.width") - 100, UIManager.getInt("Dialog.height") - 100) size = Dimension(UIManager.getInt("Dialog.width") - 100, UIManager.getInt("Dialog.height") - 100)
@@ -144,7 +144,7 @@ class SSHCopyIdDialog(
} }
try { try {
val client = SshClients.openClient(host).apply { myClient = this } val client = SshClients.openClient(host, this).apply { myClient = this }
client.userInteraction = TerminalUserInteraction(owner) client.userInteraction = TerminalUserInteraction(owner)
val session = SshClients.openSession(host, client).apply { mySession = this } val session = SshClients.openSession(host, client).apply { mySession = this }
val channel = val channel =

View File

@@ -19,6 +19,11 @@ data class Macro(
* 越大越靠前 * 越大越靠前
*/ */
val sort: Long = System.currentTimeMillis(), val sort: Long = System.currentTimeMillis(),
/**
* 更新时间
*/
val updateDate: Long = System.currentTimeMillis(),
) { ) {
val macroByteArray by lazy { macro.decodeBase64() } val macroByteArray by lazy { macro.decodeBase64() }
} }

View File

@@ -39,7 +39,7 @@ class MacroDialog(owner: Window) : DialogWrapper(owner) {
initEvents() initEvents()
init() init()
setLocationRelativeTo(null) setLocationRelativeTo(owner)
} }
private fun initView() { private fun initView() {

View File

@@ -2,6 +2,7 @@ package app.termora.macro
import app.termora.ApplicationScope import app.termora.ApplicationScope
import app.termora.Database import app.termora.Database
import app.termora.DeleteDataManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
/** /**
@@ -38,6 +39,7 @@ class MacroManager private constructor() {
fun removeMacro(id: String) { fun removeMacro(id: String) {
database.removeMacro(id) database.removeMacro(id)
macros.remove(id) macros.remove(id)
DeleteDataManager.getInstance().removeMacro(id)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Removed macro $id") log.debug("Removed macro $id")

View File

@@ -0,0 +1,9 @@
package app.termora.sftp
import org.apache.commons.vfs2.FileSystem
interface FileSystemProvider {
fun getFileSystem(): FileSystem
fun setFileSystem(fileSystem: FileSystem)
}

View File

@@ -4,7 +4,12 @@ import app.termora.Icons
import app.termora.assertEventDispatchThread import app.termora.assertEventDispatchThread
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextField import com.formdev.flatlaf.extras.components.FlatTextField
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.VFS
import org.apache.commons.vfs2.provider.local.LocalFileSystem
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
@@ -14,8 +19,7 @@ import java.awt.event.ActionEvent
import java.awt.event.ActionListener import java.awt.event.ActionListener
import java.awt.event.ItemEvent import java.awt.event.ItemEvent
import java.awt.event.ItemListener import java.awt.event.ItemListener
import java.nio.file.FileSystem import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.* import javax.swing.*
import javax.swing.event.PopupMenuEvent import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener import javax.swing.event.PopupMenuListener
@@ -23,8 +27,8 @@ import javax.swing.filechooser.FileSystemView
import kotlin.io.path.absolutePathString import kotlin.io.path.absolutePathString
class FileSystemViewNav( class FileSystemViewNav(
private val fileSystem: FileSystem, private val fileSystemProvider: FileSystemProvider,
private val homeDirectory: Path private val homeDirectory: FileObject
) : JPanel(BorderLayout()) { ) : JPanel(BorderLayout()) {
companion object { companion object {
@@ -38,7 +42,7 @@ class FileSystemViewNav(
private val history = linkedSetOf<String>() private val history = linkedSetOf<String>()
private val layeredPane = LayeredPane() private val layeredPane = LayeredPane()
private val downBtn = JButton(Icons.chevronDown) private val downBtn = JButton(Icons.chevronDown)
private val comboBox = object : JComboBox<Path>() { private val comboBox = object : JComboBox<FileObject>() {
override fun getLocationOnScreen(): Point { override fun getLocationOnScreen(): Point {
val point = super.getLocationOnScreen() val point = super.getLocationOnScreen()
point.y -= 1 point.y -= 1
@@ -80,7 +84,7 @@ class FileSystemViewNav(
): Component { ): Component {
val c = super.getListCellRendererComponent( val c = super.getListCellRendererComponent(
list, list,
value, if (value is FileObject) formatDisplayPath(value) else value.toString(),
index, index,
isSelected, isSelected,
cellHasFocus cellHasFocus
@@ -99,12 +103,12 @@ class FileSystemViewNav(
add(layeredPane, BorderLayout.CENTER) add(layeredPane, BorderLayout.CENTER)
if (fileSystem.isWindows()) { if (SystemInfo.isWindows && fileSystemProvider.getFileSystem() is LocalFileSystem) {
try { try {
for (root in fileSystemView.roots) { for (root in fileSystemView.roots) {
history.add(root.absolutePath) history.add(root.absolutePath)
} }
for (rootDirectory in fileSystem.rootDirectories) { for (rootDirectory in FileSystems.getDefault().rootDirectories) {
history.add(rootDirectory.absolutePathString()) history.add(rootDirectory.absolutePathString())
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -115,12 +119,16 @@ class FileSystemViewNav(
} }
} }
private fun formatDisplayPath(file: FileObject): String {
return file.absolutePathString()
}
private fun initEvents() { private fun initEvents() {
val itemListener = ItemListener { e -> val itemListener = ItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) { if (e.stateChange == ItemEvent.SELECTED) {
val item = comboBox.selectedItem val item = comboBox.selectedItem
if (item is Path) { if (item is FileObject) {
changeSelectedPath(item) changeSelectedPath(item)
} }
} }
@@ -166,8 +174,17 @@ class FileSystemViewNav(
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) {
val name = textField.text.trim() val name = textField.text.trim()
if (name.isBlank()) return if (name.isBlank()) return
val fileSystem = fileSystemProvider.getFileSystem()
try { try {
changeSelectedPath(fileSystem.getPath(name)) if (fileSystem is LocalFileSystem && SystemUtils.IS_OS_WINDOWS) {
val file = VFS.getManager().resolveFile("file://${name}")
if (!StringUtils.equals(file.fileSystem.rootURI, fileSystemProvider.getFileSystem().rootURI)) {
fileSystemProvider.setFileSystem(file.fileSystem)
}
changeSelectedPath(file)
} else {
changeSelectedPath(fileSystem.resolveFile(name))
}
} catch (e: Exception) { } catch (e: Exception) {
if (log.isErrorEnabled) { if (log.isErrorEnabled) {
log.error(e.message, e) log.error(e.message, e)
@@ -180,9 +197,14 @@ class FileSystemViewNav(
private fun showComboBoxPopup() { private fun showComboBoxPopup() {
comboBox.removeAllItems() comboBox.removeAllItems()
val fileSystem = fileSystemProvider.getFileSystem()
for (text in history) { for (text in history) {
val path = fileSystem.getPath(text) val path = if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
VFS.getManager().resolveFile("file://${text}")
} else {
fileSystem.resolveFile(text)
}
comboBox.addItem(path) comboBox.addItem(path)
if (text == textField.text) { if (text == textField.text) {
comboBox.selectedItem = path comboBox.selectedItem = path
@@ -218,15 +240,22 @@ class FileSystemViewNav(
} }
} }
fun getSelectedPath(): Path { fun getSelectedPath(): FileObject {
return textField.getClientProperty(PATH) as Path return textField.getClientProperty(PATH) as FileObject
} }
fun changeSelectedPath(path: Path) { fun changeSelectedPath(file: FileObject) {
assertEventDispatchThread() assertEventDispatchThread()
textField.text = path.absolutePathString() textField.text = formatDisplayPath(file)
textField.putClientProperty(PATH, path) textField.putClientProperty(PATH, file)
val fileSystem = fileSystemProvider.getFileSystem()
if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
if (!StringUtils.equals(fileSystem.rootURI, file.fileSystem.rootURI)) {
fileSystemProvider.setFileSystem(file.fileSystem)
}
}
for (listener in listenerList.getListeners(ActionListener::class.java)) { for (listener in listenerList.getListeners(ActionListener::class.java)) {
listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY)) listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))

View File

@@ -3,35 +3,38 @@ package app.termora.sftp
import app.termora.* import app.termora.*
import app.termora.actions.DataProvider import app.termora.actions.DataProvider
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.vfs2.sftp.MySftpFileSystem
import com.formdev.flatlaf.extras.components.FlatToolBar import com.formdev.flatlaf.extras.components.FlatToolBar
import kotlinx.coroutines.* import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
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.sshd.sftp.client.fs.SftpFileSystem import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.FileSystem
import org.apache.commons.vfs2.VFS
import org.apache.commons.vfs2.provider.local.LocalFileSystem
import org.jdesktop.swingx.JXBusyLabel import org.jdesktop.swingx.JXBusyLabel
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.event.* import java.awt.event.*
import java.nio.file.FileSystem
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.function.Consumer import java.util.function.Consumer
import javax.swing.* import javax.swing.*
import kotlin.io.path.absolutePathString
import kotlin.io.path.name
class FileSystemViewPanel( class FileSystemViewPanel(
val host: Host, val host: Host,
val fileSystem: FileSystem, private var fileSystem: FileSystem,
private val transportManager: TransportManager, private val transportManager: TransportManager,
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), private val coroutineScope: CoroutineScope,
) : JPanel(BorderLayout()), Disposable, DataProvider { ) : JPanel(BorderLayout()), Disposable, DataProvider, FileSystemProvider {
private val properties get() = Database.getDatabase().properties private val properties get() = Database.getDatabase().properties
private val sftp get() = Database.getDatabase().sftp private val sftp get() = Database.getDatabase().sftp
private val table = FileSystemViewTable(fileSystem, transportManager, coroutineScope) private val table = FileSystemViewTable(this, transportManager, coroutineScope)
private val disposed = AtomicBoolean(false) private val disposed = AtomicBoolean(false)
private var nextReloadTicks = emptyArray<Consumer<Unit>>() private var nextReloadTicks = emptyArray<Consumer<Unit>>()
private val isLoading = AtomicBoolean(false) private val isLoading = AtomicBoolean(false)
@@ -39,7 +42,7 @@ class FileSystemViewPanel(
private val loadingPanel = LoadingPanel() private val loadingPanel = LoadingPanel()
private val layeredPane = LayeredPane() private val layeredPane = LayeredPane()
private val homeDirectory = getHomeDirectory() private val homeDirectory = getHomeDirectory()
private val nav = FileSystemViewNav(fileSystem, homeDirectory) private val nav = FileSystemViewNav(this, homeDirectory)
private var workdir = homeDirectory private var workdir = homeDirectory
private val model get() = table.model as FileSystemViewTableModel private val model get() = table.model as FileSystemViewTableModel
private val showHiddenFilesKey = "termora.transport.host.${host.id}.show-hidden-files" private val showHiddenFilesKey = "termora.transport.host.${host.id}.show-hidden-files"
@@ -100,7 +103,7 @@ class FileSystemViewPanel(
override fun onTransportChanged(transport: Transport) { override fun onTransportChanged(transport: Transport) {
val path = transport.target.parent ?: return val path = transport.target.parent ?: return
if (path.fileSystem != fileSystem) return if (path.fileSystem != fileSystem) return
if (path.absolutePathString() != workdir.absolutePathString()) return if (path.name.path != workdir.name.path) return
// 立即刷新 // 立即刷新
reload(true) reload(true)
} }
@@ -123,19 +126,19 @@ class FileSystemViewPanel(
private fun enterTableSelectionFolder(row: Int = table.selectedRow) { private fun enterTableSelectionFolder(row: Int = table.selectedRow) {
if (row < 0 || isLoading.get()) return if (row < 0 || isLoading.get()) return
val attr = model.getAttr(row) val file = model.getFileObject(row)
if (attr.isFile) return if (file.isFile) return
// 当前工作目录 // 当前工作目录
val workdir = getWorkdir() val workdir = getWorkdir()
// 返回上级之后,选中上级目录 // 返回上级之后,选中上级目录
if (attr.name == "..") { if (row == 0 && model.hasParent) {
val workdirName = workdir.name val workdirName = workdir.name
nextReloadTickSelection(workdirName) nextReloadTickSelection(workdirName.baseName)
} }
changeWorkdir(attr.path) changeWorkdir(file)
} }
@@ -169,13 +172,21 @@ class FileSystemViewPanel(
bookmarkBtn.addActionListener { e -> bookmarkBtn.addActionListener { e ->
if (e.actionCommand.isNullOrBlank()) { if (e.actionCommand.isNullOrBlank()) {
if (bookmarkBtn.isBookmark) { if (bookmarkBtn.isBookmark) {
bookmarkBtn.deleteBookmark(workdir.toString()) bookmarkBtn.deleteBookmark(workdir.absolutePathString())
} else { } else {
bookmarkBtn.addBookmark(workdir.toString()) bookmarkBtn.addBookmark(workdir.absolutePathString())
} }
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
} else { } else {
changeWorkdir(fileSystem.getPath(e.actionCommand)) if (fileSystem is LocalFileSystem && SystemUtils.IS_OS_WINDOWS) {
val file = VFS.getManager().resolveFile("file://${e.actionCommand}")
if (!StringUtils.equals(file.fileSystem.rootURI, fileSystem.rootURI)) {
fileSystem = file.fileSystem
}
changeWorkdir(file)
} else {
changeWorkdir(fileSystem.resolveFile(e.actionCommand))
}
} }
} }
@@ -194,14 +205,12 @@ class FileSystemViewPanel(
button.addActionListener(object : AbstractAction() { button.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) {
if (model.rowCount < 1) return if (model.rowCount < 1) return
val attr = model.getAttr(0) if (model.hasParent) enterTableSelectionFolder(0)
if (attr !is FileSystemViewTableModel.ParentAttr) return
enterTableSelectionFolder(0)
} }
}) })
addPropertyChangeListener("workdir") { addPropertyChangeListener("workdir") {
button.isEnabled = model.rowCount > 0 && model.getAttr(0) is FileSystemViewTableModel.ParentAttr button.isEnabled = model.rowCount > 0 && model.hasParent
} }
return button return button
@@ -211,7 +220,7 @@ class FileSystemViewPanel(
// 创建成功之后需要修改和选中 // 创建成功之后需要修改和选中
registerNextReloadTick { registerNextReloadTick {
for (i in 0 until table.rowCount) { for (i in 0 until table.rowCount) {
if (model.getAttr(i).name == name) { if (model.getFileObject(i).name.baseName == name) {
table.addRowSelectionInterval(i, i) table.addRowSelectionInterval(i, i)
table.scrollRectToVisible(table.getCellRect(i, 0, true)) table.scrollRectToVisible(table.getCellRect(i, 0, true))
consumer.accept(i) consumer.accept(i)
@@ -221,18 +230,19 @@ class FileSystemViewPanel(
} }
} }
private fun changeWorkdir(workdir: Path) { private fun changeWorkdir(workdir: FileObject) {
assertEventDispatchThread() assertEventDispatchThread()
nav.changeSelectedPath(workdir) nav.changeSelectedPath(workdir)
} }
fun renameTo(oldPath: Path, newPath: Path) { fun renameTo(oldPath: FileObject, newPath: FileObject) {
// 新建文件夹 // 新建文件夹
coroutineScope.launch { coroutineScope.launch {
if (requestLoading()) { if (requestLoading()) {
try { try {
Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE) oldPath.moveTo(newPath)
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
@@ -247,7 +257,7 @@ class FileSystemViewPanel(
} }
// 创建成功之后需要选中 // 创建成功之后需要选中
nextReloadTickSelection(newPath.name) nextReloadTickSelection(newPath.name.baseName)
// 立即刷新 // 立即刷新
reload() reload()
@@ -258,7 +268,7 @@ class FileSystemViewPanel(
coroutineScope.launch { coroutineScope.launch {
if (requestLoading()) { if (requestLoading()) {
try { try {
doNewFolderOrFile(getWorkdir().resolve(name), isFile) doNewFolderOrFile(getWorkdir().resolveFile(name), isFile)
} finally { } finally {
stopLoading() stopLoading()
} }
@@ -273,9 +283,9 @@ class FileSystemViewPanel(
} }
private suspend fun doNewFolderOrFile(path: Path, isFile: Boolean) { private suspend fun doNewFolderOrFile(path: FileObject, isFile: Boolean) {
if (Files.exists(path)) { if (path.exists()) {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
owner, owner,
@@ -288,7 +298,7 @@ class FileSystemViewPanel(
// 创建文件夹 // 创建文件夹
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { if (isFile) Files.createFile(path) else Files.createDirectories(path) }.onFailure { runCatching { if (isFile) path.createFile() else path.createFolder() }.onFailure {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
if (it is Exception) { if (it is Exception) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
@@ -329,7 +339,7 @@ class FileSystemViewPanel(
fun reload(rememberSelection: Boolean = false) { fun reload(rememberSelection: Boolean = false) {
if (!requestLoading()) return if (!requestLoading()) return
if (fileSystem.isSFTP()) loadingPanel.start() if (fileSystem is MySftpFileSystem) loadingPanel.start()
val oldWorkdir = workdir val oldWorkdir = workdir
val path = nav.getSelectedPath() val path = nav.getSelectedPath()
@@ -338,7 +348,7 @@ class FileSystemViewPanel(
if (rememberSelection) { if (rememberSelection) {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
table.selectedRows.sortedDescending().map { model.getAttr(it).name } table.selectedRows.sortedDescending().map { model.getFileObject(it).name.baseName }
.forEach { nextReloadTickSelection(it) } .forEach { nextReloadTickSelection(it) }
} }
} }
@@ -347,7 +357,7 @@ class FileSystemViewPanel(
if (it is Exception) { if (it is Exception) {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
owner, ExceptionUtils.getMessage(it), owner, ExceptionUtils.getRootCauseMessage(it),
messageType = JOptionPane.ERROR_MESSAGE messageType = JOptionPane.ERROR_MESSAGE
) )
} }
@@ -367,34 +377,41 @@ class FileSystemViewPanel(
} finally { } finally {
stopLoading() stopLoading()
if (fileSystem.isSFTP()) { if (fileSystem is MySftpFileSystem) {
withContext(Dispatchers.Swing) { loadingPanel.stop() } withContext(Dispatchers.Swing) { loadingPanel.stop() }
} }
} }
} }
} }
private fun getHomeDirectory(): Path { private fun getHomeDirectory(): FileObject {
if (fileSystem.isSFTP()) { val fileSystem = this.fileSystem
val fs = fileSystem as SftpFileSystem if (fileSystem is MySftpFileSystem) {
val host = fs.session.getAttribute(SshClients.HOST_KEY) ?: return fs.defaultDir val host = fileSystem.getClientSession().getAttribute(SshClients.HOST_KEY)
?: return fileSystem.resolveFile(fileSystem.getDefaultDir())
val defaultDirectory = host.options.sftpDefaultDirectory val defaultDirectory = host.options.sftpDefaultDirectory
if (defaultDirectory.isNotBlank()) { if (defaultDirectory.isNotBlank()) {
return runCatching { fs.getPath(defaultDirectory) } return fileSystem.resolveFile(defaultDirectory)
.getOrElse { fs.defaultDir }
} }
return fs.defaultDir return fileSystem.resolveFile(fileSystem.getDefaultDir())
} }
if (sftp.defaultDirectory.isNotBlank()) { if (sftp.defaultDirectory.isNotBlank()) {
return runCatching { fileSystem.getPath(sftp.defaultDirectory) } val resolveFile = if (fileSystem is LocalFileSystem && SystemInfo.isWindows) {
.getOrElse { fileSystem.getPath(SystemUtils.USER_HOME) } VFS.getManager().resolveFile("file://${sftp.defaultDirectory}")
} else {
fileSystem.resolveFile("file://${sftp.defaultDirectory}")
}
if (resolveFile.exists()) {
setFileSystem(resolveFile.fileSystem)
return resolveFile
}
} }
return fileSystem.getPath(SystemUtils.USER_HOME) return fileSystem.resolveFile("file://${SystemUtils.USER_HOME}")
} }
fun getWorkdir(): Path { fun getWorkdir(): FileObject {
return workdir return workdir
} }
@@ -422,6 +439,7 @@ class FileSystemViewPanel(
child.changeStatus(TransportStatus.Failed) child.changeStatus(TransportStatus.Failed)
} }
} }
fileSystem.fileSystemManager.filesCache.clear(fileSystem)
} }
} }
@@ -430,6 +448,14 @@ class FileSystemViewPanel(
return if (dataKey == SFTPDataProviders.FileSystemViewTable) table as T else null return if (dataKey == SFTPDataProviders.FileSystemViewTable) table as T else null
} }
override fun getFileSystem(): FileSystem {
return fileSystem
}
override fun setFileSystem(fileSystem: FileSystem) {
this.fileSystem = fileSystem
}
private class LoadingPanel : JPanel() { private class LoadingPanel : JPanel() {
private val busyLabel = JXBusyLabel() private val busyLabel = JXBusyLabel()

View File

@@ -3,21 +3,27 @@ package app.termora.sftp
import app.termora.* import app.termora.*
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction import app.termora.actions.SettingsAction
import app.termora.sftp.FileSystemViewTable.AskTransfer.Action
import app.termora.vfs2.VFSWalker
import app.termora.vfs2.sftp.MySftpFileObject
import app.termora.vfs2.sftp.MySftpFileSystem
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.FileUtils
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.sftp.client.SftpClient import org.apache.commons.vfs2.FileObject
import org.apache.sshd.sftp.client.fs.SftpFileSystem import org.apache.commons.vfs2.VFS
import org.apache.sshd.sftp.client.fs.SftpPath import org.apache.commons.vfs2.provider.local.LocalFileSystem
import org.apache.sshd.sftp.client.fs.SftpPosixFileAttributes
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.Component import java.awt.Component
import java.awt.Dimension
import java.awt.Insets import java.awt.Insets
import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
@@ -27,7 +33,10 @@ import java.awt.event.*
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import java.nio.file.* import java.nio.file.FileVisitResult
import java.nio.file.FileVisitor
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.text.MessageFormat import java.text.MessageFormat
import java.util.* import java.util.*
@@ -36,13 +45,29 @@ import java.util.regex.Pattern
import javax.swing.* import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableCellRenderer
import kotlin.collections.ArrayDeque import kotlin.collections.ArrayDeque
import kotlin.io.path.* import kotlin.collections.List
import kotlin.collections.all
import kotlin.collections.contains
import kotlin.collections.filter
import kotlin.collections.filterIsInstance
import kotlin.collections.find
import kotlin.collections.forEach
import kotlin.collections.isEmpty
import kotlin.collections.isNotEmpty
import kotlin.collections.last
import kotlin.collections.listOf
import kotlin.collections.map
import kotlin.collections.mapOf
import kotlin.collections.mutableListOf
import kotlin.collections.sortedArray
import kotlin.io.path.absolutePathString
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Suppress("DuplicatedCode") @Suppress("DuplicatedCode", "CascadeIf")
class FileSystemViewTable( class FileSystemViewTable(
private val fileSystem: FileSystem, private val fileSystemProvider: FileSystemProvider,
private val transportManager: TransportManager, private val transportManager: TransportManager,
private val coroutineScope: CoroutineScope private val coroutineScope: CoroutineScope
) : JTable(), Disposable { ) : JTable(), Disposable {
@@ -99,8 +124,8 @@ class FileSystemViewTable(
): Component { ): Component {
foreground = null foreground = null
val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getAttr(row).icon else null icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getFileIcon(row) else null
foreground = if (!isSelected && model.getAttr(row).isHidden) foreground = if (!isSelected && model.getFileObject(row).isHidden)
UIManager.getColor("textInactiveText") else foreground UIManager.getColor("textInactiveText") else foreground
return c return c
} }
@@ -131,14 +156,14 @@ class FileSystemViewTable(
table.requestFocusInWindow() table.requestFocusInWindow()
} }
showContextMenu(rows, e) showContextMenu(rows.sortedArray(), e)
} else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { } else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val row = table.selectedRow val row = table.selectedRow
if (row <= 0 || row >= table.rowCount) return if (row <= 0 || row >= table.rowCount) return
val attr = model.getAttr(row) val file = model.getFileObject(row)
if (attr.isDirectory) return if (file.isFolder) return
// 传输 // 传输
transfer(arrayOf(attr)) transfer(listOf(file))
} }
} }
}) })
@@ -150,8 +175,7 @@ class FileSystemViewTable(
if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) { if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) {
val rows = selectedRows val rows = selectedRows
if (rows.contains(0)) return if (rows.contains(0)) return
val attrs = rows.map { model.getAttr(it) }.toTypedArray() val files = rows.map { model.getFileObject(it) }
val files = attrs.map { it.path }.toTypedArray()
deletePaths(files, false) deletePaths(files, false)
} else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) { } else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) {
fileSystemViewPanel.reload(true) fileSystemViewPanel.reload(true)
@@ -167,13 +191,15 @@ class FileSystemViewTable(
// 如果不是新增行,并且光标不在第一列,那么不允许 // 如果不是新增行,并且光标不在第一列,那么不允许
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false
// 如果不是新增行,如果在一个文件上,那么不允许 // 如果不是新增行,如果在一个文件上,那么不允许
if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false if (!dropLocation.isInsertRow && model.getFileObject(dropLocation.row).isFile) return false
// 如果不是新增行,在 .. 上面,不允许
if (!dropLocation.isInsertRow && model.hasParent && dropLocation.row == 0) return false
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
return data is FileSystemTableRowTransferable && data.source != table return data is FileSystemTableRowTransferable && data.source != table
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
return !fileSystem.isLocal() return fileSystemProvider.getFileSystem() !is LocalFileSystem
} }
return false return false
@@ -184,32 +210,30 @@ class FileSystemViewTable(
// 如果不是新增行,并且光标不在第一列,那么不允许 // 如果不是新增行,并且光标不在第一列,那么不允许
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false
// 如果不是新增行,如果在一个文件上,那么不允许 // 如果不是新增行,如果在一个文件上,那么不允许
if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false if (!dropLocation.isInsertRow && model.getFileObject(dropLocation.row).isFile) return false
var targetWorkdir: Path? = null var targetWorkdir: FileObject? = null
// 变更工作目录 // 变更工作目录
if (!dropLocation.isInsertRow) { if (!dropLocation.isInsertRow) {
targetWorkdir = model.getAttr(dropLocation.row).path targetWorkdir = model.getFileObject(dropLocation.row)
} }
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) { if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor) val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
if (data !is FileSystemTableRowTransferable) return false if (data !is FileSystemTableRowTransferable) return false
// 委托源表开始传输 // 委托源表开始传输
data.source.transfer(data.attrs.toTypedArray(), false, targetWorkdir) data.source.transfer(data.files, false, targetWorkdir)
return true return true
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*> val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return false if (files.isEmpty()) return false
val paths = files.filterIsInstance<File>() val paths = files.filterIsInstance<File>().map { VFS.getManager().resolveFile(it.toURI()) }
.map { FileSystemViewTableModel.Attr(it.toPath()) }
.toTypedArray()
if (paths.isEmpty()) return false if (paths.isEmpty()) return false
val localTarget = sftpPanel.getLocalTarget() val localTarget = sftpPanel.getLocalTarget()
val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false
// 委托最左侧的本地文件系统传输 // 委托最左侧的本地文件系统传输
table.transfer(paths, true, targetWorkdir) table.transfer(paths, true, targetWorkdir, fileSystemViewPanel)
return true return true
} }
return false return false
@@ -220,9 +244,9 @@ class FileSystemViewTable(
} }
override fun createTransferable(c: JComponent?): Transferable? { override fun createTransferable(c: JComponent?): Transferable? {
val attrs = table.selectedRows.filter { it != 0 }.map { model.getAttr(it) } val files = table.selectedRows.filter { it != 0 }.map { model.getFileObject(it) }
if (attrs.isEmpty()) return null if (files.isEmpty()) return null
return FileSystemTableRowTransferable(table, attrs) return FileSystemTableRowTransferable(table, files)
} }
} }
@@ -237,7 +261,7 @@ class FileSystemViewTable(
} }
private fun navigate(row: Int, c: Char): Boolean { private fun navigate(row: Int, c: Char): Boolean {
val name = model.getAttr(row).name val name = model.getFileObject(row).name.baseName
if (name.startsWith(c, true)) { if (name.startsWith(c, true)) {
clearSelection() clearSelection()
addRowSelectionInterval(row, row) addRowSelectionInterval(row, row)
@@ -249,19 +273,10 @@ class FileSystemViewTable(
}) })
} }
override fun dispose() {
if (isDisposed.compareAndSet(false, true)) {
if (!fileSystem.isSFTP()) {
coroutineScope.cancel()
}
}
}
private fun showContextMenu(rows: IntArray, e: MouseEvent) { private fun showContextMenu(rows: IntArray, e: MouseEvent) {
val attrs = rows.map { model.getAttr(it) }.toTypedArray() val files = rows.map { model.getFileObject(it) }
val files = attrs.map { it.path }.toTypedArray()
val hasParent = rows.contains(0) val hasParent = rows.contains(0)
val fileSystem = fileSystemProvider.getFileSystem()
val popupMenu = FlatPopupMenu() val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new")) val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
@@ -273,13 +288,13 @@ class FileSystemViewTable(
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer")) val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
// 编辑 // 编辑
val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit")) val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit"))
edit.isEnabled = fileSystem.isSFTP() && attrs.all { it.isFile } edit.isEnabled = fileSystem is MySftpFileSystem && files.all { it.isFile }
popupMenu.addSeparator() popupMenu.addSeparator()
// 复制路径 // 复制路径
val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path")) val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path"))
// 如果是本地,那么支持打开本地路径 // 如果是本地,那么支持打开本地路径
if (fileSystem.isLocal()) { if (fileSystem is LocalFileSystem) {
popupMenu.add( popupMenu.add(
I18n.getString( I18n.getString(
"termora.transport.table.contextmenu.open-in-folder", "termora.transport.table.contextmenu.open-in-folder",
@@ -288,7 +303,7 @@ class FileSystemViewTable(
else I18n.getString("termora.folder") else I18n.getString("termora.folder")
) )
).addActionListener { ).addActionListener {
Application.browseInFolder(files.last().toFile()) Application.browseInFolder(File(files.last().absolutePathString()))
} }
} }
@@ -301,18 +316,15 @@ class FileSystemViewTable(
val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete")) val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete"))
// rm -rf // rm -rf
val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction)) val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction))
// 只有 SFTP 可以 // 只有 SFTP 可以
if (!fileSystem.isSFTP()) { rmrf.isVisible = fileSystem is MySftpFileSystem
rmrf.isVisible = false
}
// 修改权限 // 修改权限
val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions")) val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
permission.isEnabled = false permission.isEnabled = false
// 如果是本地系统文件,那么不允许修改权限,用户应该自己修改 // 如果是本地系统文件,那么不允许修改权限,用户应该自己修改
if (fileSystem.isSFTP() && rows.isNotEmpty()) { if (fileSystem is MySftpFileSystem && rows.isNotEmpty()) {
permission.isEnabled = true permission.isEnabled = true
} }
popupMenu.addSeparator() popupMenu.addSeparator()
@@ -354,45 +366,20 @@ class FileSystemViewTable(
}) })
copyPath.addActionListener { copyPath.addActionListener {
val sb = StringBuilder() val sb = StringBuilder()
attrs.forEach { sb.append(it.path.absolutePathString()).appendLine() } files.forEach { sb.append(it.absolutePathString()).appendLine() }
sb.deleteCharAt(sb.length - 1) sb.deleteCharAt(sb.length - 1)
toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null) toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null)
} }
edit.addActionListener { if (files.isNotEmpty()) editFiles(files) } edit.addActionListener { if (files.isNotEmpty()) editFiles(files) }
permission.addActionListener(object : AbstractAction() { permission.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) {
val last = attrs.last() val last = files.last()
val dialog = PosixFilePermissionDialog( if (last !is MySftpFileObject) return
SwingUtilities.getWindowAncestor(table), changePermission(last)
last.posixFilePermissions
)
val permissions = dialog.open() ?: return
if (fileSystemViewPanel.requestLoading()) {
coroutineScope.launch(Dispatchers.IO) {
val c = runCatching { Files.setPosixFilePermissions(last.path, permissions) }.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
// stop loading
fileSystemViewPanel.stopLoading()
// reload
if (c.isSuccess) {
fileSystemViewPanel.reload(true)
}
}
}
} }
}) })
refresh.addActionListener { fileSystemViewPanel.reload() } refresh.addActionListener { fileSystemViewPanel.reload() }
transfer.addActionListener { transfer(attrs) } transfer.addActionListener { transfer(files) }
if (rows.isEmpty() || hasParent) { if (rows.isEmpty() || hasParent) {
transfer.isEnabled = false transfer.isEnabled = false
@@ -410,16 +397,90 @@ class FileSystemViewTable(
popupMenu.show(table, e.x, e.y) popupMenu.show(table, e.x, e.y)
} }
private fun changePermission(file: MySftpFileObject) {
val dialog = PosixFilePermissionDialog(
SwingUtilities.getWindowAncestor(table),
model.getFilePermissions(file)
)
val permissions = dialog.open() ?: return
val isIncludeSubdirectories = dialog.isIncludeSubdirectories()
if (fileSystemViewPanel.requestLoading()) {
coroutineScope.launch(Dispatchers.IO) {
val c = runCatching {
file.setPosixFilePermissions(permissions)
if (isIncludeSubdirectories && file.isFolder) {
file.refresh()
VFSWalker.walk(file, object : FileVisitor<FileObject> {
override fun preVisitDirectory(
dir: FileObject,
attrs: BasicFileAttributes
): FileVisitResult {
dir.refresh()
if (dir is MySftpFileObject) {
dir.setPosixFilePermissions(permissions)
}
return FileVisitResult.CONTINUE
}
override fun visitFile(
file: FileObject,
attrs: BasicFileAttributes
): FileVisitResult {
if (file is MySftpFileObject) {
file.setPosixFilePermissions(permissions)
}
return FileVisitResult.CONTINUE
}
override fun visitFileFailed(
file: FileObject,
exc: IOException
): FileVisitResult {
return FileVisitResult.TERMINATE
}
override fun postVisitDirectory(
dir: FileObject,
exc: IOException?
): FileVisitResult {
return FileVisitResult.CONTINUE
}
})
}
}.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
// stop loading
fileSystemViewPanel.stopLoading()
// reload
if (c.isSuccess) {
fileSystemViewPanel.reload(true)
}
}
}
}
private fun renameSelection() { private fun renameSelection() {
val index = selectedRow val index = selectedRow
if (index < 0) return if (index < 0) return
val attr = model.getAttr(index) val file = model.getFileObject(index)
val text = OptionPane.showInputDialog( val text = OptionPane.showInputDialog(
owner, owner,
value = attr.name, value = file.name.baseName,
title = I18n.getString("termora.transport.table.contextmenu.rename") title = I18n.getString("termora.transport.table.contextmenu.rename")
) ?: return ) ?: return
if (text.isBlank() || text == attr.name) return if (text.isBlank() || text == file.name.baseName) return
if (model.getPathNames().contains(text)) { if (model.getPathNames().contains(text)) {
OptionPane.showMessageDialog( OptionPane.showMessageDialog(
owner, owner,
@@ -428,10 +489,11 @@ class FileSystemViewTable(
) )
return return
} }
fileSystemViewPanel.renameTo(attr.path, attr.path.parent.resolve(text))
fileSystemViewPanel.renameTo(file, file.parent.resolveFile(text))
} }
private fun editFiles(files: Array<Path>) { private fun editFiles(files: List<FileObject>) {
if (files.isEmpty()) return if (files.isEmpty()) return
if (SystemInfo.isLinux) { if (SystemInfo.isLinux) {
@@ -449,10 +511,11 @@ class FileSystemViewTable(
for (file in files) { for (file in files) {
val dir = Application.createSubTemporaryDir() val dir = Application.createSubTemporaryDir()
val path = Paths.get(dir.absolutePathString(), file.name) val path = Paths.get(dir.absolutePathString(), file.name.baseName)
val target = VFS.getManager().resolveFile("file://" + path.absolutePathString())
val newTransport = createTransport(file, false, 0L) val newTransport = createTransport(file, false, 0L)
.apply { target = path } .apply { this.target = target }
transportManager.addTransportListener(object : TransportListener { transportManager.addTransportListener(object : TransportListener {
override fun onTransportChanged(transport: Transport) { override fun onTransportChanged(transport: Transport) {
@@ -461,7 +524,7 @@ class FileSystemViewTable(
transportManager.removeTransportListener(this) transportManager.removeTransportListener(this)
if (transport.status != TransportStatus.Done) return if (transport.status != TransportStatus.Done) return
// 监听文件变动 // 监听文件变动
listenFileChange(path, file) listenFileChange(target, file)
} }
}) })
@@ -470,21 +533,15 @@ class FileSystemViewTable(
} }
} }
private fun listenFileChange(localPath: Path, remotePath: Path) { private fun listenFileChange(localPath: FileObject, remotePath: FileObject) {
try { try {
val p = localPath.absolutePathString()
if (sftp.editCommand.isNotBlank()) { if (sftp.editCommand.isNotBlank()) {
ProcessBuilder( ProcessBuilder(parseCommand(MessageFormat.format(sftp.editCommand, p))).start()
parseCommand(
MessageFormat.format(
sftp.editCommand,
localPath.absolutePathString()
)
)
).start()
} else if (SystemInfo.isMacOS) { } else if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start() ProcessBuilder("open", "-a", "TextEdit", p).start()
} else if (SystemInfo.isWindows) { } else if (SystemInfo.isWindows) {
ProcessBuilder("notepad", localPath.absolutePathString()).start() ProcessBuilder("notepad", p).start()
} else { } else {
return return
} }
@@ -495,13 +552,17 @@ class FileSystemViewTable(
return return
} }
var lastModifiedTime = localPath.getLastModifiedTime().toMillis() var lastModifiedTime = localPath.content.lastModifiedTime
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
while (coroutineScope.isActive) { while (coroutineScope.isActive) {
try { try {
if (isDisposed.get() || !Files.exists(localPath)) break
val nowModifiedTime = localPath.getLastModifiedTime().toMillis() if (isDisposed.get()) break
localPath.refresh()
if (!localPath.exists()) break
val nowModifiedTime = localPath.content.lastModifiedTime
if (nowModifiedTime != lastModifiedTime) { if (nowModifiedTime != lastModifiedTime) {
lastModifiedTime = nowModifiedTime lastModifiedTime = nowModifiedTime
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
@@ -556,23 +617,7 @@ class FileSystemViewTable(
fileSystemViewPanel.newFolderOrFile(text, isFile) fileSystemViewPanel.newFolderOrFile(text, isFile)
} }
private fun transfer( private fun deletePaths(paths: List<FileObject>, rm: Boolean = false) {
attrs: Array<FileSystemViewTableModel.Attr>,
fromLocalSystem: Boolean = false,
targetWorkdir: Path? = null
) {
coroutineScope.launch {
try {
doTransfer(attrs, fromLocalSystem, targetWorkdir)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
private fun deletePaths(paths: Array<Path>, rm: Boolean = false) {
if (OptionPane.showConfirmDialog( if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this), SwingUtilities.getWindowAncestor(this),
I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"), I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"),
@@ -586,10 +631,10 @@ class FileSystemViewTable(
return return
} }
coroutineScope.launch { coroutineScope.launch(Dispatchers.IO) {
runCatching { runCatching {
if (fileSystem.isSFTP()) { if (fileSystemProvider.getFileSystem() is MySftpFileSystem) {
deleteSftpPaths(paths, rm) deleteSftpPaths(paths, rm)
} else { } else {
deleteRecursively(paths) deleteRecursively(paths)
@@ -600,61 +645,215 @@ class FileSystemViewTable(
} }
} }
// 停止加载 withContext(Dispatchers.Swing) {
fileSystemViewPanel.stopLoading() // 停止加载
fileSystemViewPanel.stopLoading()
// 刷新 // 刷新
fileSystemViewPanel.reload() fileSystemViewPanel.reload()
}
} }
} }
private fun deleteSftpPaths(paths: Array<Path>, rm: Boolean = false) { private fun deleteSftpPaths(files: List<FileObject>, rm: Boolean = false) {
val fs = this.fileSystem as SftpFileSystem
if (rm) { if (rm) {
for (path in paths) { val session = (this.fileSystemProvider.getFileSystem() as MySftpFileSystem).getClientSession()
fs.session.executeRemoteCommand( for (path in files) {
session.executeRemoteCommand(
"rm -rf '${path.absolutePathString()}'", "rm -rf '${path.absolutePathString()}'",
OutputStream.nullOutputStream(), OutputStream.nullOutputStream(),
Charsets.UTF_8 Charsets.UTF_8
) )
} }
} else { } else {
fs.client.use { deleteRecursively(files)
for (path in paths) {
deleteRecursivelySFTP(path as SftpPath, it)
}
}
} }
} }
private fun deleteRecursively(paths: Array<Path>) { private fun deleteRecursively(files: List<FileObject>) {
for (path in paths) { for (path in files) {
FileUtils.deleteQuietly(path.toFile()) path.deleteAll()
path.close()
} }
} }
/**
* 优化删除效率,采用一个连接 private fun transfer(
*/ files: List<FileObject>,
private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) { fromLocalSystem: Boolean = false,
val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory() targetWorkdir: FileObject? = null,
if (isDirectory) { target: FileSystemViewPanel? = null,
for (e in sftpClient.readDir(path.toString())) { ) {
if (e.filename == ".." || e.filename == ".") {
continue assertEventDispatchThread()
}
if (e.attributes.isDirectory) { val target = (target ?: sftpPanel.getTarget(table)) ?: return
deleteRecursivelySFTP(path.resolve(e.filename), sftpClient) val table = target.getData(SFTPDataProviders.FileSystemViewTable) ?: return
} else { var isApplyAll = false
sftpClient.remove(path.resolve(e.filename).toString()) var lastAction = Action.Overwrite
for (file in files) {
if (!isApplyAll && (targetWorkdir == null || target.getWorkdir() == targetWorkdir)) {
val targetAttr = 0.rangeUntil(table.model.rowCount).map { table.model.getFileObject(it) }
.find { it.name.baseName == file.name.baseName }
if (targetAttr != null) {
val askTransfer = askTransfer(file, targetAttr)
if (askTransfer.option != JOptionPane.YES_OPTION) {
continue
}
if (askTransfer.action == Action.Skip) {
if (askTransfer.applyAll) break
continue
} else {
lastAction = askTransfer.action
isApplyAll = askTransfer.applyAll
}
} }
} }
sftpClient.rmdir(path.toString())
coroutineScope.launch {
try {
doTransfer(file, lastAction, fromLocalSystem, targetWorkdir, target)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
}
private data class AskTransfer(
val option: Int,
val action: Action,
val applyAll: Boolean
) {
enum class Action {
Overwrite,
Append,
Skip
}
}
private fun askTransfer(
sourceFile: FileObject,
targetFile: FileObject
): AskTransfer {
val formMargin = "7dlu"
val layout = FormLayout(
"left:pref, $formMargin, default:grow, 2dlu, left:pref",
"pref, 12dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val iconSize = 36
val targetIcon = if (SystemInfo.isWindows)
model.getFileIcon(targetFile, iconSize, iconSize)
else if (targetFile.isFolder) {
FlatSVGIcon(Icons.folder.name, iconSize, iconSize)
} else { } else {
sftpClient.remove(path.toString()) FlatSVGIcon(Icons.file.name, iconSize, iconSize)
} }
val sourceIcon = if (SystemInfo.isWindows)
model.getFileIcon(sourceFile, iconSize, iconSize)
else if (sourceFile.isFolder) {
FlatSVGIcon(Icons.folder.name, iconSize, iconSize)
} else {
FlatSVGIcon(Icons.file.name, iconSize, iconSize)
}
val sourceModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(sourceFile), "-")
val targetModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(targetFile), "-")
val actionsComBoBox = JComboBox<Action>()
actionsComBoBox.addItem(Action.Overwrite)
actionsComBoBox.addItem(Action.Append)
actionsComBoBox.addItem(Action.Skip)
actionsComBoBox.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 == Action.Overwrite) {
text = I18n.getString("termora.transport.sftp.already-exists.overwrite")
} else if (value == Action.Skip) {
text = I18n.getString("termora.transport.sftp.already-exists.skip")
} else if (value == Action.Append) {
text = I18n.getString("termora.transport.sftp.already-exists.append")
}
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
}
}
val applyAllCheckbox = JCheckBox(I18n.getString("termora.transport.sftp.already-exists.apply-all"))
val box = Box.createHorizontalBox()
box.add(actionsComBoBox)
box.add(Box.createHorizontalStrut(8))
box.add(applyAllCheckbox)
box.add(Box.createHorizontalGlue())
val ttBox = Box.createVerticalBox()
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message1")))
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message2")))
val warningIcon = FlatSVGIcon(
Icons.warningIntroduction.name,
iconSize,
iconSize
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
// tip
.add(JLabel(warningIcon)).xy(1, rows)
.add(ttBox).xyw(3, rows, 3).apply { rows += step }
// name
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.name")}:")).xy(1, rows)
.add(sourceFile.name.baseName).xyw(3, rows, 3).apply { rows += step }
// separator
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
// Destination
.add("${I18n.getString("termora.transport.sftp.already-exists.destination")}:").xy(1, rows)
.apply { rows += step }
// Folder
.add(JLabel(targetIcon)).xy(1, rows, "center, fill")
.add(targetModified).xyw(3, rows, 3).apply { rows += step }
// Source
.add("${I18n.getString("termora.transport.sftp.already-exists.source")}:").xy(1, rows)
.apply { rows += step }
// Folder
.add(JLabel(sourceIcon)).xy(1, rows, "center, fill")
.add(sourceModified).xyw(3, rows, 3).apply { rows += step }
// separator
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
// name
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.actions")}:")).xy(1, rows)
.add(box).xyw(3, rows, 3).apply { rows += step }
.build()
panel.putClientProperty("SKIP_requestFocusInWindow", true)
return AskTransfer(
option = OptionPane.showConfirmDialog(
owner, panel,
messageType = JOptionPane.PLAIN_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION,
title = sourceFile.name.baseName,
initialValue = JOptionPane.YES_OPTION,
) {
it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height)
it.setLocationRelativeTo(it.owner)
},
action = actionsComBoBox.selectedItem as Action,
applyAll = applyAllCheckbox.isSelected
)
} }
@@ -662,78 +861,82 @@ class FileSystemViewTable(
* 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止 * 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止
*/ */
private fun doTransfer( private fun doTransfer(
attrs: Array<FileSystemViewTableModel.Attr>, file: FileObject,
action: Action,
fromLocalSystem: Boolean, fromLocalSystem: Boolean,
targetWorkdir: Path? targetWorkdir: FileObject?,
target: FileSystemViewPanel? = null
) { ) {
if (attrs.isEmpty()) return
val sftpPanel = this.sftpPanel val sftpPanel = this.sftpPanel
val target = sftpPanel.getTarget(table) ?: return val target = (target ?: sftpPanel.getTarget(table)) ?: return
var isTerminate = false
/**
* 定义一个添加器,它可以自动的判断导入/拖拽行为
*/
val adder = object {
fun add(transport: Transport): Boolean {
if (action == Action.Append) {
transport.mode = StandardOpenOption.APPEND
} else {
transport.mode = StandardOpenOption.TRUNCATE_EXISTING
}
return addTransport(
sftpPanel,
if (fromLocalSystem) file.parent else null,
target,
targetWorkdir,
transport
)
}
}
if (file.isFile) {
adder.add(createTransport(file, false, 0).apply { scanned() })
return
}
val queue = ArrayDeque<Transport>() val queue = ArrayDeque<Transport>()
var isTerminate = false
for (attr in attrs) { try {
walk(file, object : FileVisitor<FileObject> {
/** override fun preVisitDirectory(dir: FileObject, attrs: BasicFileAttributes): FileVisitResult {
* 定义一个添加器,它可以自动的判断导入/拖拽行为 val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L)
*/ .apply { queue.addLast(this) }
val adder = object { if (adder.add(transport)) return FileVisitResult.CONTINUE
fun add(transport: Transport): Boolean { return FileVisitResult.TERMINATE.apply { isTerminate = true }
return addTransport(
sftpPanel,
if (fromLocalSystem) attr.path.parent else null,
target,
targetWorkdir,
transport
)
} }
}
if (attr.isFile) { override fun visitFile(file: FileObject, attrs: BasicFileAttributes): FileVisitResult {
if (!adder.add(createTransport(attr.path, false, 0).apply { scanned() })) { if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS
isTerminate = true val transport = createTransport(file, false, queue.last().id).apply { scanned() }
break if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true }
} }
continue
}
queue.clear() override fun visitFileFailed(file: FileObject, exc: IOException): FileVisitResult {
return FileVisitResult.CONTINUE
try {
walk(attr.path, object : FileVisitor<Path> {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L)
.apply { queue.addLast(this) }
if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true }
}
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS
val transport = createTransport(file, false, queue.last().id).apply { scanned() }
if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true }
}
override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult {
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
// 标记为扫描完毕
queue.removeLast().scanned()
return FileVisitResult.CONTINUE
}
})
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
} }
isTerminate = true
}
if (isTerminate) break override fun postVisitDirectory(dir: FileObject, exc: IOException?): FileVisitResult {
// 标记为扫描完毕
queue.removeLast().scanned()
return FileVisitResult.CONTINUE
}
})
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
SwingUtilities.invokeLater {
OptionPane.showMessageDialog(
owner,
message = ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
isTerminate = true
} }
if (isTerminate) { if (isTerminate) {
@@ -742,61 +945,32 @@ class FileSystemViewTable(
} }
} }
private fun walk(dir: Path, visitor: FileVisitor<Path>) {
if (fileSystem is SftpFileSystem) {
val attr = SftpPosixFileAttributes(dir, SftpClient.Attributes())
fileSystem.client.use { walkSFTP(dir, attr, visitor, it) }
} else {
Files.walkFileTree(dir, setOf(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, visitor)
}
}
private fun walkSFTP( private fun walk(
dir: Path, dir: FileObject,
attr: SftpPosixFileAttributes, visitor: FileVisitor<FileObject>,
visitor: FileVisitor<Path>,
client: SftpClient
): FileVisitResult { ): FileVisitResult {
return VFSWalker.walk(dir, visitor)
if (visitor.preVisitDirectory(dir, attr) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
val paths = client.readDir(dir.absolutePathString())
for (e in paths) {
if (e.filename == ".." || e.filename == ".") continue
if (e.attributes.isDirectory) {
if (walkSFTP(dir.resolve(e.filename), attr, visitor, client) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
} else {
val result = visitor.visitFile(dir.resolve(e.filename), attr)
if (result == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
} else if (result == FileVisitResult.SKIP_SUBTREE) {
break
}
}
}
if (visitor.postVisitDirectory(dir, null) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
return FileVisitResult.CONTINUE
} }
private fun addTransport( private fun addTransport(
sftpPanel: SFTPPanel, sftpPanel: SFTPPanel,
sourceWorkdir: Path?, sourceWorkdir: FileObject?,
target: FileSystemViewPanel, target: FileSystemViewPanel,
targetWorkdir: Path?, targetWorkdir: FileObject?,
transport: Transport transport: Transport
): Boolean { ): Boolean {
return sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport) return try {
sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
false
}
} }
private fun createTransport(source: Path, isDirectory: Boolean, parentId: Long): Transport { private fun createTransport(source: FileObject, isDirectory: Boolean, parentId: Long): Transport {
val transport = Transport( val transport = Transport(
source = source, source = source,
target = source, target = source,
@@ -804,7 +978,7 @@ class FileSystemViewTable(
isDirectory = isDirectory, isDirectory = isDirectory,
) )
if (transport.isFile) { if (transport.isFile) {
transport.filesize.addAndGet(source.fileSize()) transport.filesize.addAndGet(source.content.size)
} }
return transport return transport
} }
@@ -812,7 +986,7 @@ class FileSystemViewTable(
private class FileSystemTableRowTransferable( private class FileSystemTableRowTransferable(
val source: FileSystemViewTable, val source: FileSystemViewTable,
val attrs: List<FileSystemViewTableModel.Attr> val files: List<FileObject>
) : Transferable { ) : Transferable {
companion object { companion object {
val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable") val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable")
@@ -835,4 +1009,5 @@ class FileSystemViewTable(
} }
} }

View File

@@ -3,21 +3,24 @@ package app.termora.sftp
import app.termora.I18n import app.termora.I18n
import app.termora.NativeStringComparator import app.termora.NativeStringComparator
import app.termora.formatBytes import app.termora.formatBytes
import app.termora.vfs2.sftp.MySftpFileObject
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
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.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils import org.apache.commons.lang3.time.DateFormatUtils
import org.apache.sshd.sftp.client.fs.SftpPath import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.FileType
import org.apache.commons.vfs2.provider.local.LocalFileSystem
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.PosixFilePermissions import java.nio.file.attribute.PosixFilePermissions
import java.util.* import java.util.*
import javax.swing.Icon
import javax.swing.SwingUtilities
import javax.swing.table.DefaultTableModel import javax.swing.table.DefaultTableModel
import kotlin.io.path.*
class FileSystemViewTableModel : DefaultTableModel() { class FileSystemViewTableModel : DefaultTableModel() {
@@ -29,9 +32,10 @@ class FileSystemViewTableModel : DefaultTableModel() {
const val COLUMN_LAST_MODIFIED_TIME = 3 const val COLUMN_LAST_MODIFIED_TIME = 3
const val COLUMN_ATTRS = 4 const val COLUMN_ATTRS = 4
const val COLUMN_OWNER = 5 const val COLUMN_OWNER = 5
private val log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java) private val log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java)
private fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> { fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
val result = mutableSetOf<PosixFilePermission>() val result = mutableSetOf<PosixFilePermission>()
// 将十进制权限转换为八进制字符串 // 将十进制权限转换为八进制字符串
@@ -68,23 +72,69 @@ class FileSystemViewTableModel : DefaultTableModel() {
} }
} }
override fun getValueAt(row: Int, column: Int): Any { var hasParent: Boolean = false
val attr = getAttr(row) private set
return when (column) {
COLUMN_NAME -> attr.name
COLUMN_FILE_SIZE -> if (attr.isDirectory) StringUtils.EMPTY else formatBytes(attr.size)
COLUMN_TYPE -> attr.type
COLUMN_LAST_MODIFIED_TIME -> if (attr.modified > 0) DateFormatUtils.format(
Date(attr.modified),
"yyyy/MM/dd HH:mm"
) else StringUtils.EMPTY
COLUMN_ATTRS -> attr.permissions override fun getValueAt(row: Int, column: Int): Any {
COLUMN_OWNER -> attr.owner val file = getFileObject(row)
else -> StringUtils.EMPTY val isParentRow = hasParent && row == 0
try {
if (file.type == FileType.IMAGINARY) return StringUtils.EMPTY
return when (column) {
COLUMN_NAME -> if (isParentRow) ".." else file.name.baseName
COLUMN_FILE_SIZE -> if (isParentRow || file.isFolder) StringUtils.EMPTY else formatBytes(file.content.size)
COLUMN_TYPE -> if (isParentRow) StringUtils.EMPTY else getFileType(file)
COLUMN_LAST_MODIFIED_TIME -> if (isParentRow) StringUtils.EMPTY else getLastModifiedTime(file)
COLUMN_ATTRS -> if (isParentRow) StringUtils.EMPTY else getAttrs(file)
COLUMN_OWNER -> StringUtils.EMPTY
else -> StringUtils.EMPTY
}
} catch (e: Exception) {
if (file.fileSystem is LocalFileSystem) {
if (ExceptionUtils.getRootCause(e) is java.nio.file.NoSuchFileException) {
SwingUtilities.invokeLater { removeRow(row) }
return StringUtils.EMPTY
}
}
if (log.isWarnEnabled) {
log.warn(e.message, e)
}
return StringUtils.EMPTY
} }
} }
private fun getFileType(file: FileObject): String {
return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile).second
else if (file.isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
else NativeFileIcons.getIcon(file.name.baseName, file.isFile).second
}
fun getFileIcon(file: FileObject, width: Int = 16, height: Int = 16): Icon {
return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile, width, height).first
else NativeFileIcons.getIcon(file.name.baseName, file.isFile).first
}
fun getFileIcon(row: Int): Icon {
return getFileIcon(getFileObject(row))
}
fun getLastModifiedTime(file: FileObject): String {
if (file.content.lastModifiedTime < 1) return "-"
return DateFormatUtils.format(Date(file.content.lastModifiedTime), "yyyy/MM/dd HH:mm")
}
private fun getAttrs(file: FileObject): String {
if (file.fileSystem is LocalFileSystem) return StringUtils.EMPTY
return PosixFilePermissions.toString(getFilePermissions(file))
}
fun getFilePermissions(file: FileObject): Set<PosixFilePermission> {
val permissions = file.content.getAttribute(MySftpFileObject.POSIX_FILE_PERMISSIONS)
as Int? ?: return emptySet()
return fromSftpPermissions(permissions)
}
override fun getDataVector(): Vector<Vector<Any>> { override fun getDataVector(): Vector<Vector<Any>> {
return super.getDataVector() return super.getDataVector()
} }
@@ -100,14 +150,18 @@ class FileSystemViewTableModel : DefaultTableModel() {
} }
} }
fun getAttr(row: Int): Attr { fun getFileObject(row: Int): FileObject {
return super.getValueAt(row, 0) as Attr return super.getValueAt(row, 0) as FileObject
} }
fun getPathNames(): Set<String> { fun getPathNames(): Set<String> {
val names = linkedSetOf<String>() val names = linkedSetOf<String>()
for (i in 0 until rowCount) { for (i in 0 until rowCount) {
names.add(getAttr(i).name) if (hasParent && i == 0) {
names.add("..")
} else {
names.add(getFileObject(i).name.baseName)
}
} }
return names return names
} }
@@ -129,144 +183,40 @@ class FileSystemViewTableModel : DefaultTableModel() {
return false return false
} }
suspend fun reload(dir: Path, useFileHiding: Boolean) { suspend fun reload(dir: FileObject, useFileHiding: Boolean) {
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding) log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding)
} }
val attrs = mutableListOf<Attr>() val files = mutableListOf<FileObject>()
if (dir.parent != null) {
attrs.add(ParentAttr(dir.parent))
}
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Files.list(dir).use { paths -> dir.refresh()
for (path in paths) { for (file in dir.children) {
val attr = if (path is SftpPath) SftpAttr(path) else Attr(path) if (useFileHiding && file.isHidden) continue
if (useFileHiding && attr.isHidden) continue files.add(file)
attrs.add(attr)
}
} }
} }
attrs.sortWith(compareBy<Attr> { !it.isDirectory }.thenComparing { a, b -> files.sortWith(compareBy<FileObject> { !it.isFolder }.thenComparing { a, b ->
NativeStringComparator.getInstance().compare( NativeStringComparator.getInstance().compare(
a.name, a.name.baseName,
b.name b.name.baseName
) )
}) })
hasParent = dir.parent != null
if (hasParent) {
files.addFirst(dir.parent)
}
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
while (rowCount > 0) removeRow(0) while (rowCount > 0) removeRow(0)
attrs.forEach { addRow(arrayOf(it)) } files.forEach { addRow(arrayOf(it)) }
} }
}
open class Attr(val path: Path) {
/**
* 名称
*/
open val name by lazy { path.name }
/**
* 文件类型
*/
open val type by lazy {
if (path.fileSystem.isWindows()) NativeFileIcons.getIcon(name, isFile).second
else if (isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
else NativeFileIcons.getIcon(name, isFile).second
}
/**
* 大小
*/
open val size by lazy { path.fileSize() }
/**
* 修改时间
*/
open val modified by lazy { path.getLastModifiedTime().toMillis() }
/**
* 获取所有者
*/
open val owner by lazy { StringUtils.EMPTY }
/**
* 获取操作系统图标
*/
open val icon by lazy { NativeFileIcons.getIcon(name, isFile).first }
/**
* 是否是文件夹
*/
open val isDirectory by lazy { path.isDirectory() }
/**
* 是否是文件
*/
open val isFile by lazy { !isDirectory }
/**
* 是否是文件夹
*/
open val isHidden by lazy { path.isHidden() }
open val isSymbolicLink by lazy { path.isSymbolicLink() }
/**
* 获取权限
*/
open val permissions: String by lazy {
posixFilePermissions.let {
if (it.isNotEmpty()) PosixFilePermissions.toString(
it
) else StringUtils.EMPTY
}
}
open val posixFilePermissions by lazy { if (path.fileSystem.isUnix()) path.getPosixFilePermissions() else emptySet() }
open fun toFile(): File {
if (path.fileSystem.isSFTP()) {
return File(path.absolutePathString())
}
return path.toFile()
}
}
class ParentAttr(path: Path) : Attr(path) {
override val name by lazy { ".." }
override val isDirectory = true
override val isFile = false
override val isHidden = false
override val permissions = StringUtils.EMPTY
override val modified = 0L
override val type = StringUtils.EMPTY
override val icon by lazy { NativeFileIcons.getFolderIcon() }
override val isSymbolicLink = false
}
class SftpAttr(sftpPath: SftpPath) : Attr(sftpPath) {
private val attributes = sftpPath.attributes
override val isSymbolicLink = attributes.isSymbolicLink
override val isDirectory = if (isSymbolicLink) sftpPath.isDirectory() else attributes.isDirectory
override val isHidden = name.startsWith(".")
override val size = attributes.size
override val owner: String = StringUtils.defaultString(attributes.owner)
override val modified = attributes.modifyTime.toMillis()
override val permissions: String = PosixFilePermissions.toString(fromSftpPermissions(attributes.permissions))
override val posixFilePermissions = fromSftpPermissions(attributes.permissions)
override fun toFile(): File {
return File(path.absolutePathString())
}
} }

View File

@@ -7,6 +7,7 @@ import com.formdev.flatlaf.icons.FlatTreeLeafIcon
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import org.eclipse.jgit.util.LRUMap import org.eclipse.jgit.util.LRUMap
import java.util.* import java.util.*
@@ -24,7 +25,7 @@ object NativeFileIcons {
init { init {
if (SystemUtils.IS_OS_UNIX) { if (SystemUtils.IS_OS_UNIX) {
cache[SystemUtils.USER_HOME] = Pair(FlatTreeClosedIcon(), I18n.getString("termora.folder")) cache[SystemUtils.USER_HOME] = Pair(folderIcon, I18n.getString("termora.folder"))
} }
} }
@@ -36,35 +37,30 @@ object NativeFileIcons {
return getIcon(filename, true).first return getIcon(filename, true).first
} }
fun getIcon(filename: String, isFile: Boolean = true): Pair<Icon, String> { fun getIcon(filename: String, isFile: Boolean = true, width: Int = 16, height: Int = 16): Pair<Icon, String> {
if (isFile) { val key = if (isFile) FilenameUtils.getExtension(filename) + "." + width + "@" + height
val extension = FilenameUtils.getExtension(filename) else SystemUtils.USER_HOME + "." + width + "@" + height
if (cache.containsKey(extension)) {
return cache.getValue(extension) if (cache.containsKey(key)) {
} return cache.getValue(key)
} else {
if (cache.containsKey(SystemUtils.USER_HOME)) {
return cache.getValue(SystemUtils.USER_HOME)
}
} }
val isDirectory = !isFile val isDirectory = !isFile
if (SystemInfo.isWindows) { if (SystemInfo.isWindows) {
val file = if (isDirectory) FileUtils.getFile(SystemUtils.USER_HOME) else val file = if (isDirectory) FileUtils.getFile(SystemUtils.USER_HOME) else
FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}.${filename}") FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}.${filename}")
if (isFile && !file.exists()) { if (isFile && !file.exists()) {
file.createNewFile() file.createNewFile()
} }
val icon = getFileSystemView().getSystemIcon(file, 16, 16)
val icon = getFileSystemView().getSystemIcon(file, width, height) ?: if (isFile) fileIcon else folderIcon
val description = getFileSystemView().getSystemTypeDescription(file) val description = getFileSystemView().getSystemTypeDescription(file)
?: StringUtils.defaultString(file.extension)
val pair = icon to description val pair = icon to description
if (isDirectory) { cache[key] = pair
cache[SystemUtils.USER_HOME] = pair
} else {
cache[FilenameUtils.getExtension(file.name)] = pair
}
if (isFile) FileUtils.deleteQuietly(file) if (isFile) FileUtils.deleteQuietly(file)

View File

@@ -25,6 +25,7 @@ class PosixFilePermissionDialog(
private val otherRead = JCheckBox(I18n.getString("termora.transport.permissions.read")) private val otherRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
private val otherWrite = JCheckBox(I18n.getString("termora.transport.permissions.write")) private val otherWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
private val otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute")) private val otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
private val includeSubFolder = JCheckBox(I18n.getString("termora.transport.permissions.include-subfolder"))
private var isCancelled = false private var isCancelled = false
@@ -60,13 +61,14 @@ class PosixFilePermissionDialog(
otherRead.isFocusable = false otherRead.isFocusable = false
otherWrite.isFocusable = false otherWrite.isFocusable = false
otherExecute.isFocusable = false otherExecute.isFocusable = false
includeSubFolder.isFocusable = false
} }
override fun createCenterPanel(): JComponent { override fun createCenterPanel(): JComponent {
val formMargin = "7dlu" val formMargin = "7dlu"
val layout = FormLayout( val layout = FormLayout(
"default:grow, $formMargin, default:grow, $formMargin, default:grow", "default:grow, $formMargin, default:grow, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref" "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
) )
val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin") val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin")
@@ -95,6 +97,8 @@ class PosixFilePermissionDialog(
otherBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.others")) otherBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.others"))
builder.add(otherBox).xy(5, 3) builder.add(otherBox).xy(5, 3)
builder.add(includeSubFolder).xyw(1, 5, 5)
return builder.build() return builder.build()
} }
@@ -103,6 +107,10 @@ class PosixFilePermissionDialog(
super.doCancelAction() super.doCancelAction()
} }
fun isIncludeSubdirectories(): Boolean {
return includeSubFolder.isSelected
}
/** /**
* @return 返回空表示取消了 * @return 返回空表示取消了
*/ */

View File

@@ -56,6 +56,16 @@ class SFTPAction : AnAction("SFTP", Icons.folder) {
} }
val host = hostManager.getHost(hostId) ?: return val host = hostManager.getHost(hostId) ?: return
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getComponentAt(i)
if (c is SFTPFileSystemViewPanel) {
if (c.state == SFTPFileSystemViewPanel.State.Initialized) {
c.selectHost(host)
return
}
}
}
tabbed.addSFTPFileSystemViewPanelTab(host) tabbed.addSFTPFileSystemViewPanelTab(host)
} }

View File

@@ -3,6 +3,7 @@ package app.termora.sftp
import app.termora.* import app.termora.*
import app.termora.actions.DataProvider import app.termora.actions.DataProvider
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
@@ -11,10 +12,11 @@ import kotlinx.coroutines.swing.Swing
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.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.vfs2.FileSystem
import org.apache.commons.vfs2.FileSystemOptions
import org.apache.commons.vfs2.VFS
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 org.apache.sshd.sftp.client.SftpClientFactory
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.jdesktop.swingx.JXBusyLabel import org.jdesktop.swingx.JXBusyLabel
import org.jdesktop.swingx.JXHyperlink import org.jdesktop.swingx.JXHyperlink
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -25,6 +27,8 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.* import javax.swing.*
import javax.swing.event.TreeExpansionEvent
import javax.swing.event.TreeExpansionListener
class SFTPFileSystemViewPanel( class SFTPFileSystemViewPanel(
var host: Host? = null, var host: Host? = null,
@@ -33,31 +37,30 @@ class SFTPFileSystemViewPanel(
companion object { companion object {
private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java) private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java)
}
private enum class State { enum class State {
Initialized, Initialized,
Connecting, Connecting,
Connected, Connected,
ConnectFailed, ConnectFailed,
}
} }
@Volatile @Volatile
private var state = State.Initialized var state = State.Initialized
private set
private val cardLayout = CardLayout() private val cardLayout = CardLayout()
private val cardPanel = JPanel(cardLayout) private val cardPanel = JPanel(cardLayout)
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val connectingPanel = ConnectingPanel() private val connectingPanel = ConnectingPanel()
private val selectHostPanel = SelectHostPanel() private val selectHostPanel = SelectHostPanel()
private val connectFailedPanel = ConnectFailedPanel() private val connectFailedPanel = ConnectFailedPanel()
private val isDisposed = AtomicBoolean(false) private val isDisposed = AtomicBoolean(false)
private val that = this private val that = this
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val properties get() = Database.getDatabase().properties private val properties get() = Database.getDatabase().properties
private var client: SshClient? = null private var client: SshClient? = null
private var session: ClientSession? = null private var session: ClientSession? = null
private var fileSystem: SftpFileSystem? = null
private var fileSystemPanel: FileSystemViewPanel? = null private var fileSystemPanel: FileSystemViewPanel? = null
@@ -111,11 +114,17 @@ class SFTPFileSystemViewPanel(
closeIO() closeIO()
val mySftpFileSystem: FileSystem
try { try {
val (client, host) = SshClients.openClient(thisHost, SwingUtilities.getWindowAncestor(that)) val owner = SwingUtilities.getWindowAncestor(that)
this.client = client val client = SshClients.openClient(thisHost, owner).apply { client = this }
val session = SshClients.openSession(host, client).apply { session = this } val session = SshClients.openSession(thisHost, client).apply { session = this }
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
val options = FileSystemOptions()
MySftpFileSystemConfigBuilder.getInstance()
.setClientSession(options, session)
mySftpFileSystem = VFS.getManager().resolveFile("sftp:///", options).fileSystem
session.addCloseFutureListener { onClose() } session.addCloseFutureListener { onClose() }
} catch (e: Exception) { } catch (e: Exception) {
closeIO() closeIO()
@@ -126,11 +135,10 @@ class SFTPFileSystemViewPanel(
throw IllegalStateException("Closed") throw IllegalStateException("Closed")
} }
val fileSystem = this.fileSystem ?: return
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
state = State.Connected state = State.Connected
val fileSystemPanel = FileSystemViewPanel(thisHost, fileSystem, transportManager, coroutineScope) val fileSystemPanel = FileSystemViewPanel(thisHost, mySftpFileSystem, transportManager, coroutineScope)
cardPanel.add(fileSystemPanel, State.Connected.name) cardPanel.add(fileSystemPanel, State.Connected.name)
cardLayout.show(cardPanel, State.Connected.name) cardLayout.show(cardPanel, State.Connected.name)
that.fileSystemPanel = fileSystemPanel that.fileSystemPanel = fileSystemPanel
@@ -157,7 +165,6 @@ class SFTPFileSystemViewPanel(
fileSystemPanel?.let { Disposer.dispose(it) } fileSystemPanel?.let { Disposer.dispose(it) }
fileSystemPanel = null fileSystemPanel = null
runCatching { IOUtils.closeQuietly(fileSystem) }
runCatching { IOUtils.closeQuietly(session) } runCatching { IOUtils.closeQuietly(session) }
runCatching { IOUtils.closeQuietly(client) } runCatching { IOUtils.closeQuietly(client) }
@@ -279,12 +286,20 @@ class SFTPFileSystemViewPanel(
val node = tree.getLastSelectedPathNode() ?: return val node = tree.getLastSelectedPathNode() ?: return
if (node.isFolder) return if (node.isFolder) return
val host = node.data as Host val host = node.data as Host
that.setTabTitle(host.name) selectHost(host)
that.host = host
that.connect()
} }
} }
}) })
tree.addTreeExpansionListener(object : TreeExpansionListener {
override fun treeExpanded(event: TreeExpansionEvent) {
properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree))
}
override fun treeCollapsed(event: TreeExpansionEvent) {
properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree))
}
})
} }
override fun dispose() { override fun dispose() {
@@ -301,6 +316,12 @@ class SFTPFileSystemViewPanel(
} }
} }
fun selectHost(host: Host) {
that.setTabTitle(host.name)
that.host = host
that.connect()
}
private fun setTabTitle(title: String) { private fun setTabTitle(title: String) {
val tabbed = SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, that) val tabbed = SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, that)
if (tabbed is JTabbedPane) { if (tabbed is JTabbedPane) {

View File

@@ -0,0 +1,18 @@
package app.termora.sftp
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.provider.local.LocalFile
import java.io.File
fun FileObject.absolutePathString(): String {
var text = name.path
if (this is LocalFile && SystemUtils.IS_OS_WINDOWS) {
text = this.name.toString()
text = StringUtils.removeStart(text, "file:///")
text = StringUtils.replace(text, "/", File.separator)
}
return text
}

View File

@@ -5,31 +5,34 @@ import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviderSupport
import app.termora.findeverywhere.FindEverywhereProvider import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import okio.withLock import okio.withLock
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem import org.apache.commons.vfs2.FileObject
import org.apache.commons.vfs2.VFS
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.event.ComponentAdapter import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent import java.awt.event.ComponentEvent
import java.nio.file.FileSystem
import java.nio.file.FileSystems import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.* import javax.swing.*
import kotlin.io.path.absolutePathString
fun FileSystem.isSFTP() = this is SftpFileSystem
fun FileSystem.isUnix() = isLocal() && SystemUtils.IS_OS_UNIX
fun FileSystem.isWindows() = isLocal() && SystemUtils.IS_OS_WINDOWS
fun FileSystem.isLocal() = StringUtils.startsWithAny(javaClass.name, "java", "sun")
class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable { class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val transportTable = TransportTable() private val transportTable = TransportTable()
private val transportManager get() = transportTable.model private val transportManager get() = transportTable.model
private val dataProviderSupport = DataProviderSupport() private val dataProviderSupport = DataProviderSupport()
private val leftComponent = SFTPTabbed(transportManager) private val leftComponent = SFTPTabbed(transportManager)
private val rightComponent = SFTPTabbed(transportManager) private val rightComponent = SFTPTabbed(transportManager)
private val localHost = Host(
id = "local",
name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local,
)
init { init {
initViews() initViews()
@@ -87,11 +90,10 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
leftComponent.addTab( leftComponent.addTab(
I18n.getString("termora.transport.local"), I18n.getString("termora.transport.local"),
FileSystemViewPanel( FileSystemViewPanel(
Host( localHost,
id = "local", VFS.getManager().resolveFile("file:///${SystemUtils.USER_HOME}").fileSystem,
name = I18n.getString("termora.transport.local"), transportManager,
protocol = Protocol.Local, coroutineScope
), FileSystems.getDefault(), transportManager
) )
) )
leftComponent.setTabClosable(0, false) leftComponent.setTabClosable(0, false)
@@ -125,7 +127,7 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
return return
} }
val fs = c.fileSystem val fs = c.getFileSystem()
val root = transportManager.root val root = transportManager.root
transportManager.lock.withLock { transportManager.lock.withLock {
@@ -165,9 +167,9 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
*/ */
fun addTransport( fun addTransport(
source: JComponent, source: JComponent,
sourceWorkdir: Path?, sourceWorkdir: FileObject?,
target: FileSystemViewPanel, target: FileSystemViewPanel,
targetWorkdir: Path?, targetWorkdir: FileObject?,
transport: Transport transport: Transport
): Boolean { ): Boolean {
@@ -175,15 +177,12 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
as? FileSystemViewPanel ?: return false as? FileSystemViewPanel ?: return false
val targetPanel = target as? FileSystemViewPanel ?: return false val targetPanel = target as? FileSystemViewPanel ?: return false
if (sourcePanel.isDisposed || targetPanel.isDisposed) return false if (sourcePanel.isDisposed || targetPanel.isDisposed) return false
val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir()).absolutePathString() val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir())
val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir()).absolutePathString() val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir())
val targetFileSystem = targetPanel.fileSystem val sourcePath = transport.source
val sourcePath = transport.source.absolutePathString()
transport.target = targetFileSystem.getPath( val relativeName = mySourceWorkdir.name.getRelativeName(sourcePath.name)
myTargetWorkdir, transport.target = myTargetWorkdir.resolveFile(relativeName)
StringUtils.removeStart(sourcePath, mySourceWorkdir)
)
return transportManager.addTransport(transport) return transportManager.addTransport(transport)
@@ -212,4 +211,8 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
return dataProviderSupport.getData(dataKey) return dataProviderSupport.getData(dataKey)
} }
override fun dispose() {
coroutineScope.cancel()
}
} }

View File

@@ -5,7 +5,6 @@ import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
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
import java.awt.Point
import java.awt.event.MouseAdapter import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -13,8 +12,6 @@ import javax.swing.JButton
import javax.swing.JToolBar import javax.swing.JToolBar
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import javax.swing.UIManager import javax.swing.UIManager
import kotlin.io.path.absolutePathString
import kotlin.math.max
@Suppress("DuplicatedCode") @Suppress("DuplicatedCode")
class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPane(), Disposable { class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPane(), Disposable {
@@ -44,23 +41,20 @@ class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPan
private fun initEvents() { private fun initEvents() {
addBtn.addActionListener(object : AnAction() { addBtn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) { override fun actionPerformed(evt: AnActionEvent) {
val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(tabbed)) for (i in 0 until tabCount) {
dialog.location = Point( val c = getComponentAt(i)
max(0, addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2), if (c !is SFTPFileSystemViewPanel) continue
addBtn.locationOnScreen.y + max(tabHeight, addBtn.height) if (c.state != SFTPFileSystemViewPanel.State.Initialized) continue
) selectedIndex = i
dialog.setFilter { it.host.protocol == Protocol.SSH } return
dialog.setTreeName("SFTPTabbed.Tree")
dialog.allowMulti = true
dialog.isVisible = true
val hosts = dialog.hosts
if (hosts.isEmpty()) return
for (host in hosts) {
addSFTPFileSystemViewPanelTab(host)
} }
// 添加一个新的
addTab(
I18n.getString("termora.transport.sftp.select-host"),
SFTPFileSystemViewPanel(transportManager = transportManager)
)
selectedIndex = tabCount - 1
} }
}) })

View File

@@ -6,18 +6,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.net.io.Util import org.apache.commons.net.io.Util
import org.apache.commons.vfs2.FileObject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.file.Files import java.nio.file.StandardOpenOption
import java.nio.file.Path
import java.nio.file.attribute.BasicFileAttributeView
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.name
enum class TransportStatus { enum class TransportStatus {
Ready, Ready,
@@ -48,12 +43,19 @@ class Transport(
/** /**
* 源 * 源
*/ */
val source: Path, val source: FileObject,
/** /**
* 目标 * 目标
*/ */
var target: Path, var target: FileObject,
/**
* 仅对文件生效,切只有两个选项
*
* 1. [StandardOpenOption.APPEND]
* 2. [StandardOpenOption.TRUNCATE_EXISTING]
*/
var mode: StandardOpenOption = StandardOpenOption.TRUNCATE_EXISTING
) { ) {
companion object { companion object {
@@ -154,7 +156,7 @@ class Transport(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
if (!target.exists()) { if (!target.exists()) {
target.createDirectories() target.createFolder()
} }
} catch (e: FileAlreadyExistsException) { } catch (e: FileAlreadyExistsException) {
if (log.isWarnEnabled) { if (log.isWarnEnabled) {
@@ -169,8 +171,8 @@ class Transport(
} }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val input = Files.newInputStream(source) val input = source.content.inputStream
val output = Files.newOutputStream(target) val output = target.content.getOutputStream(mode == StandardOpenOption.APPEND)
try { try {
@@ -209,8 +211,7 @@ class Transport(
private fun preserveModificationTime() { private fun preserveModificationTime() {
// 设置修改时间 // 设置修改时间
if (isPreserveModificationTime) { if (isPreserveModificationTime) {
Files.getFileAttributeView(target, BasicFileAttributeView::class.java) target.content.lastModifiedTime = source.content.lastModifiedTime
.setTimes(source.getLastModifiedTime(), source.getLastModifiedTime(), null)
} }
} }

View File

@@ -12,8 +12,10 @@ import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import org.jdesktop.swingx.treetable.DefaultTreeTableModel import org.jdesktop.swingx.treetable.DefaultTreeTableModel
import org.jdesktop.swingx.treetable.MutableTreeTableNode import org.jdesktop.swingx.treetable.MutableTreeTableNode
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.util.*
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import kotlin.collections.ArrayDeque
import kotlin.io.path.name import kotlin.io.path.name
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
@@ -27,7 +29,7 @@ class TransportTableModel(private val coroutineScope: CoroutineScope) :
val lock = ReentrantLock() val lock = ReentrantLock()
private val transports = linkedMapOf<Long, TransportTreeTableNode>() private val transports = Collections.synchronizedMap(linkedMapOf<Long, TransportTreeTableNode>())
private val reporter = SpeedReporter(coroutineScope) private val reporter = SpeedReporter(coroutineScope)
private var listeners = emptyArray<TransportListener>() private var listeners = emptyArray<TransportListener>()
private val activeTransports = linkedMapOf<Long, Job>() private val activeTransports = linkedMapOf<Long, Job>()
@@ -104,8 +106,15 @@ class TransportTableModel(private val coroutineScope: CoroutineScope) :
transports[transport.id] = newNode transports[transport.id] = newNode
if ((transports.containsKey(parentId) || p == root) && transports.containsKey(transport.id)) { if ((transports.containsKey(parentId) || p == root) && transports.containsKey(transport.id)) {
// 同步加入节点 // 主线程加入节点
SwingUtilities.invokeLater { insertNodeInto(newNode, p, p.childCount) } SwingUtilities.invokeLater {
// 因为是异步的,父节点此时可能已经被移除了
if (p == root || transports.containsKey(parentId)) {
insertNodeInto(newNode, p, p.childCount)
} else {
removeTransport(transport.id)
}
}
} }
return@withLock true return@withLock true

View File

@@ -3,12 +3,11 @@ package app.termora.sftp
import app.termora.I18n import app.termora.I18n
import app.termora.formatBytes import app.termora.formatBytes
import app.termora.formatSeconds import app.termora.formatSeconds
import org.apache.commons.io.file.PathUtils import app.termora.vfs2.sftp.MySftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpFileSystem import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder
import org.apache.commons.vfs2.FileObject
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import java.nio.file.Path
import kotlin.io.path.absolutePathString
class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) { class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) {
val transport get() = userObject as Transport val transport get() = userObject as Transport
@@ -20,7 +19,7 @@ class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode
(transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0 (transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0
return when (column) { return when (column) {
TransportTableModel.COLUMN_NAME -> PathUtils.getFileNameString(transport.source) TransportTableModel.COLUMN_NAME -> transport.source.name.baseName
TransportTableModel.COLUMN_STATUS -> formatStatus(transport) TransportTableModel.COLUMN_STATUS -> formatStatus(transport)
TransportTableModel.COLUMN_SIZE -> size() TransportTableModel.COLUMN_SIZE -> size()
TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-" TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-"
@@ -31,12 +30,14 @@ class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode
} }
} }
private fun formatPath(path: Path): String { private fun formatPath(file: FileObject): String {
if (path.fileSystem.isSFTP()) { if (file.fileSystem is MySftpFileSystem) {
val hostname = ((path.fileSystem as SftpFileSystem).session as JGitClientSession).hostConfigEntry.hostName val session = MySftpFileSystemConfigBuilder.getInstance()
return hostname + ":" + path.absolutePathString() .getClientSession(file.fileSystem.fileSystemOptions) as JGitClientSession
val hostname = session.hostConfigEntry.hostName
return hostname + ":" + file.name.path
} }
return path.toUri().scheme + ":" + path.absolutePathString() return file.name.toString()
} }
private fun formatStatus(transport: Transport): String { private fun formatStatus(transport: Transport): String {

View File

@@ -6,7 +6,10 @@ import app.termora.Icons
import app.termora.actions.AnAction import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.terminal.ControlCharacters import app.termora.terminal.ControlCharacters
import app.termora.terminal.Null
import app.termora.terminal.panel.TerminalWriter import app.termora.terminal.panel.TerminalWriter
import org.apache.commons.lang3.StringUtils
import org.apache.commons.text.StringEscapeUtils
class SnippetAction private constructor() : AnAction(I18n.getString("termora.snippet.title"), Icons.codeSpan) { class SnippetAction private constructor() : AnAction(I18n.getString("termora.snippet.title"), Icons.codeSpan) {
companion object { companion object {
@@ -15,6 +18,16 @@ class SnippetAction private constructor() : AnAction(I18n.getString("termora.sni
} }
const val SNIPPET = "SnippetAction" const val SNIPPET = "SnippetAction"
// \r \n \t \a \e \b
private val SpecialChars = mutableMapOf(
'r' to '\r',
'n' to '\n',
't' to '\t',
'a' to ControlCharacters.BEL,
'e' to ControlCharacters.ESC,
'b' to ControlCharacters.BS
)
} }
override fun actionPerformed(evt: AnActionEvent) { override fun actionPerformed(evt: AnActionEvent) {
@@ -24,19 +37,39 @@ class SnippetAction private constructor() : AnAction(I18n.getString("termora.sni
fun runSnippet(snippet: Snippet, writer: TerminalWriter) { fun runSnippet(snippet: Snippet, writer: TerminalWriter) {
if (snippet.type != SnippetType.Snippet) return if (snippet.type != SnippetType.Snippet) return
val map = mapOf( writer.write(TerminalWriter.WriteRequest.fromBytes(unescape(snippet.snippet).toByteArray(writer.getCharset())))
"\\r" to ControlCharacters.CR, }
"\\n" to ControlCharacters.LF,
"\\t" to ControlCharacters.TAB,
"\\a" to ControlCharacters.BEL,
"\\e" to ControlCharacters.ESC,
"\\b" to ControlCharacters.BS,
)
var text = snippet.snippet private fun unescape(text: String): String {
for (e in map.entries) { val chars = text.toCharArray()
text = text.replace(e.key, e.value.toString()) val sb = StringBuilder()
for (i in chars.indices) {
val c = chars[i]
// 不是特殊字符不处理
if (SpecialChars.containsKey(c).not()) {
sb.append(c)
continue
}
// 特殊字符前面不是 `\` 不处理
if (chars.getOrNull(i - 1) != '\\') {
sb.append(c)
continue
}
// 如果构成的字符串是:\\r 就会生成 \r 字符串并非转译成CR
if (chars.getOrNull(i - 2) == '\\') {
sb.deleteCharAt(sb.length - 1)
sb.append(c)
continue
}
// 命中条件之后,那么 sb 最后一个字符肯定是 \
sb.deleteCharAt(sb.length - 1)
sb.append(SpecialChars.getValue(c))
} }
writer.write(TerminalWriter.WriteRequest.fromBytes(text.toByteArray(writer.getCharset())))
return sb.toString()
} }
} }

View File

@@ -2,6 +2,7 @@ package app.termora.snippet
import app.termora.ApplicationScope import app.termora.ApplicationScope
import app.termora.Database import app.termora.Database
import app.termora.DeleteDataManager
import app.termora.assertEventDispatchThread import app.termora.assertEventDispatchThread
@@ -20,14 +21,20 @@ class SnippetManager private constructor() {
*/ */
fun addSnippet(snippet: Snippet) { fun addSnippet(snippet: Snippet) {
assertEventDispatchThread() assertEventDispatchThread()
database.addSnippet(snippet)
if (snippet.deleted) { if (snippet.deleted) {
snippets.entries.removeIf { it.value.id == snippet.id || it.value.parentId == snippet.id } removeSnippet(snippet.id)
} else { } else {
database.addSnippet(snippet)
snippets[snippet.id] = snippet snippets[snippet.id] = snippet
} }
} }
fun removeSnippet(id: String) {
snippets.entries.removeIf { it.value.id == id || it.value.parentId == id }
database.removeSnippet(id)
DeleteDataManager.getInstance().removeSnippet(id)
}
/** /**
* 第一次调用从数据库中获取,后续从缓存中获取 * 第一次调用从数据库中获取,后续从缓存中获取
*/ */

View File

@@ -41,8 +41,10 @@ class SnippetPanel : JPanel(BorderLayout()), Disposable {
private fun initViews() { private fun initViews() {
val splitPane = JSplitPane() val splitPane = JSplitPane()
splitPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor) splitPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
val scrollPane = JScrollPane(snippetTree)
scrollPane.border = BorderFactory.createEmptyBorder()
leftPanel.add(snippetTree, BorderLayout.CENTER) leftPanel.add(scrollPane, BorderLayout.CENTER)
leftPanel.border = BorderFactory.createCompoundBorder( leftPanel.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor), BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(4, 4, 4, 4) BorderFactory.createEmptyBorder(4, 4, 4, 4)
@@ -51,6 +53,7 @@ class SnippetPanel : JPanel(BorderLayout()), Disposable {
properties.getString("SnippetPanel.LeftPanel.width", "180").toIntOrNull() ?: 180, properties.getString("SnippetPanel.LeftPanel.width", "180").toIntOrNull() ?: 180,
-1 -1
) )
leftPanel.minimumSize = Dimension(leftPanel.preferredSize.width, leftPanel.preferredSize.height)
rightPanel.border = BorderFactory.createCompoundBorder( rightPanel.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor), BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor),

View File

@@ -1,5 +1,6 @@
package app.termora.snippet package app.termora.snippet
import app.termora.I18n
import app.termora.SimpleTreeModel import app.termora.SimpleTreeModel
import javax.swing.tree.MutableTreeNode import javax.swing.tree.MutableTreeNode
import javax.swing.tree.TreeNode import javax.swing.tree.TreeNode
@@ -8,7 +9,7 @@ class SnippetTreeModel : SimpleTreeModel<Snippet>(
SnippetTreeNode( SnippetTreeNode(
Snippet( Snippet(
id = "0", id = "0",
name = "全部片段", name = I18n.getString("termora.snippet.title"),
type = SnippetType.Folder type = SnippetType.Folder
) )
) )

View File

@@ -1,6 +1,7 @@
package app.termora.sync package app.termora.sync
import app.termora.Application.ohMyJson import app.termora.Application.ohMyJson
import app.termora.DeletedData
import app.termora.ResponseException import app.termora.ResponseException
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@@ -26,46 +27,51 @@ abstract class GitSyncer : SafetySyncer() {
} }
val gistResponse = parsePullResponse(response, config) val gistResponse = parsePullResponse(response, config)
val deletedData = mutableListOf<DeletedData>()
// DeletedData
gistResponse.gists.findLast { it.filename == "DeletedData" }
?.let { deletedData.addAll(decodeDeletedData(it.content, config)) }
// decode hosts // decode hosts
if (config.ranges.contains(SyncRange.Hosts)) { if (config.ranges.contains(SyncRange.Hosts)) {
gistResponse.gists.findLast { it.filename == "Hosts" }?.let { gistResponse.gists.findLast { it.filename == "Hosts" }?.let {
decodeHosts(it.content, config) decodeHosts(it.content, deletedData.filter { e -> e.type == "Host" }, config)
} }
} }
// decode keys // decode keys
if (config.ranges.contains(SyncRange.KeyPairs)) { if (config.ranges.contains(SyncRange.KeyPairs)) {
gistResponse.gists.findLast { it.filename == "KeyPairs" }?.let { gistResponse.gists.findLast { it.filename == "KeyPairs" }?.let {
decodeKeys(it.content, config) decodeKeys(it.content, deletedData.filter { e -> e.type == "KeyPair" }, config)
} }
} }
// decode keyword highlights // decode keyword highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) { if (config.ranges.contains(SyncRange.KeywordHighlights)) {
gistResponse.gists.findLast { it.filename == "KeywordHighlights" }?.let { gistResponse.gists.findLast { it.filename == "KeywordHighlights" }?.let {
decodeKeywordHighlights(it.content, config) decodeKeywordHighlights(it.content, deletedData.filter { e -> e.type == "KeywordHighlight" }, config)
} }
} }
// decode macros // decode macros
if (config.ranges.contains(SyncRange.Macros)) { if (config.ranges.contains(SyncRange.Macros)) {
gistResponse.gists.findLast { it.filename == "Macros" }?.let { gistResponse.gists.findLast { it.filename == "Macros" }?.let {
decodeMacros(it.content, config) decodeMacros(it.content, deletedData.filter { e -> e.type == "Macro" }, config)
} }
} }
// decode keymaps // decode keymaps
if (config.ranges.contains(SyncRange.Macros)) { if (config.ranges.contains(SyncRange.Macros)) {
gistResponse.gists.findLast { it.filename == "Keymaps" }?.let { gistResponse.gists.findLast { it.filename == "Keymaps" }?.let {
decodeKeymaps(it.content, config) decodeKeymaps(it.content, deletedData.filter { e -> e.type == "Keymap" }, config)
} }
} }
// decode Snippets // decode Snippets
if (config.ranges.contains(SyncRange.Snippets)) { if (config.ranges.contains(SyncRange.Snippets)) {
gistResponse.gists.findLast { it.filename == "Snippets" }?.let { gistResponse.gists.findLast { it.filename == "Snippets" }?.let {
decodeSnippets(it.content, config) decodeSnippets(it.content, deletedData.filter { e -> e.type == "Snippet" }, config)
} }
} }
@@ -79,6 +85,11 @@ abstract class GitSyncer : SafetySyncer() {
override fun push(config: SyncConfig): GistResponse { override fun push(config: SyncConfig): GistResponse {
if (log.isInfoEnabled) {
log.info("Type: ${config.type} , Gist: ${config.gistId} Push...")
}
val gistFiles = mutableListOf<GistFile>() val gistFiles = mutableListOf<GistFile>()
// aes key // aes key
val key = ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16) val key = ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
@@ -142,9 +153,21 @@ abstract class GitSyncer : SafetySyncer() {
throw IllegalArgumentException("No gist files found") throw IllegalArgumentException("No gist files found")
} }
val deletedData = encodeDeletedData(config)
if (log.isDebugEnabled) {
log.debug("Push DeletedData: {}", deletedData)
}
gistFiles.add(GistFile("DeletedData", deletedData))
val request = newPushRequestBuilder(gistFiles, config).build() val request = newPushRequestBuilder(gistFiles, config).build()
return parsePushResponse(httpClient.newCall(request).execute(), config) try {
return parsePushResponse(httpClient.newCall(request).execute(), config)
} finally {
if (log.isInfoEnabled) {
log.info("Type: ${config.type} , Gist: ${config.gistId} Pushed")
}
}
} }
open fun parsePullResponse(response: Response, config: SyncConfig): GistResponse { open fun parsePullResponse(response: Response, config: SyncConfig): GistResponse {

View File

@@ -34,8 +34,9 @@ abstract class SafetySyncer : Syncer {
protected val macroManager get() = MacroManager.getInstance() protected val macroManager get() = MacroManager.getInstance()
protected val keymapManager get() = KeymapManager.getInstance() protected val keymapManager get() = KeymapManager.getInstance()
protected val snippetManager get() = SnippetManager.getInstance() protected val snippetManager get() = SnippetManager.getInstance()
protected val deleteDataManager get() = DeleteDataManager.getInstance()
protected fun decodeHosts(text: String, config: SyncConfig) { protected fun decodeHosts(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key // aes key
val key = getKey(config) val key = getKey(config)
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text) val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
@@ -44,9 +45,9 @@ abstract class SafetySyncer : Syncer {
for (encryptedHost in encryptedHosts) { for (encryptedHost in encryptedHosts) {
val oldHost = hosts[encryptedHost.id] val oldHost = hosts[encryptedHost.id]
// 如果一样,则无需配置 // 如果本地的修改时间大于云端时间,那么跳过
if (oldHost != null) { if (oldHost != null) {
if (oldHost.updateDate == encryptedHost.updateDate) { if (oldHost.updateDate >= encryptedHost.updateDate) {
continue continue
} }
} }
@@ -83,7 +84,6 @@ abstract class SafetySyncer : Syncer {
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(), creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
createDate = encryptedHost.createDate, createDate = encryptedHost.createDate,
updateDate = encryptedHost.updateDate, updateDate = encryptedHost.updateDate,
deleted = encryptedHost.deleted
) )
SwingUtilities.invokeLater { hostManager.addHost(host) } SwingUtilities.invokeLater { hostManager.addHost(host) }
} catch (e: Exception) { } catch (e: Exception) {
@@ -93,6 +93,12 @@ abstract class SafetySyncer : Syncer {
} }
} }
SwingUtilities.invokeLater {
deletedData.forEach {
hostManager.removeHost(it.id)
deleteDataManager.removeHost(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Decode hosts: {}", text) log.debug("Decode hosts: {}", text)
@@ -120,7 +126,6 @@ abstract class SafetySyncer : Syncer {
encryptedHost.tunnelings = encryptedHost.tunnelings =
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String() ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.sort = host.sort encryptedHost.sort = host.sort
encryptedHost.deleted = host.deleted
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String() encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String() encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String() encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
@@ -133,7 +138,18 @@ abstract class SafetySyncer : Syncer {
} }
protected fun decodeSnippets(text: String, config: SyncConfig) { protected fun encodeDeletedData(config: SyncConfig): String {
return ohMyJson.encodeToString(deleteDataManager.getDeletedData())
}
protected fun decodeDeletedData(text: String, config: SyncConfig): List<DeletedData> {
val deletedData = ohMyJson.decodeFromString<List<DeletedData>>(text).toMutableList()
// 和本地融合
deletedData.addAll(deleteDataManager.getDeletedData())
return deletedData
}
protected fun decodeSnippets(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key // aes key
val key = getKey(config) val key = getKey(config)
val encryptedSnippets = ohMyJson.decodeFromString<List<Snippet>>(text) val encryptedSnippets = ohMyJson.decodeFromString<List<Snippet>>(text)
@@ -144,7 +160,7 @@ abstract class SafetySyncer : Syncer {
// 如果一样,则无需配置 // 如果一样,则无需配置
if (oldHost != null) { if (oldHost != null) {
if (oldHost.updateDate == encryptedSnippet.updateDate) { if (oldHost.updateDate >= encryptedSnippet.updateDate) {
continue continue
} }
} }
@@ -165,9 +181,15 @@ abstract class SafetySyncer : Syncer {
} }
} }
SwingUtilities.invokeLater {
deletedData.forEach {
snippetManager.removeSnippet(it.id)
deleteDataManager.removeSnippet(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Decode hosts: {}", text) log.debug("Decode Snippets: {}", text)
} }
} }
@@ -188,12 +210,20 @@ abstract class SafetySyncer : Syncer {
} }
protected fun decodeKeys(text: String, config: SyncConfig) { protected fun decodeKeys(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key // aes key
val key = getKey(config) val key = getKey(config)
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text) val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
val keys = keyManager.getOhKeyPairs().associateBy { it.id }
for (encryptedKey in encryptedKeys) { for (encryptedKey in encryptedKeys) {
val k = keys[encryptedKey.id]
if (k != null) {
if (k.updateDate > encryptedKey.updateDate) {
continue
}
}
try { try {
// aes iv // aes iv
val iv = getIv(encryptedKey.id) val iv = getIv(encryptedKey.id)
@@ -215,6 +245,13 @@ abstract class SafetySyncer : Syncer {
} }
} }
SwingUtilities.invokeLater {
deletedData.forEach {
keyManager.removeOhKeyPair(it.id)
deleteDataManager.removeKeyPair(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Decode keys: {}", text) log.debug("Decode keys: {}", text)
} }
@@ -240,12 +277,20 @@ abstract class SafetySyncer : Syncer {
return ohMyJson.encodeToString(encryptedKeys) return ohMyJson.encodeToString(encryptedKeys)
} }
protected fun decodeKeywordHighlights(text: String, config: SyncConfig) { protected fun decodeKeywordHighlights(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key // aes key
val key = getKey(config) val key = getKey(config)
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text) val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
val keywordHighlights = keywordHighlightManager.getKeywordHighlights().associateBy { it.id }
for (e in encryptedKeywordHighlights) { for (e in encryptedKeywordHighlights) {
val keywordHighlight = keywordHighlights[e.id]
if (keywordHighlight != null) {
if (keywordHighlight.updateDate >= e.updateDate) {
continue
}
}
try { try {
// aes iv // aes iv
val iv = getIv(e.id) val iv = getIv(e.id)
@@ -262,6 +307,13 @@ abstract class SafetySyncer : Syncer {
} }
} }
SwingUtilities.invokeLater {
deletedData.forEach {
keywordHighlightManager.removeKeywordHighlight(it.id)
deleteDataManager.removeKeywordHighlight(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Decode KeywordHighlight: {}", text) log.debug("Decode KeywordHighlight: {}", text)
} }
@@ -281,12 +333,19 @@ abstract class SafetySyncer : Syncer {
return ohMyJson.encodeToString(keywordHighlights) return ohMyJson.encodeToString(keywordHighlights)
} }
protected fun decodeMacros(text: String, config: SyncConfig) { protected fun decodeMacros(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key // aes key
val key = getKey(config) val key = getKey(config)
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text) val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
val macros = macroManager.getMacros().associateBy { it.id }
for (e in encryptedMacros) { for (e in encryptedMacros) {
val macro = macros[e.id]
if (macro != null) {
if (macro.updateDate >= e.updateDate) {
continue
}
}
try { try {
// aes iv // aes iv
val iv = getIv(e.id) val iv = getIv(e.id)
@@ -303,6 +362,13 @@ abstract class SafetySyncer : Syncer {
} }
} }
SwingUtilities.invokeLater {
deletedData.forEach {
macroManager.removeMacro(it.id)
deleteDataManager.removeMacro(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Decode Macros: {}", text) log.debug("Decode Macros: {}", text)
} }
@@ -322,12 +388,27 @@ abstract class SafetySyncer : Syncer {
return ohMyJson.encodeToString(macros) return ohMyJson.encodeToString(macros)
} }
protected fun decodeKeymaps(text: String, config: SyncConfig) { protected fun decodeKeymaps(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
for (keymap in ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }) { val localKeymaps = keymapManager.getKeymaps().associateBy { it.name }
val remoteKeymaps = ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }
for (keymap in remoteKeymaps) {
val localKeymap = localKeymaps[keymap.name]
if (localKeymap != null) {
if (localKeymap.updateDate > keymap.updateDate) {
continue
}
}
keymapManager.addKeymap(keymap) keymapManager.addKeymap(keymap)
} }
SwingUtilities.invokeLater {
deletedData.forEach {
keymapManager.removeKeymap(it.id)
deleteDataManager.removeKeymap(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Decode Keymaps: {}", text) log.debug("Decode Keymaps: {}", text)
} }

View File

@@ -7,6 +7,11 @@ enum class SyncType {
WebDAV, WebDAV,
} }
enum class SyncPolicy {
Manual,
OnChange,
}
enum class SyncRange { enum class SyncRange {
Hosts, Hosts,
KeyPairs, KeyPairs,

View File

@@ -0,0 +1,175 @@
package app.termora.sync
import app.termora.ApplicationScope
import app.termora.Database
import app.termora.Disposable
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import kotlin.random.Random
import kotlin.time.Duration.Companion.seconds
@Suppress("DuplicatedCode")
class SyncManager private constructor() : Disposable {
companion object {
private val log = LoggerFactory.getLogger(SyncManager::class.java)
fun getInstance(): SyncManager {
return ApplicationScope.forApplicationScope().getOrCreate(SyncManager::class) { SyncManager() }
}
}
private val sync get() = Database.getDatabase().sync
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null
private var disableTrigger = false
private fun trigger() {
trigger(getSyncConfig())
}
fun triggerOnChanged() {
if (sync.policy == SyncPolicy.OnChange.name) {
trigger()
}
}
private fun trigger(config: SyncConfig) {
if (disableTrigger) return
job?.cancel()
if (log.isInfoEnabled) {
log.info("Automatic synchronisation is interrupted")
}
job = coroutineScope.launch {
// 因为会频繁调用,等待 10 - 30 秒
val seconds = Random.nextInt(10, 30)
if (log.isInfoEnabled) {
log.info("Trigger synchronisation, which will take place after {} seconds", seconds)
}
delay(seconds.seconds)
if (!disableTrigger) {
try {
if (log.isInfoEnabled) {
log.info("Automatic synchronisation begin")
}
// 如果已经开始,设置为 null
// 因为同步的时候会修改数据,避免被中断
job = null
sync(config)
sync.lastSyncTime = System.currentTimeMillis()
if (log.isInfoEnabled) {
log.info("Automatic synchronisation end")
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
}
fun sync(config: SyncConfig): SyncResponse {
return syncImmediately(config)
}
private fun getSyncConfig(): SyncConfig {
val range = mutableSetOf<SyncRange>()
if (sync.rangeHosts) {
range.add(SyncRange.Hosts)
}
if (sync.rangeKeyPairs) {
range.add(SyncRange.KeyPairs)
}
if (sync.rangeKeywordHighlights) {
range.add(SyncRange.KeywordHighlights)
}
if (sync.rangeMacros) {
range.add(SyncRange.Macros)
}
if (sync.rangeKeymap) {
range.add(SyncRange.Keymap)
}
if (sync.rangeSnippets) {
range.add(SyncRange.Snippets)
}
return SyncConfig(
type = sync.type,
token = sync.token,
gistId = sync.gist,
options = mapOf("domain" to sync.domain),
ranges = range
)
}
private fun syncImmediately(config: SyncConfig): SyncResponse {
synchronized(this) {
return SyncResponse(pull(config), push(config))
}
}
fun pull(config: SyncConfig): GistResponse {
synchronized(this) {
disableTrigger = true
try {
return SyncerProvider.getInstance().getSyncer(config.type).pull(config)
} finally {
disableTrigger = false
}
}
}
fun push(config: SyncConfig): GistResponse {
synchronized(this) {
try {
disableTrigger = true
return SyncerProvider.getInstance().getSyncer(config.type).push(config)
} finally {
disableTrigger = false
}
}
}
override fun dispose() {
coroutineScope.cancel()
}
private class SyncerProvider private constructor() {
companion object {
fun getInstance(): SyncerProvider {
return ApplicationScope.forApplicationScope().getOrCreate(SyncerProvider::class) { SyncerProvider() }
}
}
fun getSyncer(type: SyncType): Syncer {
return when (type) {
SyncType.GitHub -> GitHubSyncer.getInstance()
SyncType.Gitee -> GiteeSyncer.getInstance()
SyncType.GitLab -> GitLabSyncer.getInstance()
SyncType.WebDAV -> WebDAVSyncer.getInstance()
}
}
}
data class SyncResponse(val pull: GistResponse, val push: GistResponse)
}

View File

@@ -1,21 +0,0 @@
package app.termora.sync
import app.termora.ApplicationScope
class SyncerProvider private constructor() {
companion object {
fun getInstance(): SyncerProvider {
return ApplicationScope.forApplicationScope().getOrCreate(SyncerProvider::class) { SyncerProvider() }
}
}
fun getSyncer(type: SyncType): Syncer {
return when (type) {
SyncType.GitHub -> GitHubSyncer.getInstance()
SyncType.Gitee -> GiteeSyncer.getInstance()
SyncType.GitLab -> GitLabSyncer.getInstance()
SyncType.WebDAV -> WebDAVSyncer.getInstance()
}
}
}

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