Compare commits
333 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc4333da21 | ||
|
|
184f6d46dc | ||
|
|
68788905fe | ||
|
|
fc46216a3f | ||
|
|
563143645e | ||
|
|
891ccb901b | ||
|
|
928a866fe7 | ||
|
|
ea25b5b46f | ||
|
|
1de10e6129 | ||
|
|
aaf9c2e8d2 | ||
|
|
b8196b5730 | ||
|
|
0a83e8beb4 | ||
|
|
bdf29b27e7 | ||
|
|
96da7eac41 | ||
|
|
71c0751692 | ||
|
|
442f334af2 | ||
|
|
48302a519f | ||
|
|
c00f759f15 | ||
|
|
1736dd909e | ||
|
|
1f01e368dd | ||
|
|
bfba958b7e | ||
|
|
758121b523 | ||
|
|
06e9a89e82 | ||
|
|
0ba6ac3305 | ||
|
|
993f220b8b | ||
|
|
8755c4ad23 | ||
|
|
77cb102dd6 | ||
|
|
89cfb0b451 | ||
|
|
6bdd83f208 | ||
|
|
8f86057dcc | ||
|
|
a7d7ffa2cc | ||
|
|
d51cbeee13 | ||
|
|
deb2a0151e | ||
|
|
e1c4e9312d | ||
|
|
c7233357bd | ||
|
|
eff8d565d0 | ||
|
|
932db49868 | ||
|
|
4d71c6cd05 | ||
|
|
96133e5abf | ||
|
|
f06e5d7dc1 | ||
|
|
d4b96edccf | ||
|
|
e9876d5b91 | ||
|
|
8b9a78a7bd | ||
|
|
6b48f577e9 | ||
|
|
da9b6c21d6 | ||
|
|
f1f889df14 | ||
|
|
ed65853ebe | ||
|
|
5ffdd219d9 | ||
|
|
4f84d6741c | ||
|
|
2568e7fcc8 | ||
|
|
dddbb49084 | ||
|
|
95846ab135 | ||
|
|
b5207e56c1 | ||
|
|
160771e912 | ||
|
|
0fbe180f3f | ||
|
|
41a0409e9e | ||
|
|
79e59143fb | ||
|
|
54e0f621ce | ||
|
|
4c8944d248 | ||
|
|
64bd95d8a8 | ||
|
|
1d88942e8e | ||
|
|
129e1b149a | ||
|
|
01aac98437 | ||
|
|
f9aaf7143f | ||
|
|
28174483f4 | ||
|
|
46412054c4 | ||
|
|
1ab0d26bab | ||
|
|
d90fb9aa35 | ||
|
|
744e64b359 | ||
|
|
2c5442f1f3 | ||
|
|
054c4701d2 | ||
|
|
54044625ea | ||
|
|
ca82704738 | ||
|
|
e98ec3fa8e | ||
|
|
6a4abf7e50 | ||
|
|
e2a6cceafd | ||
|
|
283404b6b9 | ||
|
|
c714f33a44 | ||
|
|
30fe047e5c | ||
|
|
827d814c7b | ||
|
|
ccb2c6daa0 | ||
|
|
1516d6d81e | ||
|
|
09b3655c4e | ||
|
|
614514c87e | ||
|
|
30cba6720d | ||
|
|
dce6551de2 | ||
|
|
95943cdeec | ||
|
|
18a26ee6bf | ||
|
|
f23aae371a | ||
|
|
757bc1c001 | ||
|
|
a19222dc60 | ||
|
|
24677ca4a6 | ||
|
|
0c5b6f8112 | ||
|
|
7c26e3d08a | ||
|
|
9b84fb4ec8 | ||
|
|
d8ec7b6d4a | ||
|
|
769c0d990b | ||
|
|
3f1ae38b61 | ||
|
|
e10fce21a2 | ||
|
|
a00557bb9d | ||
|
|
e478535ae5 | ||
|
|
7756758738 | ||
|
|
e0ea42faee | ||
|
|
e72c6b77b5 | ||
|
|
bcd3aacd6f | ||
|
|
570b0e08ad | ||
|
|
d703850e87 | ||
|
|
4bb1a411e8 | ||
|
|
9884ed19fa | ||
|
|
1ffaed3f36 | ||
|
|
4cb42953ad | ||
|
|
0248992dc3 | ||
|
|
9bab9db875 | ||
|
|
b283a3ea38 | ||
|
|
98ac2928b4 | ||
|
|
a0a6f43c10 | ||
|
|
0c158acbe0 | ||
|
|
9a97b3a304 | ||
|
|
aef44bd0da | ||
|
|
75c65d9ba8 | ||
|
|
93755db77f | ||
|
|
79d0a9a348 | ||
|
|
422e9aac84 | ||
|
|
9915c373b7 | ||
|
|
eba85e6348 | ||
|
|
483a7772f4 | ||
|
|
dcc96358f6 | ||
|
|
b5c30d505b | ||
|
|
1f3ef5f3f0 | ||
|
|
d388bcfc92 | ||
|
|
562c1f98fe | ||
|
|
f3c5009a45 | ||
|
|
09a1d9f51e | ||
|
|
84b48278ad | ||
|
|
ef9caf2578 | ||
|
|
b85bdf840e | ||
|
|
a2d7f3b5bb | ||
|
|
02a96d73c8 | ||
|
|
9fb12c7a71 | ||
|
|
145d8fc802 | ||
|
|
72c9dba806 | ||
|
|
de20bd654c | ||
|
|
35b3a10746 | ||
|
|
05fe6a0eb1 | ||
|
|
0552917c26 | ||
|
|
51c355c113 | ||
|
|
034ee3791d | ||
|
|
adabaf8f2d | ||
|
|
1f392c52a1 | ||
|
|
28fe4c725f | ||
|
|
18fe92cb11 | ||
|
|
c49acf7b51 | ||
|
|
7df317a1b9 | ||
|
|
219e5420f5 | ||
|
|
aefb7c3014 | ||
|
|
f0c7f06ff5 | ||
|
|
604e07b43a | ||
|
|
0000e4610a | ||
|
|
510324d7c4 | ||
|
|
33a359fcbf | ||
|
|
0b84d3271c | ||
|
|
57547c95cb | ||
|
|
503cfa9a4e | ||
|
|
af1f979e31 | ||
|
|
3cd9f92ea9 | ||
|
|
b332bada95 | ||
|
|
63a12c2ec8 | ||
|
|
743f242805 | ||
|
|
5bead0b27d | ||
|
|
73e3c7016b | ||
|
|
3829dcd0f9 | ||
|
|
b2047044fe | ||
|
|
47d1a13189 | ||
|
|
309909cbd7 | ||
|
|
b5cebb4cea | ||
|
|
b6dd2693cd | ||
|
|
5fdfe98f26 | ||
|
|
0c768aa1ca | ||
|
|
d493e6dc9e | ||
|
|
7e0c7d8891 | ||
|
|
3510c6600d | ||
|
|
32d91150bd | ||
|
|
bbf2d50e3f | ||
|
|
39725f9828 | ||
|
|
1e8c617a85 | ||
|
|
7f8573ec4c | ||
|
|
d8e629917e | ||
|
|
bdc0a15439 | ||
|
|
a25b97614f | ||
|
|
4e12c32566 | ||
|
|
ea9c0f1225 | ||
|
|
ff865f13a2 | ||
|
|
9875200912 | ||
|
|
9f218d004e | ||
|
|
ab727f66f4 | ||
|
|
efbc0302e4 | ||
|
|
ab2367d670 | ||
|
|
045e4f81d6 | ||
|
|
160cfee947 | ||
|
|
0e40b5ecce | ||
|
|
fcaddcee80 | ||
|
|
8d6295fd3b | ||
|
|
d0d51b3e6f | ||
|
|
b8d612f1d5 | ||
|
|
f7c49cde0c | ||
|
|
189f8fb3ba | ||
|
|
2a64bd28a8 | ||
|
|
8a733379a3 | ||
|
|
e5f854dfcd | ||
|
|
4e690bafed | ||
|
|
28b511e179 | ||
|
|
f010a13abd | ||
|
|
4d80ffafdd | ||
|
|
9aecd4d54b | ||
|
|
65091823eb | ||
|
|
d17218bfbd | ||
|
|
724c5d2632 | ||
|
|
6806c26028 | ||
|
|
dcd89174c9 | ||
|
|
9a8707b8cb | ||
|
|
28f1d05f06 | ||
|
|
54b044584e | ||
|
|
ed39449a20 | ||
|
|
2ff3f3a352 | ||
|
|
91e2e964a5 | ||
|
|
ca6cc68fed | ||
|
|
0962de7735 | ||
|
|
062b957fdb | ||
|
|
4efe4e5663 | ||
|
|
25eb6966c4 | ||
|
|
7843460020 | ||
|
|
1cbc6ba4a9 | ||
|
|
a43407bee8 | ||
|
|
05c4ec9af2 | ||
|
|
9236064293 | ||
|
|
e1955a371e | ||
|
|
58b56c4221 | ||
|
|
1e461e529f | ||
|
|
38ada1207c | ||
|
|
8bd1b34f46 | ||
|
|
4a513360e6 | ||
|
|
22da5c1c37 | ||
|
|
483582a8d1 | ||
|
|
f037cbfac0 | ||
|
|
343d11482d | ||
|
|
7ef81a0116 | ||
|
|
5df62d5d3e | ||
|
|
7db650d69f | ||
|
|
8d80d38d63 | ||
|
|
48f05d4cff | ||
|
|
9a1cf387c0 | ||
|
|
8b7efefbdb | ||
|
|
75f21db325 | ||
|
|
b094c9d4ff | ||
|
|
0da3c95759 | ||
|
|
fa79473ece | ||
|
|
86ccb5e0cc | ||
|
|
f385f4b277 | ||
|
|
3d0ef2a331 | ||
|
|
96999205a8 | ||
|
|
ee7f3871eb | ||
|
|
df2e9b0743 | ||
|
|
7964950149 | ||
|
|
e2d77fe881 | ||
|
|
f5783c8587 | ||
|
|
346044b1ba | ||
|
|
aa6ec8dd43 | ||
|
|
e0e6a85a81 | ||
|
|
56ba107c87 | ||
|
|
0345848418 | ||
|
|
f1073fb53f | ||
|
|
ce1924c422 | ||
|
|
d6de0922c6 | ||
|
|
d5157d3a16 | ||
|
|
63b27a2f83 | ||
|
|
992015c8e5 | ||
|
|
5d459f9b0d | ||
|
|
88f20c4898 | ||
|
|
314c112d4b | ||
|
|
0cd818e9a0 | ||
|
|
0884486e91 | ||
|
|
e30316eab3 | ||
|
|
d321e766b1 | ||
|
|
6aaed92f2c | ||
|
|
21cf22906b | ||
|
|
1476368673 | ||
|
|
45ea822fd6 | ||
|
|
a71493e52c | ||
|
|
cb327f218c | ||
|
|
6881b6376f | ||
|
|
5027fd9dfb | ||
|
|
49cef39b8b | ||
|
|
5c4acf85e8 | ||
|
|
07bee64b7f | ||
|
|
923afb7e99 | ||
|
|
68df52bfc0 | ||
|
|
c2ee6fc8ac | ||
|
|
9d4562e7e3 | ||
|
|
5733b5f485 | ||
|
|
9dbdb5fd7a | ||
|
|
a1d1821553 | ||
|
|
4a8faea8c5 | ||
|
|
cfb841db00 | ||
|
|
a87d4ddf82 | ||
|
|
6071b251a4 | ||
|
|
950ff517bb | ||
|
|
70008978d8 | ||
|
|
7c445bdadb | ||
|
|
f24151f6d8 | ||
|
|
7d65a88d63 | ||
|
|
ed57c3e5b4 | ||
|
|
00f11c9ed5 | ||
|
|
5ebea06a95 | ||
|
|
3e5df2161b | ||
|
|
ffcb4d028e | ||
|
|
022ae402cc | ||
|
|
75f8d1de99 | ||
|
|
b9ed8258d1 | ||
|
|
6b6ceb1409 | ||
|
|
550ad85415 | ||
|
|
9d6fd7871b | ||
|
|
7f40a67c28 | ||
|
|
89fa153c1e | ||
|
|
46af9a44b2 | ||
|
|
babc440841 | ||
|
|
72057418aa | ||
|
|
568962dc41 | ||
|
|
d578d1529c | ||
|
|
fe1106658a | ||
|
|
401712c5b5 | ||
|
|
3ff6d93279 | ||
|
|
38496b9f1b | ||
|
|
db3e15508c |
12
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 25
|
||||
47
.github/workflows/linux-aarch64.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Linux aarch64
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# download jdk
|
||||
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-aarch64-b895.91.tar.gz
|
||||
|
||||
# appimagetool
|
||||
- run: sudo apt install libfuse2
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.6'
|
||||
architecture: aarch64
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
./gradlew dist --no-daemon
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-linux-aarch64
|
||||
path: |
|
||||
build/distributions/*.tar.gz
|
||||
build/distributions/*.AppImage
|
||||
47
.github/workflows/linux-x86-64.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Linux x86-64
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# download jdk
|
||||
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b895.91.tar.gz
|
||||
|
||||
# appimagetool
|
||||
- run: sudo apt install libfuse2
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.6'
|
||||
architecture: x64
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
./gradlew dist --no-daemon
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-linux-x86-64
|
||||
path: |
|
||||
build/distributions/*.tar.gz
|
||||
build/distributions/*.AppImage
|
||||
84
.github/workflows/osx-aarch64.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
name: macOS aarch64
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the Apple certificate
|
||||
if: github.event_name == 'push' && github.repository == 'TermoraDev/termora'
|
||||
env:
|
||||
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
|
||||
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# create variables
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
|
||||
# import certificate from secrets
|
||||
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||
|
||||
# create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# import certificate to keychain
|
||||
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
- name: Setup the Notary information
|
||||
if: "startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora'"
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
STORE_CREDENTIALS: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
|
||||
run: |
|
||||
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
|
||||
|
||||
# download jdk
|
||||
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b895.91.tar.gz
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.6'
|
||||
architecture: aarch64
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- name: Dist
|
||||
env:
|
||||
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }}
|
||||
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
|
||||
# 只有发布版本时才需要公证
|
||||
TERMORA_MAC_NOTARY: "${{ startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' }}"
|
||||
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
|
||||
run: |
|
||||
./gradlew dist --no-daemon
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-osx-aarch64
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.dmg
|
||||
86
.github/workflows/osx-x86-64.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: macOS x86-64
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the Apple certificate
|
||||
if: github.event_name == 'push' && github.repository == 'TermoraDev/termora'
|
||||
env:
|
||||
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
|
||||
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# create variables
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
|
||||
# import certificate from secrets
|
||||
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||
|
||||
# create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# import certificate to keychain
|
||||
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
- name: Setup the Notary information
|
||||
if: "startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora'"
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
STORE_CREDENTIALS: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
|
||||
run: |
|
||||
xcrun notarytool store-credentials "$STORE_CREDENTIALS" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_PASSWORD"
|
||||
|
||||
# download jdk
|
||||
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b895.91.tar.gz
|
||||
|
||||
# install jdk
|
||||
- name: Installing Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'jdkfile'
|
||||
jdkFile: ${{ runner.temp }}/java_package.tar.gz
|
||||
java-version: '21.0.6'
|
||||
architecture: x64
|
||||
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
|
||||
# dist
|
||||
- name: Dist
|
||||
env:
|
||||
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' && github.repository == 'TermoraDev/termora' }}
|
||||
TERMORA_MAC_SIGN_USER_NAME: ${{ secrets.TERMORA_MAC_SIGN_USER_NAME }}
|
||||
# 只有发布版本时才需要公证
|
||||
TERMORA_MAC_NOTARY: "${{ startsWith(github.event.head_commit.message, 'release: ') && github.repository == 'TermoraDev/termora' }}"
|
||||
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
|
||||
run: |
|
||||
./gradlew dist --no-daemon
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-osx-x86-64
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.dmg
|
||||
48
.github/workflows/windows-x86-64.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Windows x86-64
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install zip
|
||||
run: |
|
||||
$system32 = [System.Environment]::GetEnvironmentVariable("WINDIR") + "\System32"
|
||||
Invoke-WebRequest -Uri "http://stahlworks.com/dev/zip.exe" -OutFile "$system32\zip.exe"
|
||||
Invoke-WebRequest -Uri "http://stahlworks.com/dev/unzip.exe" -OutFile "$system32\unzip.exe"
|
||||
|
||||
- name: Install 7z
|
||||
uses: milliewalky/setup-7-zip@v2
|
||||
|
||||
- name: Installing Java
|
||||
run: |
|
||||
curl -s --output ${{ runner.temp }}\java_package.zip -L https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-windows-x64-b895.91.zip
|
||||
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
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
.\gradlew.bat dist --no-daemon
|
||||
.\gradlew.bat --stop
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-windows-x86-64
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.exe
|
||||
14
.github/workflows/winget.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
release:
|
||||
types: [ released ]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@main
|
||||
if: github.repository == 'TermoraDev/termora'
|
||||
with:
|
||||
identifier: TermoraDev.Termora
|
||||
installers-regex: 'x86-64\.exe$' # Only x86-64.exe files
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
1
.gitignore
vendored
@@ -6,6 +6,7 @@ certs/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
.vs
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
|
||||
59
README.md
@@ -1,45 +1,52 @@
|
||||
<div align="center">
|
||||
<a href="./README.zh_CN.md">🇨🇳 简体中文</a>
|
||||
</div>
|
||||
|
||||
# Termora
|
||||
|
||||
**Termora** 是一个终端模拟器和 SSH 客户端,支持 Windows,macOS 和 Linux。
|
||||
**Termora** is a terminal emulator and SSH client for Windows, macOS and Linux.
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/readme.png" alt="termora" />
|
||||
</div>
|
||||
|
||||
**Termora** 采用 [Kotlin/JVM](https://kotlinlang.org/) 开发并实现了 [XTerm](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 协议(尚未完全实现),它的最终目标是通过 [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) 实现全平台(含 Android、iOS、iPadOS 等)。
|
||||
**Termora** is developed using [Kotlin/JVM](https://kotlinlang.org) and partially implements the [XTerm](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) protocol (with ongoing improvements). Its ultimate vision is to achieve full platform support (including Android, iOS, and iPadOS) through [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html).
|
||||
|
||||
## 功能特性
|
||||
## Features
|
||||
|
||||
- 支持 SSH 和本地终端
|
||||
- 支持 Windows、macOS、Linux 平台
|
||||
- 支持 Zmodem 协议
|
||||
- 支持 SSH 端口转发
|
||||
- 支持配置同步到 [Gist](https://gist.github.com)
|
||||
- 支持宏(录制脚本并回放)
|
||||
- 支持关键词高亮
|
||||
- 支持密钥管理器
|
||||
- 支持将命令发送到多个会话
|
||||
- 支持 [Find Everywhere](./docs/findeverywhere.png) 快速跳转
|
||||
- 支持数据加密
|
||||
- SSH and local terminal support
|
||||
- Serial port protocol support
|
||||
- [SFTP](./docs/sftp.png?raw=1) & [Command](./docs/sftp-command.png?raw=1) file transfer support
|
||||
- Compatible with Windows, macOS, and Linux
|
||||
- Zmodem protocol support
|
||||
- SSH port forwarding & Jump hosts
|
||||
- Support for X11 and SSH-Agent
|
||||
- Terminal log
|
||||
- Configuration synchronization via [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
|
||||
- Macro support (record and replay scripts)
|
||||
- Keyword highlighting
|
||||
- Key management
|
||||
- Broadcast commands to multiple sessions
|
||||
- [Find Everywhere](./docs/findeverywhere.png?raw=1) quick navigation
|
||||
- Data encryption
|
||||
- ...
|
||||
|
||||
## 下载
|
||||
## Download
|
||||
|
||||
- [releases](https://github.com/TermoraDev/termora/releases/latest)
|
||||
- [Latest release](https://github.com/TermoraDev/termora/releases/latest)
|
||||
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora`
|
||||
- [WinGet](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/TermoraDev/Termora): `winget install termora`
|
||||
|
||||
### macOS
|
||||
## Development
|
||||
|
||||
由于苹果开发者证书正在申请中,所以 macOS 用户需要执行 `sudo xattr -r -d com.apple.quarantine /Applications/Termora.app` 后才可以运行程序。
|
||||
It is recommended to use the [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) version of the JDK and run the program via `./gradlew :run` to run the program.
|
||||
|
||||
## 开发
|
||||
The program can be run via `./gradlew dist` to automatically build the local version. On macOS: `dmg`, on Windows: `zip`, on Linux: `tar.gz`.
|
||||
|
||||
建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) 的 JDK 版本,通过 `./gradlew :run`即可运行程序。
|
||||
|
||||
通过 `./gradlew dist` 可以自动构建适用于本机的版本。在 macOS 上是:`dmg`,在 Windows 上是:`zip`,在 Linux 上是:`tar.gz`。
|
||||
## LICENSE
|
||||
|
||||
## 协议
|
||||
This software is distributed under a dual-license model. You may choose one of the following options:
|
||||
|
||||
本软件采用双重许可模式,您可以选择以下任意一种许可方式:
|
||||
|
||||
- AGPL-3.0:根据 [AGPL-3.0](https://opensource.org/license/agpl-v3) 的条款,您可以自由使用、分发和修改本软件。
|
||||
- 专有许可:如果希望在闭源或专有环境中使用,请联系作者获取许可。
|
||||
- AGPL-3.0: Use, distribute, and modify the software under the terms of the [AGPL-3.0](https://opensource.org/license/agpl-v3).
|
||||
- Proprietary License: For closed-source or proprietary use, please contact the author to obtain a commercial license.
|
||||
|
||||
47
README.zh_CN.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Termora
|
||||
|
||||
**Termora** 是一个终端模拟器和 SSH 客户端,支持 Windows,macOS 和 Linux。
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/readme-zh_CN.png" alt="termora" />
|
||||
</div>
|
||||
|
||||
**Termora** 采用 [Kotlin/JVM](https://kotlinlang.org/) 开发并实现了 [XTerm](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 协议(尚未完全实现),它的最终目标是通过 [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) 实现全平台(含 Android、iOS、iPadOS 等)。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 支持 SSH 和本地终端
|
||||
- 支持串口协议
|
||||
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) & [命令行](./docs/sftp-command.png?raw=1) 文件传输
|
||||
- 支持 Windows、macOS、Linux 平台
|
||||
- 支持 Zmodem 协议
|
||||
- 支持 SSH 端口转发和跳板机
|
||||
- 支持 X11 和 SSH-Agent
|
||||
- 终端日志记录
|
||||
- 支持配置同步到 [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
|
||||
- 支持宏(录制脚本并回放)
|
||||
- 支持关键词高亮
|
||||
- 支持密钥管理器
|
||||
- 支持将命令发送到多个会话
|
||||
- 支持 [Find Everywhere](./docs/findeverywhere-zh_CN.png?raw=1) 快速跳转
|
||||
- 支持数据加密
|
||||
- ...
|
||||
|
||||
## 下载
|
||||
|
||||
- [Latest release](https://github.com/TermoraDev/termora/releases/latest)
|
||||
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora`
|
||||
- [WinGet](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/TermoraDev/Termora): `winget install termora`
|
||||
|
||||
## 开发
|
||||
|
||||
建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) 的 JDK 版本,通过 `./gradlew :run` 即可运行程序。
|
||||
|
||||
通过 `./gradlew dist` 可以自动构建适用于本机的版本。在 macOS 上是:`dmg`,在 Windows 上是:`zip`,在 Linux 上是:`tar.gz`。
|
||||
|
||||
## 协议
|
||||
|
||||
本软件采用双重许可模式,您可以选择以下任意一种许可方式:
|
||||
|
||||
- AGPL-3.0:根据 [AGPL-3.0](https://opensource.org/license/agpl-v3) 的条款,您可以自由使用、分发和修改本软件。
|
||||
- 专有许可:如果希望在闭源或专有环境中使用,请联系作者获取许可。
|
||||
152
THIRDPARTY
@@ -1,231 +1,263 @@
|
||||
annotations 24.0.1
|
||||
annotations
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/java-annotations/blob/master/LICENSE.txt
|
||||
|
||||
bip39-lib-jvm 1.0.8
|
||||
kotlin-bip39
|
||||
MIT License
|
||||
https://github.com/Electric-Coin-Company/kotlin-bip39/blob/main/LICENSE
|
||||
|
||||
colorpicker 2.0.1
|
||||
colorpicker
|
||||
BSD 3-Clause "New" or "Revised" License
|
||||
https://github.com/dheid/colorpicker/blob/main/LICENSE
|
||||
|
||||
commonmark 0.24.0
|
||||
commonmark
|
||||
BSD 2-Clause "Simplified" License
|
||||
https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt
|
||||
|
||||
commons-codec 1.17.1
|
||||
commons-codec
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-codec/blob/master/LICENSE.txt
|
||||
|
||||
commons-compress 1.27.1
|
||||
commons-compress
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-compress/blob/master/LICENSE.txt
|
||||
|
||||
commons-io 2.18.0
|
||||
commons-vfs2
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-vfs/blob/master/LICENSE.txt
|
||||
|
||||
commons-io
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-io/blob/master/LICENSE.txt
|
||||
|
||||
commons-lang3 3.17.0
|
||||
commons-lang3
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-lang/blob/master/LICENSE.txt
|
||||
|
||||
commons-net 3.11.1
|
||||
commons-net
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-net/blob/master/LICENSE.txt
|
||||
|
||||
commons-text 1.12.0
|
||||
commons-text
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-text/blob/master/LICENSE.txt
|
||||
|
||||
eddsa 0.3.0
|
||||
commons-csv
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-csv/blob/master/LICENSE.txt
|
||||
|
||||
ini4j
|
||||
Apache License 2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
|
||||
eddsa
|
||||
Creative Commons Zero v1.0 Universal
|
||||
https://github.com/str4d/ed25519-java/blob/master/LICENSE.txt
|
||||
|
||||
flatlaf 3.5.4
|
||||
flatlaf
|
||||
Apache License 2.0
|
||||
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
|
||||
|
||||
flatlaf-extras 3.5.4
|
||||
flatlaf-no-natives
|
||||
Apache License 2.0
|
||||
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
|
||||
|
||||
flatlaf-swingx 3.5.4
|
||||
flatlaf-extras
|
||||
Apache License 2.0
|
||||
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
|
||||
|
||||
JavaEWAH 1.2.3
|
||||
flatlaf-swingx
|
||||
Apache License 2.0
|
||||
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
|
||||
|
||||
JavaEWAH
|
||||
Apache License 2.0
|
||||
https://github.com/lemire/javaewah/blob/master/LICENSE
|
||||
|
||||
jbr-api 17.1.10.1
|
||||
jbr-api
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/JetBrainsRuntimeApi/blob/main/LICENSE
|
||||
|
||||
jcl-over-slf4j 1.7.36
|
||||
jcl-over-slf4j
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
|
||||
jfa 1.2.0
|
||||
jfa
|
||||
Apache License 2.0
|
||||
https://github.com/0x4a616e/jfa/blob/main/LICENSE
|
||||
|
||||
jgoodies-common 1.8.1
|
||||
jgoodies-common
|
||||
BSD-2-Clause License
|
||||
http://www.opensource.org/licenses/bsd-license.html
|
||||
|
||||
jgoodies-forms 1.9.0
|
||||
jgoodies-forms
|
||||
BSD-2-Clause License
|
||||
http://www.opensource.org/licenses/bsd-license.html
|
||||
|
||||
jna 5.16.0
|
||||
jna
|
||||
Apache License 2.0
|
||||
https://github.com/java-native-access/jna/blob/master/AL2.0
|
||||
|
||||
jna-platform 5.16.0
|
||||
jna-platform
|
||||
Apache License 2.0
|
||||
https://github.com/java-native-access/jna/blob/master/AL2.0
|
||||
|
||||
jnafilechooser-api 1.1.2
|
||||
jnafilechooser-api
|
||||
BSD 3-Clause "New" or "Revised" License
|
||||
https://github.com/steos/jnafilechooser/blob/master/LICENSE
|
||||
|
||||
jnafilechooser-win32 1.1.2
|
||||
jnafilechooser-win32
|
||||
BSD 3-Clause "New" or "Revised" License
|
||||
https://github.com/steos/jnafilechooser/blob/master/LICENSE
|
||||
|
||||
jsvg 1.4.0
|
||||
jsvg
|
||||
MIT License
|
||||
https://github.com/weisJ/jsvg/blob/master/LICENSE
|
||||
|
||||
jSystemThemeDetector 3.9.1
|
||||
jSystemThemeDetector
|
||||
Apache License 2.0
|
||||
https://github.com/Dansoftowner/jSystemThemeDetector/blob/master/LICENSE
|
||||
|
||||
kotlin-logging 1.7.9
|
||||
kotlin-logging
|
||||
Apache License 2.0
|
||||
https://github.com/oshai/kotlin-logging/blob/master/LICENSE
|
||||
|
||||
kotlin-stdlib 2.1.0
|
||||
kotlin-stdlib
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
kotlin-stdlib-jdk7 1.9.10
|
||||
kotlin-stdlib-jdk7
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
kotlin-stdlib-jdk8 1.9.10
|
||||
kotlin-stdlib-jdk8
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
kotlin-stdlib-jdk8 1.9.10
|
||||
kotlin-stdlib-jdk8
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
kotlinx-coroutines-core-jvm 1.10.1
|
||||
restart4j
|
||||
Apache License 2.0
|
||||
https://github.com/hstyi/restart4j/blob/main/LICENSE
|
||||
|
||||
kotlinx-coroutines-core
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
kotlinx-coroutines-swing 1.10.1
|
||||
kotlinx-coroutines-swing
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
kotlinx-serialization-core-jvm 1.7.3
|
||||
kotlinx-serialization-json
|
||||
Apache License 2.0
|
||||
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
|
||||
|
||||
kotlinx-serialization-json-jvm 1.7.3
|
||||
Apache License 2.0
|
||||
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
|
||||
|
||||
logging-interceptor 4.12.0
|
||||
logging-interceptor
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
okhttp 4.12.0
|
||||
okhttp
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
okio-jvm 3.6.0
|
||||
okio-jvm
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
org.eclipse.jgit.ssh.apache 7.1.0.202411261347-r
|
||||
org.eclipse.jgit.ssh.apache
|
||||
Eclipse Distribution License
|
||||
https://www.eclipse.org/org/documents/edl-v10.php
|
||||
|
||||
org.eclipse.jgit 7.1.0.202411261347-r
|
||||
org.eclipse.jgit.ssh.apache.agent
|
||||
Eclipse Distribution License
|
||||
https://www.eclipse.org/org/documents/edl-v10.php
|
||||
|
||||
oshi-core 6.6.5
|
||||
org.eclipse.jgit
|
||||
Eclipse Distribution License
|
||||
https://www.eclipse.org/org/documents/edl-v10.php
|
||||
|
||||
oshi-core
|
||||
MIT License
|
||||
https://github.com/oshi/oshi/blob/master/LICENSE
|
||||
|
||||
pty4j 0.13.2
|
||||
pty4j
|
||||
Eclipse Public License 1.0
|
||||
https://github.com/JetBrains/pty4j/blob/master/LICENSE
|
||||
|
||||
slf4j-api 2.0.16
|
||||
slf4j-api
|
||||
MIT License
|
||||
https://github.com/qos-ch/slf4j/blob/master/LICENSE.txt
|
||||
|
||||
slf4j-tinylog 2.7.0
|
||||
slf4j-tinylog
|
||||
Apache License 2.0
|
||||
https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt
|
||||
|
||||
sshd-common 2.14.0
|
||||
sshd-common
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
sshd-core 2.14.0
|
||||
sshd-core
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
sshd-osgi 2.14.0
|
||||
sshd-osgi
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
sshd-sftp 2.14.0
|
||||
sshd-sftp
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
swingx-all 1.6.5-1
|
||||
swingx-all
|
||||
GNU LESSER GENERAL PUBLIC LICENSE v3
|
||||
https://www.gnu.org/licenses/lgpl-3.0
|
||||
|
||||
tinylog-api 2.7.0
|
||||
tinylog-api
|
||||
Apache License 2.0
|
||||
https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt
|
||||
|
||||
tinylog-impl 2.7.0
|
||||
tinylog-impl
|
||||
Apache License 2.0
|
||||
https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt
|
||||
|
||||
versioncompare 1.4.1
|
||||
versioncompare
|
||||
Apache License 2.0
|
||||
https://github.com/G00fY2/version-compare/blob/main/LICENSE
|
||||
|
||||
xodus-compress 2.0.1
|
||||
xodus-compress
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
|
||||
|
||||
xodus-environment 2.0.1
|
||||
xodus-environment
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
|
||||
|
||||
xodus-openAPI 2.0.1
|
||||
xodus-openAPI
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
|
||||
|
||||
xodus-utils 2.0.1
|
||||
xodus-utils
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
|
||||
|
||||
xodus-vfs 2.0.1
|
||||
xodus-vfs
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
|
||||
|
||||
jediterm
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/jediterm/blob/master/LICENSE-APACHE-2.0.txt
|
||||
|
||||
mixpanel-java
|
||||
Apache License 2.0
|
||||
https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
|
||||
|
||||
json-20231013
|
||||
Public Domain.
|
||||
https://github.com/stleary/JSON-java/blob/master/LICENSE
|
||||
|
||||
jSerialComm
|
||||
Apache License 2.0
|
||||
https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0
|
||||
682
build.gradle.kts
@@ -2,23 +2,39 @@ import org.gradle.internal.jvm.Jvm
|
||||
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
|
||||
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
|
||||
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
|
||||
import org.gradle.nativeplatform.platform.internal.DefaultOperatingSystem
|
||||
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils
|
||||
import org.jetbrains.kotlin.org.apache.commons.io.filefilter.FileFilterUtils
|
||||
import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
|
||||
import java.io.FileNotFoundException
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
|
||||
plugins {
|
||||
java
|
||||
idea
|
||||
application
|
||||
`maven-publish`
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
}
|
||||
|
||||
|
||||
group = "app.termora"
|
||||
version = "1.0.0"
|
||||
version = "1.0.15"
|
||||
|
||||
val os: DefaultOperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
|
||||
var arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
|
||||
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
|
||||
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
|
||||
|
||||
// macOS 签名信息
|
||||
val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY
|
||||
val macOSSign = os.isMacOsX && macOSSignUsername.isNotBlank()
|
||||
&& System.getenv("TERMORA_MAC_SIGN").toBoolean()
|
||||
|
||||
// macOS 公证信息
|
||||
val macOSNotaryKeychainProfile = System.getenv("TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE") ?: StringUtils.EMPTY
|
||||
val macOSNotary = macOSSign && macOSNotaryKeychainProfile.isNotBlank()
|
||||
&& System.getenv("TERMORA_MAC_NOTARY").toBoolean()
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -27,61 +43,97 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 由于签名和公证,macOS 不携带 natives
|
||||
val useNoNativesFlatLaf = os.isMacOsX && System.getenv("ENABLE_BUILD").toBoolean()
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation(libs.hutool)
|
||||
testImplementation(libs.sshj)
|
||||
testImplementation(platform(libs.koin.bom))
|
||||
testImplementation(libs.koin.core)
|
||||
testImplementation(libs.jsch)
|
||||
testImplementation(libs.rhino)
|
||||
testImplementation(libs.delight.rhino.sandbox)
|
||||
testImplementation(platform(libs.testcontainers.bom))
|
||||
testImplementation(libs.testcontainers)
|
||||
|
||||
implementation(libs.slf4j.api)
|
||||
implementation(libs.pty4j)
|
||||
implementation(libs.slf4j.tinylog)
|
||||
implementation(libs.tinylog.impl)
|
||||
implementation(libs.commons.codec)
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.commons.lang3)
|
||||
implementation(libs.commons.net)
|
||||
implementation(libs.commons.text)
|
||||
implementation(libs.commons.compress)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.flatlaf)
|
||||
implementation(libs.flatlaf.extras)
|
||||
implementation(libs.flatlaf.swingx)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.swingx)
|
||||
implementation(libs.jgoodies.forms)
|
||||
implementation(libs.jna)
|
||||
implementation(libs.jna.platform)
|
||||
implementation(libs.versioncompare)
|
||||
implementation(libs.oshi.core)
|
||||
implementation(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") }
|
||||
implementation(libs.jfa) { exclude(group = "*", module = "*") }
|
||||
implementation(libs.jbr.api)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.sshd.core)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.jgit)
|
||||
implementation(libs.jgit.sshd)
|
||||
implementation(libs.jnafilechooser)
|
||||
implementation(libs.xodus.vfs)
|
||||
implementation(libs.xodus.openAPI)
|
||||
implementation(libs.xodus.environment)
|
||||
implementation(libs.bip39)
|
||||
implementation(libs.colorpicker)
|
||||
// implementation(platform(libs.koin.bom))
|
||||
// implementation(libs.koin.core)
|
||||
api(libs.slf4j.api)
|
||||
api(libs.pty4j)
|
||||
api(libs.slf4j.tinylog)
|
||||
api(libs.tinylog.impl)
|
||||
api(libs.commons.codec)
|
||||
api(libs.commons.io)
|
||||
api(libs.commons.lang3)
|
||||
api(libs.commons.csv)
|
||||
api(libs.commons.net)
|
||||
api(libs.commons.text)
|
||||
api(libs.commons.compress)
|
||||
api(libs.commons.vfs2) { exclude(group = "*", module = "*") }
|
||||
api(libs.kotlinx.coroutines.swing)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
|
||||
api(libs.flatlaf) {
|
||||
artifact {
|
||||
if (useNoNativesFlatLaf) {
|
||||
classifier = "no-natives"
|
||||
}
|
||||
}
|
||||
}
|
||||
api(libs.flatlaf.extras) {
|
||||
if (useNoNativesFlatLaf) {
|
||||
exclude(group = "com.formdev", module = "flatlaf")
|
||||
}
|
||||
}
|
||||
api(libs.flatlaf.swingx) {
|
||||
if (useNoNativesFlatLaf) {
|
||||
exclude(group = "com.formdev", module = "flatlaf")
|
||||
}
|
||||
}
|
||||
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.swingx)
|
||||
api(libs.jgoodies.forms)
|
||||
api(libs.jna)
|
||||
api(libs.jna.platform)
|
||||
api(libs.versioncompare)
|
||||
api(libs.oshi.core)
|
||||
api(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") }
|
||||
api(libs.jfa) { exclude(group = "*", module = "*") }
|
||||
api(libs.jbr.api)
|
||||
api(libs.okhttp)
|
||||
api(libs.okhttp.logging)
|
||||
api(libs.sshd.core)
|
||||
api(libs.commonmark)
|
||||
api(libs.jgit)
|
||||
api(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
|
||||
api(libs.jgit.agent) { exclude(group = "*", module = "sshd-osgi") }
|
||||
api(libs.eddsa)
|
||||
api(libs.jnafilechooser)
|
||||
api(libs.xodus.vfs)
|
||||
api(libs.xodus.openAPI)
|
||||
api(libs.xodus.environment)
|
||||
api(libs.bip39)
|
||||
api(libs.colorpicker)
|
||||
api(libs.mixpanel)
|
||||
api(libs.jSerialComm)
|
||||
api(libs.ini4j)
|
||||
api(libs.restart4j)
|
||||
}
|
||||
|
||||
application {
|
||||
val args = mutableListOf(
|
||||
"--add-exports java.base/sun.nio.ch=ALL-UNNAMED",
|
||||
"-Xmx2g",
|
||||
"-XX:+UseZGC",
|
||||
"-XX:+ZUncommit",
|
||||
"-XX:+ZGenerational",
|
||||
"-XX:ZUncommitDelay=60",
|
||||
)
|
||||
|
||||
if (os.isMacOsX) {
|
||||
args.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
|
||||
// macOS NSWindow
|
||||
args.add("--add-opens java.desktop/java.awt=ALL-UNNAMED")
|
||||
args.add("--add-opens java.desktop/sun.lwawt=ALL-UNNAMED")
|
||||
args.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
|
||||
args.add("-Dsun.java2d.metal=true")
|
||||
args.add("-Dapple.awt.application.appearance=system")
|
||||
}
|
||||
@@ -96,13 +148,191 @@ application {
|
||||
mainClass = "app.termora.MainKt"
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("mavenJava") {
|
||||
from(components["java"])
|
||||
pom {
|
||||
name = project.name
|
||||
description = "Termora is a terminal emulator and SSH client for Windows, macOS and Linux"
|
||||
url = "https://github.com/TermoraDev/termora"
|
||||
|
||||
licenses {
|
||||
license {
|
||||
name = "AGPL-3.0"
|
||||
url = "https://opensource.org/license/agpl-v3"
|
||||
}
|
||||
}
|
||||
|
||||
developers {
|
||||
developer {
|
||||
name = "hstyi"
|
||||
url = "https://github.com/hstyi"
|
||||
}
|
||||
}
|
||||
|
||||
scm {
|
||||
url = "https://github.com/TermoraDev/termora"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
tasks.register<Copy>("copy-dependencies") {
|
||||
from(configurations.runtimeClasspath)
|
||||
.into("${layout.buildDirectory.get()}/libs")
|
||||
val dir = layout.buildDirectory.dir("libs")
|
||||
from(configurations.runtimeClasspath).into(dir)
|
||||
val jna = libs.jna.asProvider().get()
|
||||
val pty4j = libs.pty4j.get()
|
||||
val jSerialComm = libs.jSerialComm.get()
|
||||
val restart4j = libs.restart4j.get()
|
||||
|
||||
// 对 JNA 和 PTY4J 的本地库提取
|
||||
// 提取出来是为了单独签名,不然无法通过公证
|
||||
if (os.isMacOsX && macOSSign) {
|
||||
doLast {
|
||||
val archName = if (arch.isArm) "aarch64" else "x86_64"
|
||||
val dylib = dir.get().dir("dylib").asFile
|
||||
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
|
||||
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
|
||||
val targetDir = File(dylib, jna.name)
|
||||
FileUtils.forceMkdir(targetDir)
|
||||
// @formatter:off
|
||||
exec { commandLine("unzip","-j","-o", file.absolutePath, "com/sun/jna/darwin-${arch.name}/*", "-d", targetDir.absolutePath) }
|
||||
// @formatter:on
|
||||
// 删除所有二进制类库
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/darwin-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/sunos-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/openbsd-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/linux-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/freebsd-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/dragonflybsd-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/aix-*") }
|
||||
} else if ("${pty4j.name}-${pty4j.version}" == file.nameWithoutExtension) {
|
||||
val targetDir = FileUtils.getFile(dylib, pty4j.name, "darwin")
|
||||
FileUtils.forceMkdir(targetDir)
|
||||
// @formatter:off
|
||||
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "resources/com/pty4j/native/darwin*", "-d", targetDir.absolutePath) }
|
||||
// @formatter:on
|
||||
// 删除所有二进制类库
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*") }
|
||||
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
|
||||
val targetDir = FileUtils.getFile(dylib, jSerialComm.name, "OSX", archName)
|
||||
FileUtils.forceMkdir(targetDir)
|
||||
// @formatter:off
|
||||
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "OSX/${archName}/*", "-d", targetDir.absolutePath) }
|
||||
// @formatter:on
|
||||
// 删除所有二进制类库
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Android/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "FreeBSD/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Linux/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OpenBSD/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
|
||||
} else if ("${restart4j.name}-${restart4j.version}" == file.nameWithoutExtension) {
|
||||
val targetDir = FileUtils.getFile(dylib, restart4j.name)
|
||||
FileUtils.forceMkdir(targetDir)
|
||||
// @formatter:off
|
||||
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "darwin/${archName}/*", "-d", targetDir.absolutePath) }
|
||||
// @formatter:on
|
||||
// 删除所有二进制类库
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "win32/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "darwin/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "linux/*") }
|
||||
// 设置可执行权限
|
||||
for (e in FileUtils.listFiles(
|
||||
targetDir,
|
||||
FileFilterUtils.trueFileFilter(),
|
||||
FileFilterUtils.falseFileFilter()
|
||||
)) {
|
||||
e.setExecutable(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 对二进制签名
|
||||
Files.walk(dylib.toPath()).use { paths ->
|
||||
for (path in paths) {
|
||||
if (Files.isRegularFile(path)) {
|
||||
signMacOSLocalFile(path.toFile())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (os.isLinux || os.isWindows) { // 缩减安装包
|
||||
doLast {
|
||||
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
|
||||
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/darwin-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/sunos-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/openbsd-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/freebsd-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/dragonflybsd-*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/aix-*") }
|
||||
if (os.isWindows) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/linux-*") }
|
||||
if (arch.isArm) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-x86*") }
|
||||
} else {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-aarch64/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-x86/*") }
|
||||
}
|
||||
} else if (os.isLinux) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "com/sun/jna/win32-*") }
|
||||
}
|
||||
} else if ("${pty4j.name}-${pty4j.version}" == file.nameWithoutExtension) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*darwin*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*freebsd*") }
|
||||
if (os.isWindows) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*linux*") }
|
||||
if (arch.isArm) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86-64*") }
|
||||
} else {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/x86/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win/aarch64/*") }
|
||||
}
|
||||
} else if (os.isLinux) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*win*") }
|
||||
}
|
||||
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Android/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "FreeBSD/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OpenBSD/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
|
||||
if (os.isWindows) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Linux/*") }
|
||||
} else if (os.isLinux) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
|
||||
}
|
||||
} else if ("${restart4j.name}-${restart4j.version}" == file.nameWithoutExtension) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "darwin/*") }
|
||||
if (os.isWindows) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "linux/*") }
|
||||
if (arch.isArm) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "win32/x86_64/*") }
|
||||
} else {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "win32/aarch64/*") }
|
||||
}
|
||||
} else if (os.isLinux) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "win32/*") }
|
||||
if (arch.isArm) {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "linux/x86_64/*") }
|
||||
} else {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "linux/aarch64/*") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<Exec>("jlink") {
|
||||
@@ -134,25 +364,37 @@ tasks.register<Exec>("jlink") {
|
||||
}
|
||||
|
||||
tasks.register<Exec>("jpackage") {
|
||||
|
||||
val buildDir = layout.buildDirectory.get()
|
||||
val options = mutableListOf(
|
||||
"--add-exports java.base/sun.nio.ch=ALL-UNNAMED",
|
||||
"-Xmx2g",
|
||||
"-XX:+UseZGC",
|
||||
"-XX:+ZUncommit",
|
||||
"-XX:+ZGenerational",
|
||||
"-XX:ZUncommitDelay=60",
|
||||
"-XX:+HeapDumpOnOutOfMemoryError",
|
||||
"-Dlogger.console.level=off",
|
||||
"-Dkotlinx.coroutines.debug=off",
|
||||
"-Dapp-version=${project.version}",
|
||||
)
|
||||
|
||||
options.add("-Dsun.java2d.metal=true")
|
||||
|
||||
if (os.isMacOsX) {
|
||||
options.add("-Dsun.java2d.metal=true")
|
||||
// NSWindow
|
||||
options.add("--add-opens java.desktop/java.awt=ALL-UNNAMED")
|
||||
options.add("--add-opens java.desktop/sun.lwawt=ALL-UNNAMED")
|
||||
options.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
|
||||
options.add("-Dapple.awt.application.appearance=system")
|
||||
options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
|
||||
} else {
|
||||
}
|
||||
|
||||
if (os.isLinux) {
|
||||
options.add("-Dsun.java2d.opengl=true")
|
||||
}
|
||||
|
||||
val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage", "--verbose")
|
||||
val arguments = mutableListOf("${Jvm.current().javaHome}/bin/jpackage")
|
||||
arguments.addAll(listOf("--runtime-image", "${buildDir}/jlink"))
|
||||
arguments.addAll(listOf("--name", project.name.uppercaseFirstChar()))
|
||||
arguments.addAll(listOf("--app-version", "${project.version}"))
|
||||
@@ -162,6 +404,19 @@ tasks.register<Exec>("jpackage") {
|
||||
arguments.addAll(listOf("--temp", "$buildDir/jpackage"))
|
||||
arguments.addAll(listOf("--dest", "$buildDir/distributions"))
|
||||
arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE)))
|
||||
arguments.addAll(listOf("--vendor", "TermoraDev"))
|
||||
arguments.addAll(listOf("--copyright", "TermoraDev"))
|
||||
|
||||
if (os.isWindows) {
|
||||
arguments.addAll(
|
||||
listOf(
|
||||
"--description",
|
||||
"${project.name.uppercaseFirstChar()}: A terminal emulator and SSH client"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
arguments.addAll(listOf("--description", "A terminal emulator and SSH client."))
|
||||
}
|
||||
|
||||
|
||||
if (os.isMacOsX) {
|
||||
@@ -179,6 +434,10 @@ tasks.register<Exec>("jpackage") {
|
||||
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.ico"))
|
||||
}
|
||||
|
||||
if (os.isLinux) {
|
||||
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.png"))
|
||||
}
|
||||
|
||||
|
||||
arguments.add("--type")
|
||||
if (os.isMacOsX) {
|
||||
@@ -191,26 +450,29 @@ tasks.register<Exec>("jpackage") {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
if (os.isMacOsX && macOSSign) {
|
||||
arguments.add("--mac-sign")
|
||||
arguments.add("--mac-signing-key-user-name")
|
||||
arguments.add(macOSSignUsername)
|
||||
}
|
||||
|
||||
commandLine(arguments)
|
||||
|
||||
}
|
||||
|
||||
tasks.register("dist") {
|
||||
doLast {
|
||||
val vendor = Jvm.current().vendor ?: StringUtils.EMPTY
|
||||
@Suppress("UnstableApiUsage")
|
||||
if (!JvmVendorSpec.JETBRAINS.matches(vendor)) {
|
||||
throw GradleException("JVM: $vendor is not supported")
|
||||
}
|
||||
|
||||
val distributionDir = layout.buildDirectory.dir("distributions").get()
|
||||
val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath
|
||||
|
||||
// 清空目录
|
||||
exec { commandLine(gradlew, "clean") }
|
||||
|
||||
// 打包并复制依赖
|
||||
exec { commandLine(gradlew, "jar", "copy-dependencies") }
|
||||
exec {
|
||||
commandLine(gradlew, "jar", "copy-dependencies")
|
||||
environment("ENABLE_BUILD" to true)
|
||||
}
|
||||
|
||||
// 检查依赖的开源协议
|
||||
exec { commandLine(gradlew, "check-license") }
|
||||
@@ -221,73 +483,293 @@ tasks.register("dist") {
|
||||
// 打包
|
||||
exec { commandLine(gradlew, "jpackage") }
|
||||
|
||||
// pack
|
||||
exec {
|
||||
if (os.isWindows) { // zip
|
||||
commandLine(
|
||||
"tar", "-vacf",
|
||||
distributionDir.file("${project.name}-${project.version}-windows-${arch.name}.zip").asFile.absolutePath,
|
||||
project.name.uppercaseFirstChar()
|
||||
)
|
||||
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
|
||||
} else if (os.isLinux) { // tar.gz
|
||||
commandLine(
|
||||
"tar", "-czvf",
|
||||
distributionDir.file("${project.name}-${project.version}-linux-${arch.name}.tar.gz").asFile.absolutePath,
|
||||
project.name.uppercaseFirstChar()
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
} else if (os.isMacOsX) { // rename
|
||||
commandLine(
|
||||
"mv",
|
||||
distributionDir.file("${project.name.uppercaseFirstChar()}-${project.version}.dmg").asFile.absolutePath,
|
||||
distributionDir.file("${project.name}-${project.version}-osx-${arch.name}.dmg").asFile.absolutePath,
|
||||
)
|
||||
} else {
|
||||
throw GradleException("${os.name} is not supported")
|
||||
}
|
||||
}
|
||||
// 根据不同的系统构建不同的二进制包
|
||||
pack()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("check-license") {
|
||||
doLast {
|
||||
val thirdParty = mutableMapOf<String, String>()
|
||||
val iterator = File(projectDir, "THIRDPARTY").readLines().iterator()
|
||||
val thirdPartyNames = mutableSetOf<String>()
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
val nameWithVersion = iterator.next()
|
||||
if (nameWithVersion.isBlank()) {
|
||||
val name = iterator.next()
|
||||
if (name.isBlank()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// ignore license name
|
||||
iterator.next()
|
||||
// ignore license url
|
||||
iterator.next()
|
||||
|
||||
val license = iterator.next()
|
||||
thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license
|
||||
thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first())
|
||||
thirdPartyNames.add(name)
|
||||
}
|
||||
|
||||
for (file in configurations.runtimeClasspath.get()) {
|
||||
val name = file.nameWithoutExtension
|
||||
if (!thirdParty.containsKey(name)) {
|
||||
if (logger.isWarnEnabled) {
|
||||
logger.warn("$name does not exist in third-party")
|
||||
}
|
||||
if (!thirdPartyNames.contains(name)) {
|
||||
throw GradleException("$name No license found")
|
||||
}
|
||||
for (dependency in configurations.runtimeClasspath.get().allDependencies) {
|
||||
if (!thirdPartyNames.contains(dependency.name)) {
|
||||
throw GradleException("${dependency.name} No license found")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建包
|
||||
*/
|
||||
fun pack() {
|
||||
val osName = if (os.isMacOsX) "osx" else if (os.isWindows) "windows" else "linux"
|
||||
val distributionDir = layout.buildDirectory.dir("distributions").get()
|
||||
val finalFilenameWithoutExtension = "${project.name}-${project.version}-${osName}-${arch.name}"
|
||||
val projectName = project.name.uppercaseFirstChar()
|
||||
|
||||
if (os.isWindows) {
|
||||
packOnWindows(distributionDir, finalFilenameWithoutExtension, projectName)
|
||||
} else if (os.isLinux) {
|
||||
packOnLinux(distributionDir, finalFilenameWithoutExtension, projectName)
|
||||
} else if (os.isMacOsX) {
|
||||
packOnMac(distributionDir, finalFilenameWithoutExtension, projectName)
|
||||
} else {
|
||||
throw GradleException("${os.name} is not supported")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 zip、7z、msi
|
||||
*/
|
||||
fun packOnWindows(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
|
||||
// zip
|
||||
exec {
|
||||
commandLine(
|
||||
"tar", "-vacf",
|
||||
distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile.absolutePath,
|
||||
projectName
|
||||
)
|
||||
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
|
||||
}
|
||||
|
||||
// exe
|
||||
exec {
|
||||
commandLine(
|
||||
"iscc",
|
||||
"/DMyAppId=${projectName}",
|
||||
"/DMyAppName=${projectName}",
|
||||
"/DMyAppVersion=${project.version}",
|
||||
"/DMyOutputDir=${distributionDir.asFile.absolutePath}",
|
||||
"/DMySetupIconFile=${FileUtils.getFile(projectDir, "src", "main", "resources", "icons", "termora.ico")}",
|
||||
"/DMySourceDir=${layout.buildDirectory.dir("jpackage/images/win-msi.image/${projectName}").get().asFile}",
|
||||
"/F${finalFilenameWithoutExtension}",
|
||||
FileUtils.getFile(projectDir, "src", "main", "resources", "termora.iss")
|
||||
)
|
||||
}
|
||||
|
||||
// msi
|
||||
exec {
|
||||
commandLine(
|
||||
"cmd", "/c", "move",
|
||||
"${projectName}-${project.version}.msi",
|
||||
"${finalFilenameWithoutExtension}.msi"
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对于 macOS 先对 jpackage 构建的 dmg 重命名 -> 签名 -> 公证,另外还会创建一个 zip 包
|
||||
*/
|
||||
fun packOnMac(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
|
||||
val dmgFile = distributionDir.file("${finalFilenameWithoutExtension}.dmg").asFile
|
||||
val zipFile = distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile
|
||||
|
||||
// rename
|
||||
// @formatter:off
|
||||
exec { commandLine("mv", distributionDir.file("${projectName}-${project.version}.dmg").asFile.absolutePath, dmgFile.absolutePath,) }
|
||||
// @formatter:on
|
||||
|
||||
// sign dmg
|
||||
if (macOSSign) signMacOSLocalFile(dmgFile)
|
||||
|
||||
// 找到 .app
|
||||
val imageFile = layout.buildDirectory.dir("jpackage/images/").get().asFile
|
||||
val appFile = imageFile.listFiles()?.firstOrNull()?.listFiles()?.firstOrNull()
|
||||
?: throw FileNotFoundException("${projectName}.app")
|
||||
|
||||
// zip
|
||||
// @formatter:off
|
||||
exec { commandLine("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", appFile.absolutePath, zipFile.absolutePath) }
|
||||
// @formatter:on
|
||||
|
||||
// sign zip
|
||||
if (macOSSign) signMacOSLocalFile(zipFile)
|
||||
|
||||
// 公证
|
||||
if (macOSNotary) {
|
||||
val pool = Executors.newCachedThreadPool()
|
||||
val jobs = mutableListOf<Future<*>>()
|
||||
|
||||
// zip
|
||||
pool.submit {
|
||||
// 对 zip 公证
|
||||
notaryMacOSLocalFile(zipFile)
|
||||
// 对 .app 盖章
|
||||
stapleMacOSLocalFile(appFile)
|
||||
// 删除旧的 zip ,旧的 zip 仅仅是为了公证
|
||||
FileUtils.deleteQuietly(zipFile)
|
||||
// 再对盖完章的 app 打成 zip 包
|
||||
// @formatter:off
|
||||
exec { commandLine("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", appFile.absolutePath, zipFile.absolutePath) }
|
||||
// @formatter:on
|
||||
// 再对 zip 签名
|
||||
signMacOSLocalFile(zipFile)
|
||||
}.apply { jobs.add(this) }
|
||||
|
||||
// dmg
|
||||
pool.submit {
|
||||
// 公证
|
||||
notaryMacOSLocalFile(dmgFile)
|
||||
// 盖章
|
||||
stapleMacOSLocalFile(dmgFile)
|
||||
}.apply { jobs.add(this) }
|
||||
|
||||
// join ...
|
||||
jobs.forEach { it.get() }
|
||||
|
||||
// shutdown
|
||||
pool.shutdown()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 tar.gz 和 AppImage
|
||||
*/
|
||||
fun packOnLinux(distributionDir: Directory, finalFilenameWithoutExtension: String, projectName: String) {
|
||||
// tar.gz
|
||||
exec {
|
||||
commandLine(
|
||||
"tar", "-czvf",
|
||||
distributionDir.file("${finalFilenameWithoutExtension}.tar.gz").asFile.absolutePath,
|
||||
projectName
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
|
||||
|
||||
// AppImage
|
||||
// Download AppImageKit
|
||||
val appimagetool = FileUtils.getFile(projectDir, ".gradle", "appimagetool")
|
||||
if (!appimagetool.exists()) {
|
||||
exec {
|
||||
commandLine(
|
||||
"wget",
|
||||
"-O", appimagetool.absolutePath,
|
||||
"https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-${if (arch.isArm) "aarch64" else "x86_64"}.AppImage"
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
|
||||
// AppImageKit chmod
|
||||
exec { commandLine("chmod", "+x", appimagetool.absolutePath) }
|
||||
}
|
||||
|
||||
|
||||
// Desktop file
|
||||
val termoraName = project.name.uppercaseFirstChar()
|
||||
val desktopFile = distributionDir.file(termoraName + File.separator + termoraName + ".desktop").asFile
|
||||
desktopFile.writeText(
|
||||
"""[Desktop Entry]
|
||||
Type=Application
|
||||
Name=${termoraName}
|
||||
Comment=Terminal emulator and SSH client
|
||||
Icon=/lib/${termoraName}
|
||||
Categories=Development;
|
||||
Terminal=false
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// AppRun file
|
||||
val appRun = File(desktopFile.parentFile, "AppRun")
|
||||
val sb = StringBuilder()
|
||||
sb.append("#!/bin/sh").appendLine()
|
||||
sb.append("SELF=$(readlink -f \"$0\")").appendLine()
|
||||
sb.append("HERE=\${SELF%/*}").appendLine()
|
||||
sb.append("export LinuxAppImage=true").appendLine()
|
||||
sb.append("exec \"\${HERE}/bin/${termoraName}\" \"$@\"")
|
||||
appRun.writeText(sb.toString())
|
||||
appRun.setExecutable(true)
|
||||
|
||||
// AppImage
|
||||
exec {
|
||||
commandLine(appimagetool.absolutePath, termoraName, "${finalFilenameWithoutExtension}.AppImage")
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS 对本地文件进行签名
|
||||
*/
|
||||
fun signMacOSLocalFile(file: File) {
|
||||
if (os.isMacOsX && macOSSign) {
|
||||
if (file.exists() && file.isFile) {
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/codesign",
|
||||
"-s", macOSSignUsername,
|
||||
"--timestamp", "--force",
|
||||
"-vvvv", "--options", "runtime",
|
||||
file.absolutePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS 对本地文件进行公证
|
||||
*/
|
||||
fun notaryMacOSLocalFile(file: File) {
|
||||
if (os.isMacOsX && macOSNotary) {
|
||||
if (file.exists()) {
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/xcrun", "notarytool",
|
||||
"submit", file,
|
||||
"--keychain-profile", macOSNotaryKeychainProfile,
|
||||
"--wait",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 盖章
|
||||
*/
|
||||
fun stapleMacOSLocalFile(file: File) {
|
||||
if (os.isMacOsX && macOSNotary) {
|
||||
if (file.exists()) {
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/xcrun",
|
||||
"stapler", "staple", file,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
@Suppress("UnstableApiUsage")
|
||||
vendor = JvmVendorSpec.JETBRAINS
|
||||
}
|
||||
}
|
||||
|
||||
idea {
|
||||
module {
|
||||
isDownloadJavadoc = true
|
||||
isDownloadSources = true
|
||||
}
|
||||
}
|
||||
BIN
docs/findeverywhere-zh_CN.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 61 KiB |
BIN
docs/readme-zh_CN.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/readme.png
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 96 KiB |
BIN
docs/sftp-command.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/sftp-zh_CN.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/sftp-zh_TW.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
docs/sftp.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
@@ -1,3 +1,4 @@
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
kotlin.code.style=official
|
||||
kotlin.daemon.jvmargs=-Xmx4g
|
||||
@@ -1,44 +1,46 @@
|
||||
[versions]
|
||||
kotlin = "2.1.0"
|
||||
slf4j = "2.0.16"
|
||||
pty4j = "0.13.2"
|
||||
kotlin = "2.1.21"
|
||||
slf4j = "2.0.17"
|
||||
pty4j = "0.13.4"
|
||||
tinylog = "2.7.0"
|
||||
kotlinx-coroutines = "1.10.1"
|
||||
flatlaf = "3.5.4"
|
||||
trove4j = "1.0.20200330"
|
||||
kotlinx-serialization-json = "1.7.3"
|
||||
commons-codec = "1.17.1"
|
||||
kotlinx-coroutines = "1.10.2"
|
||||
flatlaf = "3.6"
|
||||
kotlinx-serialization-json = "1.8.1"
|
||||
commons-codec = "1.18.0"
|
||||
commons-lang3 = "3.17.0"
|
||||
commons-csv = "1.14.0"
|
||||
commons-net = "3.11.1"
|
||||
commons-text = "1.12.0"
|
||||
commons-text = "1.13.1"
|
||||
commons-compress = "1.27.1"
|
||||
koin-bom = "4.0.0"
|
||||
commons-vfs2="2.10.0"
|
||||
swingx = "1.6.5-1"
|
||||
jgoodies-forms = "1.9.0"
|
||||
jfa = "1.2.0"
|
||||
oshi = "6.6.5"
|
||||
oshi = "6.8.1"
|
||||
versioncompare = "1.4.1"
|
||||
jna = "5.16.0"
|
||||
jna = "5.17.0"
|
||||
jSystemThemeDetector = "3.9.1"
|
||||
commons-io = "2.18.0"
|
||||
commons-io = "2.19.0"
|
||||
jbr-api = "17.1.10.1"
|
||||
leveldb = "0.12"
|
||||
guava = "33.3.1-jre"
|
||||
credential-secure-storage = "1.0.3"
|
||||
hutool = "5.8.34"
|
||||
jsch = "0.2.21"
|
||||
hutool = "5.8.37"
|
||||
jsch = "0.2.26"
|
||||
okhttp = "4.12.0"
|
||||
bcprov = "1.79"
|
||||
sshj = "0.39.0"
|
||||
sshd-core = "2.14.0"
|
||||
jgit = "7.1.0.202411261347-r"
|
||||
sshd-core = "2.15.0"
|
||||
jgit = "7.2.0.202503040940-r"
|
||||
commonmark = "0.24.0"
|
||||
jnafilechooser = "1.1.2"
|
||||
xodus = "2.0.1"
|
||||
bip39 = "1.0.8"
|
||||
bip39 = "1.0.9"
|
||||
colorpicker = "2.0.1"
|
||||
rhino = "1.7.15"
|
||||
rhino = "1.8.0"
|
||||
delight-rhino-sandbox = "0.0.17"
|
||||
testcontainers = "1.21.0"
|
||||
mixpanel = "1.5.3"
|
||||
jSerialComm = "2.11.0"
|
||||
ini4j = "0.5.5-2"
|
||||
restart4j = "0.0.1"
|
||||
eddsa = "0.3.0"
|
||||
|
||||
[libraries]
|
||||
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
@@ -50,14 +52,16 @@ tinylog-impl = { group = "org.tinylog", name = "tinylog-impl", version.ref = "ti
|
||||
commons-codec = { group = "commons-codec", name = "commons-codec", version.ref = "commons-codec" }
|
||||
commons-net = { group = "commons-net", name = "commons-net", version.ref = "commons-net" }
|
||||
commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version.ref = "commons-lang3" }
|
||||
commons-csv = { group = "org.apache.commons", name = "commons-csv", version.ref = "commons-csv" }
|
||||
commons-text = { group = "org.apache.commons", name = "commons-text", version.ref = "commons-text" }
|
||||
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" }
|
||||
commons-vfs2 = { group = "org.apache.commons", name = "commons-vfs2", version.ref = "commons-vfs2" }
|
||||
pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" }
|
||||
ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" }
|
||||
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
|
||||
flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" }
|
||||
trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" }
|
||||
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
|
||||
koin-core = { module = "io.insert-koin:koin-core" }
|
||||
testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" }
|
||||
testcontainers = { module = "org.testcontainers:testcontainers" }
|
||||
swingx = { module = "org.swinglabs.swingx:swingx-all", version.ref = "swingx" }
|
||||
jgoodies-forms = { module = "com.jgoodies:jgoodies-forms", version.ref = "jgoodies-forms" }
|
||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||
@@ -67,31 +71,30 @@ versioncompare = { module = "io.github.g00fy2:versioncompare", version.ref = "ve
|
||||
jfa = { module = "de.jangassen:jfa", version.ref = "jfa" }
|
||||
oshi-core = { module = "com.github.oshi:oshi-core", version.ref = "oshi" }
|
||||
commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" }
|
||||
restart4j = { module = "com.github.hstyi:restart4j", version.ref = "restart4j" }
|
||||
jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" }
|
||||
flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" }
|
||||
leveldb = { module = "org.iq80.leveldb:leveldb", version.ref = "leveldb" }
|
||||
guava = { module = "com.google.guava:guava", version.ref = "guava" }
|
||||
hutool = { module = "cn.hutool:hutool-all", version.ref = "hutool" }
|
||||
credential-secure-storage = { module = "com.microsoft:credential-secure-storage", version.ref = "credential-secure-storage" }
|
||||
jsch = { module = "com.github.mwiede:jsch", version.ref = "jsch" }
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||
bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" }
|
||||
sshj = { module = "com.hierynomus:sshj", version.ref = "sshj" }
|
||||
sshd-core = { module = "org.apache.sshd:sshd-core", version.ref = "sshd-core" }
|
||||
jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" }
|
||||
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
|
||||
jgit-sshd = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache", version.ref = "jgit" }
|
||||
jgit-agent = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache.agent", version.ref = "jgit" }
|
||||
xodus-openAPI = { module = "org.jetbrains.xodus:xodus-openAPI", version.ref = "xodus" }
|
||||
xodus-entity-store = { module = "org.jetbrains.xodus:xodus-entity-store", version.ref = "xodus" }
|
||||
xodus-environment = { module = "org.jetbrains.xodus:xodus-environment", version.ref = "xodus" }
|
||||
xodus-crypto = { module = "org.jetbrains.xodus:xodus-crypto", version.ref = "xodus" }
|
||||
xodus-vfs = { module = "org.jetbrains.xodus:xodus-vfs", version.ref = "xodus" }
|
||||
jnafilechooser = { module = "com.github.steos.jnafilechooser:jnafilechooser-api", version.ref = "jnafilechooser" }
|
||||
bip39 = { module = "cash.z.ecc.android:kotlin-bip39-jvm", version.ref = "bip39" }
|
||||
bip39 = { module = "cash.z.ecc.android:kotlin-bip39", version.ref = "bip39" }
|
||||
rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
|
||||
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
|
||||
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
|
||||
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
|
||||
jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" }
|
||||
eddsa = { module = "net.i2p.crypto:eddsa", version.ref = "eddsa" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
|
||||
}
|
||||
rootProject.name = "termora"
|
||||
|
||||
|
||||
70
src/main/java/app/termora/CombinedKeyIdentityProvider.java
Normal 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));
|
||||
}
|
||||
}
|
||||
140
src/main/java/app/termora/MyFlatTabbedPaneUI.java
Normal file
@@ -0,0 +1,140 @@
|
||||
package app.termora;
|
||||
|
||||
import com.formdev.flatlaf.ui.FlatTabbedPaneUI;
|
||||
import com.formdev.flatlaf.ui.FlatUIUtils;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.geom.Path2D;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
|
||||
import static com.formdev.flatlaf.FlatClientProperties.*;
|
||||
import static com.formdev.flatlaf.util.UIScale.scale;
|
||||
|
||||
/**
|
||||
* 如果要升级 FlatLaf 需要检查是否兼容
|
||||
*/
|
||||
@Deprecated
|
||||
public class MyFlatTabbedPaneUI extends FlatTabbedPaneUI {
|
||||
@Override
|
||||
protected void paintContentBorder(Graphics g, int tabPlacement, int selectedIndex) {
|
||||
if (tabPane.getTabCount() <= 0 ||
|
||||
contentSeparatorHeight == 0 ||
|
||||
!clientPropertyBoolean(tabPane, TABBED_PANE_SHOW_CONTENT_SEPARATOR, showContentSeparator))
|
||||
return;
|
||||
|
||||
Insets insets = tabPane.getInsets();
|
||||
Insets tabAreaInsets = getTabAreaInsets(tabPlacement);
|
||||
|
||||
int x = insets.left;
|
||||
int y = insets.top;
|
||||
int w = tabPane.getWidth() - insets.right - insets.left;
|
||||
int h = tabPane.getHeight() - insets.top - insets.bottom;
|
||||
|
||||
// remove tabs from bounds
|
||||
switch (tabPlacement) {
|
||||
case BOTTOM:
|
||||
h -= calculateTabAreaHeight(tabPlacement, runCount, maxTabHeight);
|
||||
h += tabAreaInsets.top;
|
||||
break;
|
||||
|
||||
case LEFT:
|
||||
x += calculateTabAreaWidth(tabPlacement, runCount, maxTabWidth);
|
||||
x -= tabAreaInsets.right;
|
||||
w -= (x - insets.left);
|
||||
break;
|
||||
|
||||
case RIGHT:
|
||||
w -= calculateTabAreaWidth(tabPlacement, runCount, maxTabWidth);
|
||||
w += tabAreaInsets.left;
|
||||
break;
|
||||
|
||||
case TOP:
|
||||
default:
|
||||
y += calculateTabAreaHeight(tabPlacement, runCount, maxTabHeight);
|
||||
y -= tabAreaInsets.bottom;
|
||||
h -= (y - insets.top);
|
||||
break;
|
||||
}
|
||||
|
||||
// compute insets for separator or full border
|
||||
boolean hasFullBorder = clientPropertyBoolean(tabPane, TABBED_PANE_HAS_FULL_BORDER, this.hasFullBorder);
|
||||
int sh = scale(contentSeparatorHeight * 100); // multiply by 100 because rotateInsets() does not use floats
|
||||
Insets ci = new Insets(0, 0, 0, 0);
|
||||
rotateInsets(hasFullBorder ? new Insets(sh, sh, sh, sh) : new Insets(sh, 0, 0, 0), ci, tabPlacement);
|
||||
|
||||
// create path for content separator or full border
|
||||
Path2D path = new Path2D.Float(Path2D.WIND_EVEN_ODD);
|
||||
path.append(new Rectangle2D.Float(x, y, w, h), false);
|
||||
path.append(new Rectangle2D.Float(x + (ci.left / 100f), y + (ci.top / 100f),
|
||||
w - (ci.left / 100f) - (ci.right / 100f), h - (ci.top / 100f) - (ci.bottom / 100f)), false);
|
||||
|
||||
// add gap for selected tab to path
|
||||
if (getTabType() == TAB_TYPE_CARD && selectedIndex >= 0) {
|
||||
float csh = scale((float) contentSeparatorHeight);
|
||||
|
||||
Rectangle tabRect = getTabBounds(tabPane, selectedIndex);
|
||||
boolean componentHasFullBorder = false;
|
||||
if (tabPane.getComponentAt(selectedIndex) instanceof JComponent c) {
|
||||
componentHasFullBorder = c.getClientProperty(TABBED_PANE_HAS_FULL_BORDER) == Boolean.TRUE;
|
||||
}
|
||||
Rectangle2D.Float innerTabRect = new Rectangle2D.Float(tabRect.x + csh, tabRect.y + csh,
|
||||
componentHasFullBorder ? 0 : tabRect.width - (csh * 2), tabRect.height - (csh * 2));
|
||||
|
||||
// Ensure that the separator outside the tabViewport is present (doesn't get cutoff by the active tab)
|
||||
// If left unsolved the active tab is "visible" in the separator (the gap) even when outside the viewport
|
||||
if (tabViewport != null)
|
||||
Rectangle2D.intersect(tabViewport.getBounds(), innerTabRect, innerTabRect);
|
||||
|
||||
Rectangle2D.Float gap = null;
|
||||
if (isHorizontalTabPlacement(tabPlacement)) {
|
||||
if (innerTabRect.width > 0) {
|
||||
float y2 = (tabPlacement == TOP) ? y : y + h - csh;
|
||||
gap = new Rectangle2D.Float(innerTabRect.x, y2, innerTabRect.width, csh);
|
||||
}
|
||||
} else {
|
||||
if (innerTabRect.height > 0) {
|
||||
float x2 = (tabPlacement == LEFT) ? x : x + w - csh;
|
||||
gap = new Rectangle2D.Float(x2, innerTabRect.y, csh, innerTabRect.height);
|
||||
}
|
||||
}
|
||||
|
||||
if (gap != null) {
|
||||
path.append(gap, false);
|
||||
|
||||
// fill gap in case that the tab is colored (e.g. focused or hover)
|
||||
Color background = getTabBackground(tabPlacement, selectedIndex, true);
|
||||
g.setColor(FlatUIUtils.deriveColor(background, tabPane.getBackground()));
|
||||
((Graphics2D) g).fill(gap);
|
||||
}
|
||||
}
|
||||
|
||||
// paint content separator or full border
|
||||
g.setColor(contentAreaColor);
|
||||
((Graphics2D) g).fill(path);
|
||||
|
||||
// repaint selection in scroll-tab-layout because it may be painted before
|
||||
// the content border was painted (from BasicTabbedPaneUI$ScrollableTabPanel)
|
||||
if (isScrollTabLayout() && selectedIndex >= 0 && tabViewport != null) {
|
||||
Rectangle tabRect = getTabBounds(tabPane, selectedIndex);
|
||||
|
||||
// clip to "scrolling sides" of viewport
|
||||
// (left and right if horizontal, top and bottom if vertical)
|
||||
Shape oldClip = g.getClip();
|
||||
Rectangle vr = tabViewport.getBounds();
|
||||
if (isHorizontalTabPlacement(tabPlacement))
|
||||
g.clipRect(vr.x, 0, vr.width, tabPane.getHeight());
|
||||
else
|
||||
g.clipRect(0, vr.y, tabPane.getWidth(), vr.height);
|
||||
|
||||
paintTabSelection(g, tabPlacement, selectedIndex, tabRect.x, tabRect.y, tabRect.width, tabRect.height);
|
||||
g.setClip(oldClip);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private boolean isScrollTabLayout() {
|
||||
return tabPane.getTabLayoutPolicy() == JTabbedPane.SCROLL_TAB_LAYOUT;
|
||||
}
|
||||
|
||||
}
|
||||
38
src/main/java/app/termora/MyKernel32.java
Normal file
@@ -0,0 +1,38 @@
|
||||
package app.termora;
|
||||
|
||||
import com.sun.jna.Native;
|
||||
import com.sun.jna.Pointer;
|
||||
import com.sun.jna.WString;
|
||||
import com.sun.jna.win32.StdCallLibrary;
|
||||
|
||||
interface MyKernel32 extends StdCallLibrary {
|
||||
|
||||
MyKernel32 INSTANCE = Native.load("Kernel32", MyKernel32.class);
|
||||
WString INVARIANT_LOCALE = new WString("");
|
||||
|
||||
int CompareStringEx(WString lpLocaleName,
|
||||
int dwCmpFlags,
|
||||
WString lpString1,
|
||||
int cchCount1,
|
||||
WString lpString2,
|
||||
int cchCount2,
|
||||
Pointer lpVersionInformation,
|
||||
Pointer lpReserved,
|
||||
int lParam);
|
||||
|
||||
default int CompareStringEx(int dwCmpFlags,
|
||||
String str1,
|
||||
String str2) {
|
||||
return MyKernel32.INSTANCE
|
||||
.CompareStringEx(
|
||||
INVARIANT_LOCALE,
|
||||
dwCmpFlags,
|
||||
new WString(str1),
|
||||
str1.length(),
|
||||
new WString(str2),
|
||||
str2.length(),
|
||||
Pointer.NULL,
|
||||
Pointer.NULL,
|
||||
0);
|
||||
}
|
||||
}
|
||||
433
src/main/java/app/termora/SwingUtils.java
Normal file
@@ -0,0 +1,433 @@
|
||||
package app.termora;/*
|
||||
* @(#)SwingUtils.java 1.02 11/15/08
|
||||
*
|
||||
*/
|
||||
//package darrylbu.util;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* A collection of utility methods for Swing.
|
||||
*
|
||||
* @author Darryl Burke
|
||||
*/
|
||||
public final class SwingUtils {
|
||||
|
||||
private SwingUtils() {
|
||||
throw new Error("SwingUtils is just a container for static methods");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for searching below <code>container</code> in the
|
||||
* component hierarchy and return nested components that are instances of
|
||||
* class <code>clazz</code> it finds. Returns an empty list if no such
|
||||
* components exist in the container.
|
||||
* <P>
|
||||
* Invoking this method with a class parameter of JComponent.class
|
||||
* will return all nested components.
|
||||
* <P>
|
||||
* This method invokes getDescendantsOfType(clazz, container, true)
|
||||
*
|
||||
* @param clazz the class of components whose instances are to be found.
|
||||
* @param container the container at which to begin the search
|
||||
* @return the List of components
|
||||
*/
|
||||
public static <T extends JComponent> List<T> getDescendantsOfType(
|
||||
Class<T> clazz, Container container) {
|
||||
return getDescendantsOfType(clazz, container, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for searching below <code>container</code> in the
|
||||
* component hierarchy and return nested components that are instances of
|
||||
* class <code>clazz</code> it finds. Returns an empty list if no such
|
||||
* components exist in the container.
|
||||
* <P>
|
||||
* Invoking this method with a class parameter of JComponent.class
|
||||
* will return all nested components.
|
||||
*
|
||||
* @param clazz the class of components whose instances are to be found.
|
||||
* @param container the container at which to begin the search
|
||||
* @param nested true to list components nested within another listed
|
||||
* component, false otherwise
|
||||
* @return the List of components
|
||||
*/
|
||||
public static <T extends JComponent> List<T> getDescendantsOfType(
|
||||
Class<T> clazz, Container container, boolean nested) {
|
||||
List<T> tList = new ArrayList<T>();
|
||||
for (Component component : container.getComponents()) {
|
||||
if (clazz.isAssignableFrom(component.getClass())) {
|
||||
tList.add(clazz.cast(component));
|
||||
}
|
||||
if (nested || !clazz.isAssignableFrom(component.getClass())) {
|
||||
tList.addAll(SwingUtils.<T>getDescendantsOfType(clazz,
|
||||
(Container) component, nested));
|
||||
}
|
||||
}
|
||||
return tList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method that searches below <code>container</code> in the
|
||||
* component hierarchy and returns the first found component that is an
|
||||
* instance of class <code>clazz</code> having the bound property value.
|
||||
* Returns {@code null} if such component cannot be found.
|
||||
* <P>
|
||||
* This method invokes getDescendantOfType(clazz, container, property, value,
|
||||
* true)
|
||||
*
|
||||
* @param clazz the class of component whose instance is to be found.
|
||||
* @param container the container at which to begin the search
|
||||
* @param property the className of the bound property, exactly as expressed in
|
||||
* the accessor e.g. "Text" for getText(), "Value" for getValue().
|
||||
* @param value the value of the bound property
|
||||
* @return the component, or null if no such component exists in the
|
||||
* container
|
||||
* @throws java.lang.IllegalArgumentException if the bound property does
|
||||
* not exist for the class or cannot be accessed
|
||||
*/
|
||||
public static <T extends JComponent> T getDescendantOfType(
|
||||
Class<T> clazz, Container container, String property, Object value)
|
||||
throws IllegalArgumentException {
|
||||
return getDescendantOfType(clazz, container, property, value, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method that searches below <code>container</code> in the
|
||||
* component hierarchy and returns the first found component that is an
|
||||
* instance of class <code>clazz</code> and has the bound property value.
|
||||
* Returns {@code null} if such component cannot be found.
|
||||
*
|
||||
* @param clazz the class of component whose instance to be found.
|
||||
* @param container the container at which to begin the search
|
||||
* @param property the className of the bound property, exactly as expressed in
|
||||
* the accessor e.g. "Text" for getText(), "Value" for getValue().
|
||||
* @param value the value of the bound property
|
||||
* @param nested true to list components nested within another component
|
||||
* which is also an instance of <code>clazz</code>, false otherwise
|
||||
* @return the component, or null if no such component exists in the
|
||||
* container
|
||||
* @throws java.lang.IllegalArgumentException if the bound property does
|
||||
* not exist for the class or cannot be accessed
|
||||
*/
|
||||
public static <T extends JComponent> T getDescendantOfType(Class<T> clazz,
|
||||
Container container, String property, Object value, boolean nested)
|
||||
throws IllegalArgumentException {
|
||||
List<T> list = getDescendantsOfType(clazz, container, nested);
|
||||
return getComponentFromList(clazz, list, property, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for searching below <code>container</code> in the
|
||||
* component hierarchy and return nested components of class
|
||||
* <code>clazz</code> it finds. Returns an empty list if no such
|
||||
* components exist in the container.
|
||||
* <P>
|
||||
* This method invokes getDescendantsOfClass(clazz, container, true)
|
||||
*
|
||||
* @param clazz the class of components to be found.
|
||||
* @param container the container at which to begin the search
|
||||
* @return the List of components
|
||||
*/
|
||||
public static <T extends JComponent> List<T> getDescendantsOfClass(
|
||||
Class<T> clazz, Container container) {
|
||||
return getDescendantsOfClass(clazz, container, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for searching below <code>container</code> in the
|
||||
* component hierarchy and return nested components of class
|
||||
* <code>clazz</code> it finds. Returns an empty list if no such
|
||||
* components exist in the container.
|
||||
*
|
||||
* @param clazz the class of components to be found.
|
||||
* @param container the container at which to begin the search
|
||||
* @param nested true to list components nested within another listed
|
||||
* component, false otherwise
|
||||
* @return the List of components
|
||||
*/
|
||||
public static <T extends JComponent> List<T> getDescendantsOfClass(
|
||||
Class<T> clazz, Container container, boolean nested) {
|
||||
List<T> tList = new ArrayList<T>();
|
||||
for (Component component : container.getComponents()) {
|
||||
if (clazz.equals(component.getClass())) {
|
||||
tList.add(clazz.cast(component));
|
||||
}
|
||||
if (nested || !clazz.equals(component.getClass())) {
|
||||
tList.addAll(SwingUtils.<T>getDescendantsOfClass(clazz,
|
||||
(Container) component, nested));
|
||||
}
|
||||
}
|
||||
return tList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method that searches below <code>container</code> in the
|
||||
* component hierarchy in a depth first manner and returns the first
|
||||
* found component of class <code>clazz</code> having the bound property
|
||||
* value.
|
||||
* <P>
|
||||
* Returns {@code null} if such component cannot be found.
|
||||
* <P>
|
||||
* This method invokes getDescendantOfClass(clazz, container, property,
|
||||
* value, true)
|
||||
*
|
||||
* @param clazz the class of component to be found.
|
||||
* @param container the container at which to begin the search
|
||||
* @param property the className of the bound property, exactly as expressed in
|
||||
* the accessor e.g. "Text" for getText(), "Value" for getValue().
|
||||
* This parameter is case sensitive.
|
||||
* @param value the value of the bound property
|
||||
* @return the component, or null if no such component exists in the
|
||||
* container's hierarchy.
|
||||
* @throws java.lang.IllegalArgumentException if the bound property does
|
||||
* not exist for the class or cannot be accessed
|
||||
*/
|
||||
public static <T extends JComponent> T getDescendantOfClass(Class<T> clazz,
|
||||
Container container, String property, Object value)
|
||||
throws IllegalArgumentException {
|
||||
return getDescendantOfClass(clazz, container, property, value, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method that searches below <code>container</code> in the
|
||||
* component hierarchy in a depth first manner and returns the first
|
||||
* found component of class <code>clazz</code> having the bound property
|
||||
* value.
|
||||
* <P>
|
||||
* Returns {@code null} if such component cannot be found.
|
||||
*
|
||||
* @param clazz the class of component to be found.
|
||||
* @param container the container at which to begin the search
|
||||
* @param property the className of the bound property, exactly as expressed
|
||||
* in the accessor e.g. "Text" for getText(), "Value" for getValue().
|
||||
* This parameter is case sensitive.
|
||||
* @param value the value of the bound property
|
||||
* @param nested true to include components nested within another listed
|
||||
* component, false otherwise
|
||||
* @return the component, or null if no such component exists in the
|
||||
* container's hierarchy
|
||||
* @throws java.lang.IllegalArgumentException if the bound property does
|
||||
* not exist for the class or cannot be accessed
|
||||
*/
|
||||
public static <T extends JComponent> T getDescendantOfClass(Class<T> clazz,
|
||||
Container container, String property, Object value, boolean nested)
|
||||
throws IllegalArgumentException {
|
||||
List<T> list = getDescendantsOfClass(clazz, container, nested);
|
||||
return getComponentFromList(clazz, list, property, value);
|
||||
}
|
||||
|
||||
private static <T extends JComponent> T getComponentFromList(Class<T> clazz,
|
||||
List<T> list, String property, Object value)
|
||||
throws IllegalArgumentException {
|
||||
T retVal = null;
|
||||
Method method = null;
|
||||
try {
|
||||
method = clazz.getMethod("get" + property);
|
||||
} catch (NoSuchMethodException ex) {
|
||||
try {
|
||||
method = clazz.getMethod("is" + property);
|
||||
} catch (NoSuchMethodException ex1) {
|
||||
throw new IllegalArgumentException("Property " + property +
|
||||
" not found in class " + clazz.getName());
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (T t : list) {
|
||||
Object testVal = method.invoke(t);
|
||||
if (equals(value, testVal)) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
} catch (InvocationTargetException ex) {
|
||||
throw new IllegalArgumentException(
|
||||
"Error accessing property " + property +
|
||||
" in class " + clazz.getName());
|
||||
} catch (IllegalAccessException ex) {
|
||||
throw new IllegalArgumentException(
|
||||
"Property " + property +
|
||||
" cannot be accessed in class " + clazz.getName());
|
||||
} catch (SecurityException ex) {
|
||||
throw new IllegalArgumentException(
|
||||
"Property " + property +
|
||||
" cannot be accessed in class " + clazz.getName());
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for determining whether two objects are either
|
||||
* equal or both null.
|
||||
*
|
||||
* @param obj1 the first reference object to compare.
|
||||
* @param obj2 the second reference object to compare.
|
||||
* @return true if obj1 and obj2 are equal or if both are null,
|
||||
* false otherwise
|
||||
*/
|
||||
public static boolean equals(Object obj1, Object obj2) {
|
||||
return obj1 == null ? obj2 == null : obj1.equals(obj2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for mapping a container in the hierarchy to its
|
||||
* contained components. The keys are the containers, and the values
|
||||
* are lists of contained components.
|
||||
* <P>
|
||||
* Implementation note: The returned value is a HashMap and the values
|
||||
* are of type ArrayList. This is subject to change, so callers should
|
||||
* code against the interfaces Map and List.
|
||||
*
|
||||
* @param container The JComponent to be mapped
|
||||
* @param nested true to drill down to nested containers, false otherwise
|
||||
* @return the Map of the UI
|
||||
*/
|
||||
public static Map<JComponent, List<JComponent>> getComponentMap(
|
||||
JComponent container, boolean nested) {
|
||||
HashMap<JComponent, List<JComponent>> retVal =
|
||||
new HashMap<JComponent, List<JComponent>>();
|
||||
for (JComponent component : getDescendantsOfType(JComponent.class,
|
||||
container, false)) {
|
||||
if (!retVal.containsKey(container)) {
|
||||
retVal.put(container,
|
||||
new ArrayList<JComponent>());
|
||||
}
|
||||
retVal.get(container).add(component);
|
||||
if (nested) {
|
||||
retVal.putAll(getComponentMap(component, nested));
|
||||
}
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for retrieving a subset of the UIDefaults pertaining
|
||||
* to a particular class.
|
||||
*
|
||||
* @param clazz the class of interest
|
||||
* @return the UIDefaults of the class
|
||||
*/
|
||||
public static UIDefaults getUIDefaultsOfClass(Class clazz) {
|
||||
String name = clazz.getName();
|
||||
name = name.substring(name.lastIndexOf(".") + 2);
|
||||
return getUIDefaultsOfClass(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for retrieving a subset of the UIDefaults pertaining
|
||||
* to a particular class.
|
||||
*
|
||||
* @param className fully qualified name of the class of interest
|
||||
* @return the UIDefaults of the class named
|
||||
*/
|
||||
public static UIDefaults getUIDefaultsOfClass(String className) {
|
||||
UIDefaults retVal = new UIDefaults();
|
||||
UIDefaults defaults = UIManager.getLookAndFeelDefaults();
|
||||
List<?> listKeys = Collections.list(defaults.keys());
|
||||
for (Object key : listKeys) {
|
||||
if (key instanceof String && ((String) key).startsWith(className)) {
|
||||
String stringKey = (String) key;
|
||||
String property = stringKey;
|
||||
if (stringKey.contains(".")) {
|
||||
property = stringKey.substring(stringKey.indexOf(".") + 1);
|
||||
}
|
||||
retVal.put(property, defaults.get(key));
|
||||
}
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for retrieving the UIDefault for a single property
|
||||
* of a particular class.
|
||||
*
|
||||
* @param clazz the class of interest
|
||||
* @param property the property to query
|
||||
* @return the UIDefault property, or null if not found
|
||||
*/
|
||||
public static Object getUIDefaultOfClass(Class clazz, String property) {
|
||||
Object retVal = null;
|
||||
UIDefaults defaults = getUIDefaultsOfClass(clazz);
|
||||
List<Object> listKeys = Collections.list(defaults.keys());
|
||||
for (Object key : listKeys) {
|
||||
if (key.equals(property)) {
|
||||
return defaults.get(key);
|
||||
}
|
||||
if (key.toString().equalsIgnoreCase(property)) {
|
||||
retVal = defaults.get(key);
|
||||
}
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude methods that return values that are meaningless to the user
|
||||
*/
|
||||
static Set<String> setExclude = new HashSet<String>();
|
||||
static {
|
||||
setExclude.add("getFocusCycleRootAncestor");
|
||||
setExclude.add("getAccessibleContext");
|
||||
setExclude.add("getColorModel");
|
||||
setExclude.add("getGraphics");
|
||||
setExclude.add("getGraphicsConfiguration");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for obtaining most non-null human readable properties
|
||||
* of a JComponent. Array properties are not included.
|
||||
* <P>
|
||||
* Implementation note: The returned value is a HashMap. This is subject
|
||||
* to change, so callers should code against the interface Map.
|
||||
*
|
||||
* @param component the component whose proerties are to be determined
|
||||
* @return the class and value of the properties
|
||||
*/
|
||||
public static Map<Object, Object> getProperties(JComponent component) {
|
||||
Map<Object, Object> retVal = new HashMap<Object, Object>();
|
||||
Class<?> clazz = component.getClass();
|
||||
Method[] methods = clazz.getMethods();
|
||||
Object value = null;
|
||||
for (Method method : methods) {
|
||||
if (method.getName().matches("^(is|get).*") &&
|
||||
method.getParameterTypes().length == 0) {
|
||||
try {
|
||||
Class returnType = method.getReturnType();
|
||||
if (returnType != void.class &&
|
||||
!returnType.getName().startsWith("[") &&
|
||||
!setExclude.contains(method.getName())) {
|
||||
String key = method.getName();
|
||||
value = method.invoke(component);
|
||||
if (value != null && !(value instanceof Component)) {
|
||||
retVal.put(key, value);
|
||||
}
|
||||
}
|
||||
// ignore exceptions that arise if the property could not be accessed
|
||||
} catch (IllegalAccessException ex) {
|
||||
} catch (IllegalArgumentException ex) {
|
||||
} catch (InvocationTargetException ex) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to obtain the Swing class from which this
|
||||
* component was directly or indirectly derived.
|
||||
*
|
||||
* @param component The component whose Swing superclass is to be
|
||||
* determined
|
||||
* @return The nearest Swing class in the inheritance tree
|
||||
*/
|
||||
public static <T extends JComponent> Class getJClass(T component) {
|
||||
Class<?> clazz = component.getClass();
|
||||
while (!clazz.getName().matches("javax.swing.J[^.]*$")) {
|
||||
clazz = clazz.getSuperclass();
|
||||
}
|
||||
return clazz;
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,10 @@ package app.termora
|
||||
|
||||
object Actions {
|
||||
|
||||
/**
|
||||
* 打开设置
|
||||
*/
|
||||
const val SETTING = "SettingAction"
|
||||
|
||||
/**
|
||||
* 将命令发送到多个会话
|
||||
*/
|
||||
const val MULTIPLE = "MultipleAction"
|
||||
|
||||
/**
|
||||
* 查找
|
||||
*/
|
||||
const val FIND_EVERYWHERE = "FindEverywhereAction"
|
||||
|
||||
/**
|
||||
* 关键词高亮
|
||||
*/
|
||||
const val KEYWORD_HIGHLIGHT_EVERYWHERE = "KeywordHighlightAction"
|
||||
const val KEYWORD_HIGHLIGHT = "KeywordHighlightAction"
|
||||
|
||||
/**
|
||||
* Key manager
|
||||
@@ -38,13 +23,15 @@ object Actions {
|
||||
*/
|
||||
const val MACRO = "MacroAction"
|
||||
|
||||
/**
|
||||
* 添加主机对话框
|
||||
*/
|
||||
const val ADD_HOST = "AddHostAction"
|
||||
|
||||
/**
|
||||
* 打开一个主机
|
||||
* 终端日志记录
|
||||
*/
|
||||
const val OPEN_HOST = "OpenHostAction"
|
||||
const val TERMINAL_LOGGER = "TerminalLogAction"
|
||||
|
||||
/**
|
||||
* 打开 SFTP Tab Action
|
||||
*/
|
||||
const val SFTP = "SFTPAction"
|
||||
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import org.jdesktop.swingx.action.BoundAction
|
||||
import javax.swing.Icon
|
||||
|
||||
abstract class AnAction : BoundAction {
|
||||
|
||||
constructor() : super()
|
||||
constructor(icon: Icon) : super() {
|
||||
super.putValue(SMALL_ICON, icon)
|
||||
}
|
||||
|
||||
constructor(name: String?) : super(name)
|
||||
constructor(name: String?, icon: Icon?) : super(name, icon)
|
||||
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package app.termora
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jthemedetecor.util.OsInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -15,12 +14,14 @@ import org.slf4j.LoggerFactory
|
||||
import java.awt.Desktop
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
|
||||
|
||||
object Application {
|
||||
private val services = Collections.synchronizedMap(mutableMapOf<KClass<*>, Any>())
|
||||
private lateinit var baseDataDir: File
|
||||
|
||||
|
||||
@@ -60,6 +61,16 @@ object Application {
|
||||
return "/bin/bash"
|
||||
}
|
||||
|
||||
fun getTemporaryDir(): File {
|
||||
val temporaryDir = File(getBaseDataDir(), "temporary")
|
||||
FileUtils.forceMkdir(temporaryDir)
|
||||
return temporaryDir
|
||||
}
|
||||
|
||||
fun createSubTemporaryDir(prefix: String = getName()): Path {
|
||||
return Files.createTempDirectory(getTemporaryDir().toPath(), prefix)
|
||||
}
|
||||
|
||||
fun getBaseDataDir(): File {
|
||||
if (::baseDataDir.isInitialized) {
|
||||
return baseDataDir
|
||||
@@ -69,7 +80,7 @@ object Application {
|
||||
var baseDataDir = System.getProperty("${getName()}.base-data-dir".lowercase())
|
||||
// 取不到从环境取
|
||||
if (StringUtils.isBlank(baseDataDir)) {
|
||||
baseDataDir = System.getenv("${getName()}-BASE-DATA-DIR".uppercase())
|
||||
baseDataDir = System.getenv("${getName()}_BASE_DATA_DIR".uppercase())
|
||||
}
|
||||
|
||||
var dir = File(SystemUtils.getUserHome(), ".${getName()}".lowercase())
|
||||
@@ -99,6 +110,10 @@ object Application {
|
||||
return version
|
||||
}
|
||||
|
||||
fun isUnknownVersion(): Boolean {
|
||||
return getVersion().contains("unknown")
|
||||
}
|
||||
|
||||
fun getAppPath(): String {
|
||||
return StringUtils.defaultString(System.getProperty("jpackage.app-path"))
|
||||
}
|
||||
@@ -108,32 +123,22 @@ object Application {
|
||||
}
|
||||
|
||||
fun browse(uri: URI, async: Boolean = true) {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
// https://github.com/TermoraDev/termora/issues/178
|
||||
if (SystemInfo.isWindows && uri.scheme == "file") {
|
||||
if (async) {
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { tryBrowse(uri) }
|
||||
} else {
|
||||
tryBrowse(uri)
|
||||
}
|
||||
} else if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
Desktop.getDesktop().browse(uri)
|
||||
} else if (async) {
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { tryBrowse(uri) }
|
||||
} else {
|
||||
tryBrowse(uri)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T : Any> getService(clazz: KClass<T>): T {
|
||||
if (services.containsKey(clazz)) {
|
||||
return services[clazz] as T
|
||||
}
|
||||
throw IllegalStateException("$clazz does not exist")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun registerService(clazz: KClass<*>, service: Any) {
|
||||
if (services.containsKey(clazz)) {
|
||||
throw IllegalStateException("$clazz already registered")
|
||||
}
|
||||
services[clazz] = service
|
||||
}
|
||||
|
||||
private fun tryBrowse(uri: URI) {
|
||||
if (SystemInfo.isWindows) {
|
||||
ProcessBuilder("explorer", uri.toString()).start()
|
||||
@@ -143,4 +148,61 @@ object Application {
|
||||
ProcessBuilder("xdg-open", uri.toString()).start()
|
||||
}
|
||||
}
|
||||
|
||||
fun browseInFolder(file: File) {
|
||||
if (SystemInfo.isWindows) {
|
||||
ProcessBuilder("explorer", "/select," + file.absolutePath).start()
|
||||
} else if (SystemInfo.isMacOS) {
|
||||
ProcessBuilder("open", "-R", file.absolutePath).start()
|
||||
} else if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
|
||||
Desktop.getDesktop().browseFileDirectory(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
|
||||
val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt()
|
||||
val value = bytes / 1024.0.pow(exp.toDouble())
|
||||
|
||||
return String.format("%.2f%s", value, units[exp])
|
||||
}
|
||||
|
||||
fun formatSeconds(seconds: Long): String {
|
||||
val days = seconds / 86400
|
||||
val hours = (seconds % 86400) / 3600
|
||||
val minutes = (seconds % 3600) / 60
|
||||
val remainingSeconds = seconds % 60
|
||||
|
||||
|
||||
return when {
|
||||
days > 0 -> I18n.getString(
|
||||
"termora.transport.jobs.table.estimated-time-days-format",
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
remainingSeconds
|
||||
)
|
||||
|
||||
hours > 0 -> I18n.getString(
|
||||
"termora.transport.jobs.table.estimated-time-hours-format",
|
||||
hours,
|
||||
minutes,
|
||||
remainingSeconds
|
||||
)
|
||||
|
||||
minutes > 0 -> I18n.getString(
|
||||
"termora.transport.jobs.table.estimated-time-minutes-format",
|
||||
minutes,
|
||||
remainingSeconds
|
||||
)
|
||||
|
||||
else -> I18n.getString(
|
||||
"termora.transport.jobs.table.estimated-time-seconds-format",
|
||||
remainingSeconds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
/**
|
||||
* 将在 JVM 进程退出时释放
|
||||
*/
|
||||
class ApplicationDisposable : Disposable {
|
||||
companion object {
|
||||
val instance by lazy { ApplicationDisposable() }
|
||||
}
|
||||
}
|
||||
91
src/main/kotlin/app/termora/ApplicationInitializr.kt
Normal file
@@ -0,0 +1,91 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.pty4j.util.PtyUtil
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.tinylog.configuration.Configuration
|
||||
import java.io.File
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class ApplicationInitializr {
|
||||
|
||||
fun run() {
|
||||
|
||||
// 由于 macOS 签名和公证问题,依赖二进制依赖会单独在一个文件夹
|
||||
if (SystemUtils.IS_OS_MAC_OSX) {
|
||||
setupNativeLibraries()
|
||||
}
|
||||
|
||||
if (SystemUtils.IS_OS_MAC_OSX) {
|
||||
System.setProperty("apple.awt.application.name", Application.getName())
|
||||
}
|
||||
|
||||
// 设置 tinylog
|
||||
setupTinylog()
|
||||
|
||||
// 检查是否单例
|
||||
checkSingleton()
|
||||
|
||||
// 启动
|
||||
ApplicationRunner().run()
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun setupNativeLibraries() {
|
||||
if (!SystemUtils.IS_OS_MAC_OSX) {
|
||||
return
|
||||
}
|
||||
|
||||
val appPath = Application.getAppPath()
|
||||
if (StringUtils.isBlank(appPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
val contents = File(appPath).parentFile?.parentFile ?: return
|
||||
val dylib = FileUtils.getFile(contents, "app", "dylib")
|
||||
if (!dylib.exists()) {
|
||||
return
|
||||
}
|
||||
|
||||
val jna = FileUtils.getFile(dylib, "jna")
|
||||
if (jna.exists()) {
|
||||
System.setProperty("jna.boot.library.path", jna.absolutePath)
|
||||
}
|
||||
|
||||
val pty4j = FileUtils.getFile(dylib, "pty4j")
|
||||
if (pty4j.exists()) {
|
||||
System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath)
|
||||
}
|
||||
|
||||
val jSerialComm = FileUtils.getFile(dylib, "jSerialComm")
|
||||
if (jSerialComm.exists()) {
|
||||
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
|
||||
}
|
||||
|
||||
val restart4j = FileUtils.getFile(dylib, "restart4j", "restarter")
|
||||
if (restart4j.exists()) {
|
||||
System.setProperty("restarter.path", restart4j.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 情况覆盖
|
||||
*/
|
||||
private fun setupTinylog() {
|
||||
if (SystemInfo.isWindows) {
|
||||
val dir = File(Application.getBaseDataDir(), "logs")
|
||||
FileUtils.forceMkdir(dir)
|
||||
Configuration.set("writer_file.latest", "${dir.absolutePath}/${Application.getName().lowercase()}.log")
|
||||
Configuration.set("writer_file.file", "${dir.absolutePath}/{date:yyyy}-{date:MM}-{date:dd}.log")
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkSingleton() {
|
||||
if (ApplicationSingleton.getInstance().isSingleton()) return
|
||||
System.err.println("Program is already running")
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,117 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.db.Database
|
||||
import app.termora.actions.ActionManager
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.vfs2.sftp.MySftpFileProvider
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatSystemProperties
|
||||
import com.formdev.flatlaf.extras.FlatDesktop
|
||||
import com.formdev.flatlaf.extras.FlatInspector
|
||||
import com.formdev.flatlaf.ui.FlatTableCellBorder
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jthemedetecor.OsThemeDetector
|
||||
import com.sun.jna.platform.WindowUtils
|
||||
import com.sun.jna.platform.win32.User32
|
||||
import com.sun.jna.ptr.IntByReference
|
||||
import com.mixpanel.mixpanelapi.ClientDelivery
|
||||
import com.mixpanel.mixpanelapi.MessageBuilder
|
||||
import com.mixpanel.mixpanelapi.MixpanelAPI
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.LocaleUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.math.NumberUtils
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.cache.WeakRefFilesCache
|
||||
import org.apache.commons.vfs2.impl.DefaultFileSystemManager
|
||||
import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider
|
||||
import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.tinylog.configuration.Configuration
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.FileLock
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.awt.MenuItem
|
||||
import java.awt.PopupMenu
|
||||
import java.awt.SystemTray
|
||||
import java.awt.TrayIcon
|
||||
import java.awt.desktop.AppReopenedEvent
|
||||
import java.awt.desktop.AppReopenedListener
|
||||
import java.awt.desktop.SystemEventListener
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.WindowEvent
|
||||
import java.util.*
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.*
|
||||
import javax.swing.WindowConstants.DISPOSE_ON_CLOSE
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class ApplicationRunner {
|
||||
private lateinit var singletonLock: FileLock
|
||||
private val log by lazy {
|
||||
if (!::singletonLock.isInitialized) {
|
||||
throw UnsupportedOperationException("Singleton lock is not initialized")
|
||||
}
|
||||
LoggerFactory.getLogger("Main")
|
||||
}
|
||||
private val log by lazy { LoggerFactory.getLogger(ApplicationRunner::class.java) }
|
||||
|
||||
fun run() {
|
||||
// 覆盖 tinylog 配置
|
||||
setupTinylog()
|
||||
measureTimeMillis {
|
||||
|
||||
// 是否单例
|
||||
checkSingleton()
|
||||
// 打印系统信息
|
||||
val printSystemInfo = measureTimeMillis { printSystemInfo() }
|
||||
|
||||
// 打印系统信息
|
||||
printSystemInfo()
|
||||
|
||||
SwingUtilities.invokeAndWait {
|
||||
// 打开数据库
|
||||
openDatabase()
|
||||
val openDatabase = measureTimeMillis { openDatabase() }
|
||||
|
||||
// 加载设置
|
||||
loadSettings()
|
||||
val loadSettings = measureTimeMillis { loadSettings() }
|
||||
|
||||
// 统计
|
||||
val enableAnalytics = measureTimeMillis { enableAnalytics() }
|
||||
|
||||
// init ActionManager、KeymapManager、VFS
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
ActionManager.getInstance()
|
||||
KeymapManager.getInstance()
|
||||
|
||||
val fileSystemManager = DefaultFileSystemManager()
|
||||
fileSystemManager.addProvider("sftp", MySftpFileProvider())
|
||||
fileSystemManager.addProvider("file", DefaultLocalFileProvider())
|
||||
fileSystemManager.filesCache = WeakRefFilesCache()
|
||||
fileSystemManager.init()
|
||||
VFS.setManager(fileSystemManager)
|
||||
|
||||
// async init
|
||||
BackgroundManager.getInstance().getBackgroundImage()
|
||||
}
|
||||
|
||||
// 设置 LAF
|
||||
setupLaf()
|
||||
val setupLaf = measureTimeMillis { setupLaf() }
|
||||
|
||||
// 解密数据
|
||||
openDoor()
|
||||
val openDoor = measureTimeMillis { openDoor() }
|
||||
|
||||
// clear temporary
|
||||
clearTemporary()
|
||||
|
||||
// 启动主窗口
|
||||
startMainFrame()
|
||||
val startMainFrame = measureTimeMillis { startMainFrame() }
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("printSystemInfo: {}ms", printSystemInfo)
|
||||
log.debug("openDatabase: {}ms", openDatabase)
|
||||
log.debug("loadSettings: {}ms", loadSettings)
|
||||
log.debug("enableAnalytics: {}ms", enableAnalytics)
|
||||
log.debug("setupLaf: {}ms", setupLaf)
|
||||
log.debug("openDoor: {}ms", openDoor)
|
||||
log.debug("startMainFrame: {}ms", startMainFrame)
|
||||
}
|
||||
}.let {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("run: {}ms", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearTemporary() {
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
// 启动时清除
|
||||
FileUtils.cleanDirectory(Application.getTemporaryDir())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun openDoor() {
|
||||
if (Doorman.instance.isWorking()) {
|
||||
if (Doorman.getInstance().isWorking()) {
|
||||
if (!DoormanDialog(null).open()) {
|
||||
exitProcess(1)
|
||||
}
|
||||
@@ -72,17 +119,74 @@ class ApplicationRunner {
|
||||
}
|
||||
|
||||
private fun startMainFrame() {
|
||||
val frame = TermoraFrame()
|
||||
frame.title = if (SystemInfo.isLinux) null else Application.getName()
|
||||
frame.defaultCloseOperation = DISPOSE_ON_CLOSE
|
||||
frame.setSize(1280, 800)
|
||||
frame.setLocationRelativeTo(null)
|
||||
frame.isVisible = true
|
||||
|
||||
|
||||
TermoraFrameManager.getInstance().createWindow().isVisible = true
|
||||
|
||||
if (SystemInfo.isMacOS) {
|
||||
SwingUtilities.invokeLater {
|
||||
|
||||
try {
|
||||
// 设置 Dock
|
||||
setupMacOSDock()
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Command + Q
|
||||
FlatDesktop.setQuitHandler { quitHandler() }
|
||||
}
|
||||
} else if (SystemInfo.isWindows) {
|
||||
// 设置托盘
|
||||
SwingUtilities.invokeLater { setupSystemTray() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSystemTray() {
|
||||
if (!SystemInfo.isWindows || !SystemTray.isSupported()) return
|
||||
|
||||
val tray = SystemTray.getSystemTray()
|
||||
val image = ImageIO.read(TermoraFrame::class.java.getResourceAsStream("/icons/termora_16x16.png"))
|
||||
val trayIcon = TrayIcon(image)
|
||||
val popupMenu = PopupMenu()
|
||||
trayIcon.popupMenu = popupMenu
|
||||
trayIcon.toolTip = Application.getName()
|
||||
|
||||
// PopupMenu 不支持中文
|
||||
val exitMenu = MenuItem("Exit")
|
||||
exitMenu.addActionListener { SwingUtilities.invokeLater { quitHandler() } }
|
||||
popupMenu.add(exitMenu)
|
||||
|
||||
// double click
|
||||
trayIcon.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
TermoraFrameManager.getInstance().tick()
|
||||
}
|
||||
})
|
||||
|
||||
tray.add(trayIcon)
|
||||
|
||||
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
|
||||
override fun dispose() {
|
||||
tray.remove(trayIcon)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun quitHandler() {
|
||||
val windows = TermoraFrameManager.getInstance().getWindows()
|
||||
|
||||
for (frame in windows) {
|
||||
frame.dispatchEvent(WindowEvent(frame, WindowEvent.WINDOW_CLOSED))
|
||||
}
|
||||
|
||||
Disposer.dispose(TermoraFrameManager.getInstance())
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val language = Database.instance.appearance.language
|
||||
val language = Database.getDatabase().appearance.language
|
||||
val locale = runCatching { LocaleUtils.toLocale(language) }.getOrElse { Locale.getDefault() }
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Language: {} , Locale: {}", language, locale)
|
||||
@@ -93,7 +197,7 @@ class ApplicationRunner {
|
||||
|
||||
private fun setupLaf() {
|
||||
|
||||
System.setProperty(FlatSystemProperties.USE_WINDOW_DECORATIONS, "${SystemInfo.isLinux}")
|
||||
System.setProperty(FlatSystemProperties.USE_WINDOW_DECORATIONS, "${SystemInfo.isLinux || SystemInfo.isWindows}")
|
||||
System.setProperty(FlatSystemProperties.USE_ROUNDED_POPUP_BORDER, "false")
|
||||
|
||||
if (SystemInfo.isLinux) {
|
||||
@@ -101,22 +205,23 @@ class ApplicationRunner {
|
||||
JDialog.setDefaultLookAndFeelDecorated(true)
|
||||
}
|
||||
|
||||
val themeManager = ThemeManager.instance
|
||||
val settings = Database.instance
|
||||
var theme = settings.appearance.theme
|
||||
|
||||
// 如果是跟随系统或者不存在样式,那么使用默认的
|
||||
if (settings.appearance.followSystem || !themeManager.themes.containsKey(theme)) {
|
||||
val themeManager = ThemeManager.getInstance()
|
||||
val appearance = Database.getDatabase().appearance
|
||||
var theme = appearance.theme
|
||||
// 如果是跟随系统
|
||||
if (appearance.followSystem) {
|
||||
theme = if (OsThemeDetector.getDetector().isDark) {
|
||||
"Dark"
|
||||
appearance.darkTheme
|
||||
} else {
|
||||
"Light"
|
||||
appearance.lightTheme
|
||||
}
|
||||
}
|
||||
|
||||
themeManager.change(theme, true)
|
||||
|
||||
FlatInspector.install("ctrl shift alt X");
|
||||
|
||||
if (Application.isUnknownVersion())
|
||||
FlatInspector.install("ctrl shift alt X")
|
||||
|
||||
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
|
||||
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)
|
||||
@@ -147,9 +252,8 @@ class ApplicationRunner {
|
||||
}
|
||||
|
||||
UIManager.put("Table.rowHeight", 24)
|
||||
UIManager.put("Table.cellNoFocusBorder", BorderFactory.createEmptyBorder())
|
||||
UIManager.put("Table.focusCellHighlightBorder", BorderFactory.createEmptyBorder())
|
||||
UIManager.put("Table.focusSelectedCellHighlightBorder", BorderFactory.createEmptyBorder())
|
||||
UIManager.put("Table.focusCellHighlightBorder", FlatTableCellBorder.Default())
|
||||
UIManager.put("Table.focusSelectedCellHighlightBorder", FlatTableCellBorder.Default())
|
||||
UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc"))
|
||||
|
||||
UIManager.put("Tree.rowHeight", 24)
|
||||
@@ -160,85 +264,60 @@ class ApplicationRunner {
|
||||
|
||||
UIManager.put("List.selectionArc", UIManager.getInt("Component.arc"))
|
||||
|
||||
}
|
||||
|
||||
private fun setupMacOSDock() {
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
val cls = Class.forName("com.apple.eawt.Application")
|
||||
val app = cls.getMethod("getApplication").invoke(null)
|
||||
val addAppEventListener = cls.getMethod("addAppEventListener", SystemEventListener::class.java)
|
||||
|
||||
addAppEventListener.invoke(app, object : AppReopenedListener {
|
||||
override fun appReopened(e: AppReopenedEvent) {
|
||||
val manager = TermoraFrameManager.getInstance()
|
||||
if (manager.getWindows().isEmpty()) {
|
||||
manager.createWindow().isVisible = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 当应用程序销毁时,驻守线程也可以退出了
|
||||
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
|
||||
override fun dispose() {
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
})
|
||||
|
||||
// 驻守线程,不然当所有窗口都关闭时,程序会自动退出
|
||||
// wait application exit
|
||||
Thread.ofPlatform().daemon(false)
|
||||
.priority(Thread.MIN_PRIORITY)
|
||||
.start { countDownLatch.await() }
|
||||
}
|
||||
|
||||
private fun printSystemInfo() {
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Welcome to ${Application.getName()} ${Application.getVersion()}!")
|
||||
log.info(
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Welcome to ${Application.getName()} ${Application.getVersion()}!")
|
||||
log.debug(
|
||||
"JVM name: {} , vendor: {} , version: {}",
|
||||
SystemUtils.JAVA_VM_NAME,
|
||||
SystemUtils.JAVA_VM_VENDOR,
|
||||
SystemUtils.JAVA_VM_VERSION,
|
||||
)
|
||||
log.info(
|
||||
log.debug(
|
||||
"OS name: {} , version: {} , arch: {}",
|
||||
SystemUtils.OS_NAME,
|
||||
SystemUtils.OS_VERSION,
|
||||
SystemUtils.OS_ARCH
|
||||
)
|
||||
log.info("Base config dir: ${Application.getBaseDataDir().absolutePath}")
|
||||
log.debug("Base config dir: ${Application.getBaseDataDir().absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Windows 情况覆盖
|
||||
*/
|
||||
private fun setupTinylog() {
|
||||
if (SystemInfo.isWindows) {
|
||||
val dir = File(Application.getBaseDataDir(), "logs")
|
||||
FileUtils.forceMkdir(dir)
|
||||
Configuration.set("writer_file.latest", "${dir.absolutePath}/${Application.getName().lowercase()}.log")
|
||||
Configuration.set("writer_file.file", "${dir.absolutePath}/{date:yyyy}-{date:MM}-{date:dd}.log")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun checkSingleton() {
|
||||
val file = File(Application.getBaseDataDir(), "lock")
|
||||
val pidFile = File(Application.getBaseDataDir(), "pid")
|
||||
|
||||
|
||||
val raf = RandomAccessFile(file, "rw")
|
||||
val lock = raf.channel.tryLock()
|
||||
|
||||
if (lock != null) {
|
||||
pidFile.writeText(ProcessHandle.current().pid().toString())
|
||||
pidFile.deleteOnExit()
|
||||
file.deleteOnExit()
|
||||
} else {
|
||||
if (SystemInfo.isWindows && pidFile.exists()) {
|
||||
val pid = NumberUtils.toLong(pidFile.readText())
|
||||
for (window in WindowUtils.getAllWindows(false)) {
|
||||
if (pid > 0) {
|
||||
val processId = IntByReference()
|
||||
User32.INSTANCE.GetWindowThreadProcessId(window.hwnd, processId)
|
||||
if (processId.value.toLong() != pid) {
|
||||
continue
|
||||
}
|
||||
} else if (window.title != Application.getName() || window.filePath.endsWith("explorer.exe")) {
|
||||
continue
|
||||
}
|
||||
User32.INSTANCE.ShowWindow(window.hwnd, User32.SW_SHOWNOACTIVATE)
|
||||
User32.INSTANCE.SetForegroundWindow(window.hwnd)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
System.err.println("Program is already running")
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
singletonLock = lock
|
||||
}
|
||||
|
||||
|
||||
private fun openDatabase() {
|
||||
val dir = Application.getDatabaseFile()
|
||||
try {
|
||||
Database.open(dir)
|
||||
Database.getDatabase()
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
@@ -251,4 +330,47 @@ class ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计 https://mixpanel.com
|
||||
*/
|
||||
private fun enableAnalytics() {
|
||||
if (Application.isUnknownVersion()) {
|
||||
return
|
||||
}
|
||||
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val properties = JSONObject()
|
||||
properties.put("os", SystemUtils.OS_NAME)
|
||||
if (SystemInfo.isLinux) {
|
||||
properties.put("platform", "Linux")
|
||||
} else if (SystemInfo.isWindows) {
|
||||
properties.put("platform", "Windows")
|
||||
} else if (SystemInfo.isMacOS) {
|
||||
properties.put("platform", "macOS")
|
||||
}
|
||||
properties.put("version", Application.getVersion())
|
||||
properties.put("language", Locale.getDefault().toString())
|
||||
val message = MessageBuilder("0871335f59ee6d0eb246b008a20f9d1c")
|
||||
.event(getAnalyticsUserID(), "launch", properties)
|
||||
val delivery = ClientDelivery()
|
||||
delivery.addMessage(message)
|
||||
MixpanelAPI().deliver(delivery, true)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAnalyticsUserID(): String {
|
||||
var id = Database.getDatabase().properties.getString("AnalyticsUserID")
|
||||
if (id.isNullOrBlank()) {
|
||||
id = UUID.randomUUID().toSimpleString()
|
||||
Database.getDatabase().properties.putString("AnalyticsUserID", id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
}
|
||||
201
src/main/kotlin/app/termora/ApplicationSingleton.kt
Normal file
@@ -0,0 +1,201 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.sun.jna.platform.win32.Kernel32
|
||||
import com.sun.jna.platform.win32.User32
|
||||
import com.sun.jna.platform.win32.WinDef.*
|
||||
import com.sun.jna.platform.win32.WinError
|
||||
import com.sun.jna.platform.win32.WinUser.*
|
||||
import com.sun.jna.platform.win32.Wtsapi32
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.FileLock
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class ApplicationSingleton private constructor() : Disposable {
|
||||
|
||||
@Volatile
|
||||
private var isSingleton = null as Boolean?
|
||||
|
||||
|
||||
companion object {
|
||||
fun getInstance(): ApplicationSingleton {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(ApplicationSingleton::class) { ApplicationSingleton() }
|
||||
}
|
||||
}
|
||||
|
||||
fun isSingleton(): Boolean {
|
||||
var singleton = this.isSingleton
|
||||
if (singleton != null) return singleton
|
||||
|
||||
try {
|
||||
synchronized(this) {
|
||||
singleton = this.isSingleton
|
||||
if (singleton != null) return singleton as Boolean
|
||||
|
||||
if (SystemInfo.isWindows) {
|
||||
val handle = Kernel32.INSTANCE.CreateMutex(null, false, Application.getName())
|
||||
singleton = handle != null && Kernel32.INSTANCE.GetLastError() != WinError.ERROR_ALREADY_EXISTS
|
||||
if (singleton == true) {
|
||||
// 启动监听器,方便激活窗口
|
||||
Thread.ofVirtual().start(Win32HelperWindow.getInstance())
|
||||
} else {
|
||||
// 尝试激活窗口
|
||||
Win32HelperWindow.tick()
|
||||
}
|
||||
} else {
|
||||
singleton = FileLocker.getInstance().tryLock()
|
||||
}
|
||||
|
||||
this.isSingleton = singleton == true
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace(System.err)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return this.isSingleton == true
|
||||
|
||||
}
|
||||
|
||||
private class FileLocker private constructor() {
|
||||
companion object {
|
||||
fun getInstance(): FileLocker {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(FileLocker::class) { FileLocker() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private lateinit var singletonChannel: FileChannel
|
||||
private lateinit var singletonLock: FileLock
|
||||
|
||||
|
||||
fun tryLock(): Boolean {
|
||||
singletonChannel = FileChannel.open(
|
||||
Paths.get(Application.getBaseDataDir().absolutePath, "lock"),
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.WRITE,
|
||||
)
|
||||
|
||||
val lock = singletonChannel.tryLock() ?: return false
|
||||
|
||||
this.singletonLock = lock
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class Win32HelperWindow private constructor() : Runnable {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(Win32HelperWindow::class.java)
|
||||
private val WindowClass = "${Application.getName()}HelperWindowClass"
|
||||
private val WindowName =
|
||||
"${Application.getName()} hidden helper window, used only to catch the windows events"
|
||||
private const val TICK: Int = WM_USER + 1
|
||||
|
||||
fun getInstance(): Win32HelperWindow {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(Win32HelperWindow::class) { Win32HelperWindow() }
|
||||
}
|
||||
|
||||
|
||||
fun tick() {
|
||||
val hWnd = User32.INSTANCE.FindWindow(WindowClass, WindowName) ?: return
|
||||
User32.INSTANCE.SendMessage(hWnd, TICK, WPARAM(), LPARAM())
|
||||
}
|
||||
}
|
||||
|
||||
private val isRunning = AtomicBoolean(false)
|
||||
|
||||
override fun run() {
|
||||
if (SystemInfo.isWindows) {
|
||||
if (isRunning.compareAndSet(false, true)) {
|
||||
Win32Window()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class Win32Window : WindowProc {
|
||||
/**
|
||||
* Instantiates a new win32 window test.
|
||||
*/
|
||||
init {
|
||||
// define new window class
|
||||
val hInst = Kernel32.INSTANCE.GetModuleHandle(null)
|
||||
|
||||
val wClass = WNDCLASSEX()
|
||||
wClass.hInstance = hInst
|
||||
wClass.lpfnWndProc = this
|
||||
wClass.lpszClassName = WindowClass
|
||||
|
||||
// register window class
|
||||
User32.INSTANCE.RegisterClassEx(wClass)
|
||||
|
||||
// create new window
|
||||
val hWnd = User32.INSTANCE.CreateWindowEx(
|
||||
User32.WS_EX_TOPMOST,
|
||||
WindowClass,
|
||||
WindowName,
|
||||
0, 0, 0, 0, 0,
|
||||
null, // WM_DEVICECHANGE contradicts parent=WinUser.HWND_MESSAGE
|
||||
null, hInst, null
|
||||
)
|
||||
|
||||
|
||||
val msg = MSG()
|
||||
while (User32.INSTANCE.GetMessage(msg, hWnd, 0, 0) > 0) {
|
||||
User32.INSTANCE.TranslateMessage(msg)
|
||||
User32.INSTANCE.DispatchMessage(msg)
|
||||
}
|
||||
|
||||
Wtsapi32.INSTANCE.WTSUnRegisterSessionNotification(hWnd)
|
||||
User32.INSTANCE.UnregisterClass(WindowClass, hInst)
|
||||
User32.INSTANCE.DestroyWindow(hWnd)
|
||||
|
||||
}
|
||||
|
||||
override fun callback(hwnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT {
|
||||
when (uMsg) {
|
||||
WM_CREATE -> {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("win32 helper window created")
|
||||
}
|
||||
return LRESULT()
|
||||
}
|
||||
|
||||
TICK -> {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("win32 helper window tick")
|
||||
}
|
||||
onTick()
|
||||
return LRESULT()
|
||||
}
|
||||
|
||||
WM_DESTROY -> {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("win32 helper window destroyed")
|
||||
}
|
||||
User32.INSTANCE.PostQuitMessage(0)
|
||||
return LRESULT()
|
||||
}
|
||||
|
||||
else -> return User32.INSTANCE.DefWindowProc(hwnd, uMsg, wParam, lParam)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onTick() {
|
||||
TermoraFrameManager.getInstance().tick()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/main/kotlin/app/termora/BackgroundManager.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,6 @@ class ChannelShellPtyConnector(
|
||||
output.flush()
|
||||
}
|
||||
|
||||
override fun write(buffer: String) {
|
||||
write(buffer.toByteArray(charset))
|
||||
}
|
||||
|
||||
override fun resize(rows: Int, cols: Int) {
|
||||
channel.sendWindowChange(cols, rows)
|
||||
}
|
||||
@@ -38,4 +34,8 @@ class ChannelShellPtyConnector(
|
||||
override fun close() {
|
||||
channel.close(true)
|
||||
}
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return charset
|
||||
}
|
||||
}
|
||||
374
src/main/kotlin/app/termora/CustomizeToolBarDialog.kt
Normal file
@@ -0,0 +1,374 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import javax.swing.*
|
||||
import javax.swing.event.ListDataEvent
|
||||
import javax.swing.event.ListDataListener
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class CustomizeToolBarDialog(
|
||||
owner: Window,
|
||||
private val toolbar: TermoraToolBar
|
||||
) : DialogWrapper(owner) {
|
||||
|
||||
private val moveTopBtn = JButton(Icons.moveUp)
|
||||
private val moveBottomBtn = JButton(Icons.moveDown)
|
||||
private val upBtn = JButton(Icons.up)
|
||||
private val downBtn = JButton(Icons.down)
|
||||
|
||||
private val leftBtn = JButton(Icons.left)
|
||||
private val rightBtn = JButton(Icons.right)
|
||||
private val resetBtn = JButton(Icons.refresh)
|
||||
private val allToLeftBtn = JButton(Icons.applyNotConflictsRight)
|
||||
private val allToRightBtn = JButton(Icons.applyNotConflictsLeft)
|
||||
|
||||
private val leftList = ToolBarActionList()
|
||||
private val rightList = ToolBarActionList()
|
||||
private val actionManager get() = ActionManager.getInstance()
|
||||
|
||||
private var isOk = false
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 150, UIManager.getInt("Dialog.height") - 100)
|
||||
isModal = true
|
||||
controlsVisible = false
|
||||
isResizable = false
|
||||
title = I18n.getString("termora.toolbar.customize-toolbar")
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
moveTopBtn.isEnabled = false
|
||||
moveBottomBtn.isEnabled = false
|
||||
downBtn.isEnabled = false
|
||||
upBtn.isEnabled = false
|
||||
|
||||
leftBtn.isEnabled = false
|
||||
rightBtn.isEnabled = false
|
||||
|
||||
initEvents()
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
|
||||
allToLeftBtn.isEnabled = !rightList.model.isEmpty
|
||||
allToRightBtn.isEnabled = !leftList.model.isEmpty
|
||||
|
||||
val box = JToolBar(JToolBar.VERTICAL)
|
||||
box.add(Box.createVerticalStrut(leftList.fixedCellHeight))
|
||||
box.add(rightBtn)
|
||||
box.add(leftBtn)
|
||||
box.add(Box.createVerticalGlue())
|
||||
box.add(resetBtn)
|
||||
box.add(Box.createVerticalGlue())
|
||||
box.add(allToRightBtn)
|
||||
box.add(allToLeftBtn)
|
||||
box.add(Box.createVerticalStrut(leftList.fixedCellHeight))
|
||||
|
||||
val box2 = JToolBar(JToolBar.VERTICAL)
|
||||
box2.add(Box.createVerticalStrut(leftList.fixedCellHeight))
|
||||
box2.add(moveTopBtn)
|
||||
box2.add(upBtn)
|
||||
box2.add(Box.createVerticalGlue())
|
||||
box2.add(downBtn)
|
||||
box2.add(moveBottomBtn)
|
||||
box2.add(Box.createVerticalStrut(leftList.fixedCellHeight))
|
||||
|
||||
|
||||
return FormBuilder.create().debug(false)
|
||||
.border(BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor))
|
||||
.layout(FormLayout("default:grow, pref, default:grow, pref", "fill:p:grow"))
|
||||
.add(JScrollPane(leftList).apply {
|
||||
border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
|
||||
}).xy(1, 1)
|
||||
.add(box).xy(2, 1)
|
||||
.add(JScrollPane(rightList).apply {
|
||||
border = BorderFactory.createMatteBorder(0, 1, 0, 1, DynamicColor.BorderColor)
|
||||
}).xy(3, 1)
|
||||
.add(box2).xy(4, 1)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
rightList.addListSelectionListener { resetMoveButtons() }
|
||||
|
||||
leftList.addListSelectionListener {
|
||||
val indices = leftList.selectedIndices
|
||||
rightBtn.isEnabled = indices.isNotEmpty()
|
||||
}
|
||||
|
||||
leftList.model.addListDataListener(object : ListDataListener {
|
||||
override fun intervalAdded(e: ListDataEvent) {
|
||||
contentsChanged(e)
|
||||
}
|
||||
|
||||
override fun intervalRemoved(e: ListDataEvent) {
|
||||
contentsChanged(e)
|
||||
}
|
||||
|
||||
override fun contentsChanged(e: ListDataEvent) {
|
||||
allToLeftBtn.isEnabled = !rightList.model.isEmpty
|
||||
allToRightBtn.isEnabled = !leftList.model.isEmpty
|
||||
resetMoveButtons()
|
||||
}
|
||||
})
|
||||
|
||||
rightList.model.addListDataListener(object : ListDataListener {
|
||||
override fun intervalAdded(e: ListDataEvent) {
|
||||
contentsChanged(e)
|
||||
}
|
||||
|
||||
override fun intervalRemoved(e: ListDataEvent) {
|
||||
contentsChanged(e)
|
||||
}
|
||||
|
||||
override fun contentsChanged(e: ListDataEvent) {
|
||||
allToLeftBtn.isEnabled = !rightList.model.isEmpty
|
||||
allToRightBtn.isEnabled = !leftList.model.isEmpty
|
||||
resetMoveButtons()
|
||||
}
|
||||
})
|
||||
|
||||
resetBtn.addActionListener {
|
||||
leftList.model.removeAllElements()
|
||||
rightList.model.removeAllElements()
|
||||
for (action in toolbar.getAllActions()) {
|
||||
actionManager.getAction(action.id)?.let {
|
||||
rightList.model.addElement(ActionHolder(action.id, it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// move first
|
||||
moveTopBtn.addActionListener {
|
||||
val indices = rightList.selectedIndices.sortedDescending()
|
||||
rightList.clearSelection()
|
||||
for (index in indices.indices) {
|
||||
val ele = rightList.model.getElementAt(indices[index])
|
||||
rightList.model.removeElementAt(indices[index])
|
||||
rightList.model.add(index, ele)
|
||||
rightList.selectionModel.addSelectionInterval(index, max(index - 1, 0))
|
||||
}
|
||||
}
|
||||
|
||||
// move up
|
||||
upBtn.addActionListener {
|
||||
val indices = rightList.selectedIndices.sortedDescending()
|
||||
rightList.clearSelection()
|
||||
for (index in indices) {
|
||||
val ele = rightList.model.getElementAt(index)
|
||||
rightList.model.removeElementAt(index)
|
||||
rightList.model.add(index - 1, ele)
|
||||
rightList.selectionModel.addSelectionInterval(max(index - 1, 0), max(index - 1, 0))
|
||||
}
|
||||
}
|
||||
|
||||
// move down
|
||||
downBtn.addActionListener {
|
||||
val indices = rightList.selectedIndices.sortedDescending()
|
||||
rightList.clearSelection()
|
||||
for (index in indices) {
|
||||
val ele = rightList.model.getElementAt(index)
|
||||
rightList.model.removeElementAt(index)
|
||||
rightList.model.add(index + 1, ele)
|
||||
rightList.selectionModel.addSelectionInterval(index + 1, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// move last
|
||||
moveBottomBtn.addActionListener {
|
||||
val indices = rightList.selectedIndices.sortedDescending()
|
||||
val size = rightList.model.size
|
||||
rightList.clearSelection()
|
||||
for (index in indices.indices) {
|
||||
val ele = rightList.model.getElementAt(indices[index])
|
||||
rightList.model.removeElementAt(indices[index])
|
||||
rightList.model.add(size - index - 1, ele)
|
||||
rightList.selectionModel.addSelectionInterval(size - index - 1, size - index - 1)
|
||||
}
|
||||
}
|
||||
|
||||
allToLeftBtn.addActionListener {
|
||||
while (!rightList.model.isEmpty) {
|
||||
val ele = rightList.model.getElementAt(0)
|
||||
rightList.model.removeElementAt(0)
|
||||
leftList.model.addElement(ele)
|
||||
}
|
||||
}
|
||||
|
||||
allToRightBtn.addActionListener {
|
||||
while (!leftList.model.isEmpty) {
|
||||
val ele = leftList.model.getElementAt(0)
|
||||
leftList.model.removeElementAt(0)
|
||||
rightList.model.addElement(ele)
|
||||
}
|
||||
}
|
||||
|
||||
leftBtn.addActionListener {
|
||||
val indices = rightList.selectedIndices.sortedDescending()
|
||||
for (index in indices) {
|
||||
val ele = rightList.model.getElementAt(index)
|
||||
rightList.model.removeElementAt(index)
|
||||
leftList.model.addElement(ele)
|
||||
}
|
||||
rightList.clearSelection()
|
||||
val index = min(indices.max(), rightList.model.size - 1)
|
||||
if (!rightList.model.isEmpty) {
|
||||
rightList.addSelectionInterval(index, index)
|
||||
}
|
||||
}
|
||||
|
||||
rightBtn.addActionListener {
|
||||
val indices = leftList.selectedIndices.sortedDescending()
|
||||
val rightSelectedIndex = if (rightList.selectedIndices.isEmpty()) rightList.model.size else
|
||||
rightList.selectionModel.maxSelectionIndex + 1
|
||||
|
||||
if (indices.isNotEmpty()) {
|
||||
for (index in indices.indices) {
|
||||
val ele = leftList.model.getElementAt(indices[index])
|
||||
leftList.model.removeElementAt(indices[index])
|
||||
rightList.model.add(rightSelectedIndex + index, ele)
|
||||
}
|
||||
|
||||
leftList.clearSelection()
|
||||
val index = min(indices.max(), leftList.model.size - 1)
|
||||
if (!leftList.model.isEmpty) {
|
||||
leftList.addSelectionInterval(index, index)
|
||||
}
|
||||
|
||||
rightList.clearSelection()
|
||||
rightList.addSelectionInterval(rightSelectedIndex, rightSelectedIndex)
|
||||
}
|
||||
}
|
||||
|
||||
addWindowListener(object : WindowAdapter() {
|
||||
override fun windowOpened(e: WindowEvent) {
|
||||
removeWindowListener(this)
|
||||
|
||||
|
||||
for (action in toolbar.getActions()) {
|
||||
if (action.visible) {
|
||||
actionManager.getAction(action.id)
|
||||
?.let { rightList.model.addElement(ActionHolder(action.id, it)) }
|
||||
} else {
|
||||
actionManager.getAction(action.id)
|
||||
?.let { leftList.model.addElement(ActionHolder(action.id, it)) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun resetMoveButtons() {
|
||||
val indices = rightList.selectedIndices
|
||||
if (indices.isEmpty()) {
|
||||
moveTopBtn.isEnabled = false
|
||||
moveBottomBtn.isEnabled = false
|
||||
downBtn.isEnabled = false
|
||||
upBtn.isEnabled = false
|
||||
} else {
|
||||
moveTopBtn.isEnabled = !indices.contains(0)
|
||||
upBtn.isEnabled = moveTopBtn.isEnabled
|
||||
moveBottomBtn.isEnabled = !indices.contains(rightList.model.size - 1)
|
||||
downBtn.isEnabled = moveBottomBtn.isEnabled
|
||||
}
|
||||
leftBtn.isEnabled = indices.isNotEmpty()
|
||||
}
|
||||
|
||||
private class ToolBarActionList : JList<ActionHolder>() {
|
||||
private val model = DefaultListModel<ActionHolder>()
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
setModel(model)
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
|
||||
background = UIManager.getColor("window")
|
||||
fixedCellHeight = UIManager.getInt("Tree.rowHeight")
|
||||
cellRenderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
var text = value?.toString() ?: StringUtils.EMPTY
|
||||
if (value is ActionHolder) {
|
||||
val action = value.action
|
||||
text = action.getValue(Action.NAME)?.toString() ?: text
|
||||
}
|
||||
|
||||
val c = super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
|
||||
if (value is ActionHolder) {
|
||||
val action = value.action
|
||||
val icon = action.getValue(Action.SMALL_ICON) as Icon?
|
||||
if (icon != null) {
|
||||
this.icon = icon
|
||||
if (icon is DynamicIcon) {
|
||||
if (isSelected && cellHasFocus) {
|
||||
this.icon = icon.dark
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
}
|
||||
|
||||
override fun getModel(): DefaultListModel<ActionHolder> {
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
isOk = true
|
||||
|
||||
val actions = mutableListOf<ToolBarAction>()
|
||||
for (i in 0 until rightList.model.size()) {
|
||||
actions.add(ToolBarAction(rightList.model.getElementAt(i).id, true))
|
||||
}
|
||||
|
||||
for (i in 0 until leftList.model.size()) {
|
||||
actions.add(ToolBarAction(leftList.model.getElementAt(i).id, false))
|
||||
}
|
||||
|
||||
Database.getDatabase()
|
||||
.properties.putString("Termora.ToolBar.Actions", ohMyJson.encodeToString(actions))
|
||||
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
fun open(): Boolean {
|
||||
isModal = true
|
||||
isVisible = true
|
||||
return isOk
|
||||
}
|
||||
|
||||
private class ActionHolder(val id: String, val action: Action)
|
||||
}
|
||||
@@ -1,49 +1,40 @@
|
||||
package app.termora.db
|
||||
package app.termora
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.highlight.KeywordHighlight
|
||||
import app.termora.keymap.Keymap
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import app.termora.macro.Macro
|
||||
import app.termora.snippet.Snippet
|
||||
import app.termora.sync.SyncManager
|
||||
import app.termora.sync.SyncType
|
||||
import app.termora.terminal.CursorStyle
|
||||
import jetbrains.exodus.bindings.StringBinding
|
||||
import jetbrains.exodus.env.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.set
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class Database private constructor(private val env: Environment) : Disposable {
|
||||
companion object {
|
||||
private const val KEYMAP_STORE = "Keymap"
|
||||
private const val HOST_STORE = "Host"
|
||||
private const val SNIPPET_STORE = "Snippet"
|
||||
private const val KEYWORD_HIGHLIGHT_STORE = "KeywordHighlight"
|
||||
private const val MACRO_STORE = "Macro"
|
||||
private const val KEY_PAIR_STORE = "KeyPair"
|
||||
private const val DELETED_DATA_STORE = "DeletedData"
|
||||
private val log = LoggerFactory.getLogger(Database::class.java)
|
||||
private lateinit var database: Database
|
||||
|
||||
val instance by lazy {
|
||||
if (!::database.isInitialized) {
|
||||
throw UnsupportedOperationException("Database has not been initialized!")
|
||||
}
|
||||
database
|
||||
}
|
||||
|
||||
fun open(dir: File) {
|
||||
if (::database.isInitialized) {
|
||||
throw UnsupportedOperationException("Database is already open")
|
||||
}
|
||||
private fun open(dir: File): Database {
|
||||
val config = EnvironmentConfig()
|
||||
// 32MB
|
||||
config.setLogFileSize(1024 * 32)
|
||||
@@ -51,8 +42,12 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
// 5m
|
||||
config.setGcStartIn(5.minutes.inWholeMilliseconds.toInt())
|
||||
val environment = Environments.newInstance(dir, config)
|
||||
database = Database(environment)
|
||||
Disposer.register(ApplicationDisposable.instance, database)
|
||||
return Database(environment)
|
||||
}
|
||||
|
||||
fun getDatabase(): Database {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(Database::class) { open(Application.getDatabaseFile()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +55,44 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
val safetyProperties by lazy { SafetyProperties("Setting.SafetyProperties") }
|
||||
val terminal by lazy { Terminal() }
|
||||
val appearance by lazy { Appearance() }
|
||||
val sftp by lazy { SFTP() }
|
||||
val sync by lazy { Sync() }
|
||||
|
||||
private val doorman get() = Doorman.instance
|
||||
private val doorman get() = Doorman.getInstance()
|
||||
|
||||
|
||||
fun getKeymaps(): Collection<Keymap> {
|
||||
val array = env.computeInTransaction { tx ->
|
||||
openCursor<String>(tx, KEYMAP_STORE) { _, value ->
|
||||
value
|
||||
}.values
|
||||
}
|
||||
|
||||
val keymaps = mutableListOf<Keymap>()
|
||||
for (text in array.iterator()) {
|
||||
keymaps.add(Keymap.fromJSON(text) ?: continue)
|
||||
}
|
||||
|
||||
return keymaps
|
||||
}
|
||||
|
||||
fun addKeymap(keymap: Keymap) {
|
||||
env.executeInTransaction {
|
||||
put(it, KEYMAP_STORE, keymap.name, keymap.toJSON())
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added Keymap: ${keymap.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeKeymap(name: String) {
|
||||
env.executeInTransaction {
|
||||
delete(it, KEYMAP_STORE, name)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed Keymap: $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getHosts(): Collection<Host> {
|
||||
@@ -77,17 +107,6 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllHost() {
|
||||
env.executeInTransaction { tx ->
|
||||
val store = env.openStore(HOST_STORE, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
|
||||
store.openCursor(tx).use {
|
||||
while (it.next) {
|
||||
it.deleteCurrent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllKeyPair() {
|
||||
env.executeInTransaction { tx ->
|
||||
val store = env.openStore(KEY_PAIR_STORE, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx)
|
||||
@@ -128,11 +147,67 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
env.executeInTransaction {
|
||||
delete(it, HOST_STORE, id)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed Host: $id")
|
||||
log.debug("Removed host: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addDeletedData(deletedData: DeletedData) {
|
||||
val text = ohMyJson.encodeToString(deletedData)
|
||||
env.executeInTransaction {
|
||||
put(it, DELETED_DATA_STORE, deletedData.id, text)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added DeletedData: ${deletedData.id} , $text")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getDeletedData(): Collection<DeletedData> {
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<DeletedData?>(tx, DELETED_DATA_STORE) { _, value ->
|
||||
try {
|
||||
ohMyJson.decodeFromString(value)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}.values.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun addSnippet(snippet: Snippet) {
|
||||
var text = ohMyJson.encodeToString(snippet)
|
||||
if (doorman.isWorking()) {
|
||||
text = doorman.encrypt(text)
|
||||
}
|
||||
env.executeInTransaction {
|
||||
put(it, SNIPPET_STORE, snippet.id, text)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added Snippet: ${snippet.id} , ${snippet.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSnippet(id: String) {
|
||||
env.executeInTransaction {
|
||||
delete(it, SNIPPET_STORE, id)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed snippet: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSnippets(): Collection<Snippet> {
|
||||
val isWorking = doorman.isWorking()
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<Snippet>(tx, SNIPPET_STORE) { _, value ->
|
||||
if (isWorking)
|
||||
ohMyJson.decodeFromString(doorman.decrypt(value))
|
||||
else
|
||||
ohMyJson.decodeFromString(value)
|
||||
}.values
|
||||
}
|
||||
}
|
||||
|
||||
fun getKeywordHighlights(): Collection<KeywordHighlight> {
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<KeywordHighlight>(tx, KEYWORD_HIGHLIGHT_STORE) { _, value ->
|
||||
@@ -214,6 +289,18 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
val k = StringBinding.stringToEntry(key)
|
||||
val v = StringBinding.stringToEntry(value)
|
||||
store.put(tx, k, v)
|
||||
|
||||
// 数据变动时触发一次同步
|
||||
if (name == HOST_STORE ||
|
||||
name == KEYMAP_STORE ||
|
||||
name == SNIPPET_STORE ||
|
||||
name == KEYWORD_HIGHLIGHT_STORE ||
|
||||
name == MACRO_STORE ||
|
||||
name == KEY_PAIR_STORE ||
|
||||
name == DELETED_DATA_STORE
|
||||
) {
|
||||
SyncManager.getInstance().triggerOnChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete(tx: Transaction, name: String, key: String) {
|
||||
@@ -273,8 +360,7 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
private val properties = Collections.synchronizedMap(mutableMapOf<String, String>())
|
||||
|
||||
init {
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) { properties.putAll(getProperties()) }
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { properties.putAll(getProperties()) }
|
||||
}
|
||||
|
||||
protected open fun getString(key: String): String? {
|
||||
@@ -346,6 +432,13 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class DoublePropertyDelegate(defaultValue: Double) :
|
||||
PropertyDelegate<Double>(defaultValue) {
|
||||
override fun convertValue(value: String): Double {
|
||||
return value.toDoubleOrNull() ?: initializer.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected inner class LongPropertyDelegate(defaultValue: Long) :
|
||||
PropertyDelegate<Long>(defaultValue) {
|
||||
@@ -372,10 +465,10 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
protected inner class CursorStylePropertyDelegate(defaultValue: CursorStyle) :
|
||||
PropertyDelegate<CursorStyle>(defaultValue) {
|
||||
override fun convertValue(value: String): CursorStyle {
|
||||
try {
|
||||
return CursorStyle.valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
return initializer.invoke()
|
||||
return try {
|
||||
CursorStyle.valueOf(value)
|
||||
} catch (_: Exception) {
|
||||
initializer.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,7 +506,7 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
/**
|
||||
* 字体大小
|
||||
*/
|
||||
var fontSize by IntPropertyDelegate(16)
|
||||
var fontSize by IntPropertyDelegate(14)
|
||||
|
||||
/**
|
||||
* 最大行数
|
||||
@@ -425,6 +518,21 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
*/
|
||||
var debug by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 蜂鸣声
|
||||
*/
|
||||
var beep by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 超链接
|
||||
*/
|
||||
var hyperlink by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 光标闪烁
|
||||
*/
|
||||
var cursorBlink by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 选中复制
|
||||
*/
|
||||
@@ -434,6 +542,16 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
* 光标样式
|
||||
*/
|
||||
var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
|
||||
|
||||
/**
|
||||
* 终端断开连接时自动关闭Tab
|
||||
*/
|
||||
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 是否显示悬浮工具栏
|
||||
*/
|
||||
var floatingToolbar by BooleanPropertyDelegate(true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,7 +577,7 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
* 安全的通用属性
|
||||
*/
|
||||
open inner class SafetyProperties(name: String) : Property(name) {
|
||||
private val doorman get() = Doorman.instance
|
||||
private val doorman get() = Doorman.getInstance()
|
||||
|
||||
public override fun getString(key: String): String? {
|
||||
var value = super.getString(key)
|
||||
@@ -522,6 +640,18 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
* 跟随系统
|
||||
*/
|
||||
var followSystem by BooleanPropertyDelegate(true)
|
||||
var darkTheme by StringPropertyDelegate("Dark")
|
||||
var lightTheme by StringPropertyDelegate("Light")
|
||||
|
||||
/**
|
||||
* 允许后台运行,也就是托盘
|
||||
*/
|
||||
var backgroundRunning by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 背景图片的地址
|
||||
*/
|
||||
var backgroundImage by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* 语言
|
||||
@@ -530,6 +660,46 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
I18n.containsLanguage(Locale.getDefault()) ?: Locale.US.toString()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 透明度
|
||||
*/
|
||||
var opacity by DoublePropertyDelegate(1.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* SFTP
|
||||
*/
|
||||
inner class SFTP : Property("Setting.SFTP") {
|
||||
|
||||
|
||||
/**
|
||||
* 编辑命令
|
||||
*/
|
||||
var editCommand by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
|
||||
/**
|
||||
* sftp command
|
||||
*/
|
||||
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* defaultDirectory
|
||||
*/
|
||||
var defaultDirectory by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
|
||||
/**
|
||||
* 是否固定在标签栏
|
||||
*/
|
||||
var pinTab by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 是否保留原始文件时间
|
||||
*/
|
||||
var preserveModificationTime by BooleanPropertyDelegate(false)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -546,8 +716,10 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
*/
|
||||
var rangeHosts by BooleanPropertyDelegate(true)
|
||||
var rangeKeyPairs by BooleanPropertyDelegate(true)
|
||||
var rangeSnippets by BooleanPropertyDelegate(true)
|
||||
var rangeKeywordHighlights by BooleanPropertyDelegate(true)
|
||||
var rangeMacros by BooleanPropertyDelegate(true)
|
||||
var rangeKeymap by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* Token
|
||||
@@ -568,6 +740,11 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
* 最后同步时间
|
||||
*/
|
||||
var lastSyncTime by LongPropertyDelegate(0L)
|
||||
|
||||
/**
|
||||
* 同步策略,为空就是默认手动
|
||||
*/
|
||||
var policy by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
52
src/main/kotlin/app/termora/DeleteDataManager.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,124 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.native.osx.NativeMacLibrary
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jetbrains.JBR
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.*
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import javax.swing.*
|
||||
|
||||
abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
private val rootPanel = JPanel(BorderLayout())
|
||||
private val titleLabel = JLabel()
|
||||
private val titleBar by lazy { LogicCustomTitleBar.createCustomTitleBar(this) }
|
||||
val disposable = Disposer.newDisposable()
|
||||
private val customTitleBar = if (SystemInfo.isMacOS && JBR.isWindowDecorationsSupported())
|
||||
JBR.getWindowDecorations().createCustomTitleBar() else null
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_ACTION = "DEFAULT_ACTION"
|
||||
private const val PROCESS_GLOBAL_KEYMAP = "PROCESS_GLOBAL_KEYMAP"
|
||||
}
|
||||
|
||||
|
||||
protected var controlsVisible = true
|
||||
set(value) {
|
||||
field = value
|
||||
titleBar.putProperty("controls.visible", value)
|
||||
if (SystemInfo.isMacOS) {
|
||||
if (customTitleBar != null) {
|
||||
customTitleBar.putProperty("controls.visible", value)
|
||||
} else {
|
||||
NativeMacLibrary.setControlsVisible(this, value)
|
||||
}
|
||||
} else {
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICONIFFY, value)
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_MAXIMIZE, value)
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_CLOSE, value)
|
||||
}
|
||||
}
|
||||
|
||||
protected var titleBarHeight = UIManager.getInt("TabbedPane.tabHeight").toFloat()
|
||||
protected var fullWindowContent = false
|
||||
set(value) {
|
||||
titleBar.height = value
|
||||
field = value
|
||||
rootPane.putClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT, value)
|
||||
}
|
||||
|
||||
protected var titleVisible = true
|
||||
set(value) {
|
||||
field = value
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, value)
|
||||
}
|
||||
|
||||
protected var titleIconVisible = false
|
||||
set(value) {
|
||||
field = value
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICON, value)
|
||||
}
|
||||
|
||||
|
||||
protected var titleBarHeight = UIManager.getInt("TabbedPane.tabHeight")
|
||||
set(value) {
|
||||
field = value
|
||||
if (SystemInfo.isMacOS) {
|
||||
customTitleBar?.height = height.toFloat()
|
||||
} else {
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, value)
|
||||
}
|
||||
}
|
||||
|
||||
protected var lostFocusDispose = false
|
||||
protected var escapeDispose = true
|
||||
|
||||
protected fun init() {
|
||||
|
||||
defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||
|
||||
initTitleBar()
|
||||
initEvents()
|
||||
|
||||
if (JBR.isWindowDecorationsSupported()) {
|
||||
if (rootPane.getClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE) != false) {
|
||||
val titlePanel = createTitlePanel()
|
||||
if (titlePanel != null) {
|
||||
rootPanel.add(titlePanel, BorderLayout.NORTH)
|
||||
}
|
||||
var processGlobalKeymap: Boolean
|
||||
get() {
|
||||
val v = super.rootPane.getClientProperty(PROCESS_GLOBAL_KEYMAP)
|
||||
if (v is Boolean) {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
protected set(value) {
|
||||
super.rootPane.putClientProperty(PROCESS_GLOBAL_KEYMAP, value)
|
||||
}
|
||||
|
||||
init {
|
||||
super.setDefaultCloseOperation(DISPOSE_ON_CLOSE)
|
||||
|
||||
// 使用 FlatLaf 的 TitlePane
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
rootPane.windowDecorationStyle = JRootPane.PLAIN_DIALOG
|
||||
}
|
||||
}
|
||||
|
||||
protected fun init() {
|
||||
initEvents()
|
||||
|
||||
val rootPanel = JPanel(BorderLayout())
|
||||
rootPanel.add(createCenterPanel(), BorderLayout.CENTER)
|
||||
|
||||
if (SystemInfo.isMacOS) {
|
||||
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
|
||||
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
|
||||
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
|
||||
rootPane.putClientProperty(
|
||||
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING,
|
||||
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING_MEDIUM
|
||||
)
|
||||
|
||||
val titlePanel = createTitlePanel()
|
||||
if (titlePanel != null) {
|
||||
rootPanel.add(titlePanel, BorderLayout.NORTH)
|
||||
}
|
||||
|
||||
val customTitleBar = this.customTitleBar
|
||||
if (customTitleBar != null) {
|
||||
customTitleBar.putProperty("controls.visible", controlsVisible)
|
||||
customTitleBar.height = titleBarHeight.toFloat()
|
||||
JBR.getWindowDecorations().setCustomTitleBar(this, customTitleBar)
|
||||
}
|
||||
}
|
||||
|
||||
val southPanel = createSouthPanel()
|
||||
if (southPanel != null) {
|
||||
rootPanel.add(southPanel, BorderLayout.SOUTH)
|
||||
@@ -71,17 +133,23 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
|
||||
BorderFactory.createEmptyBorder(8, 12, 8, 12)
|
||||
)
|
||||
|
||||
val okButton = createJButtonForAction(createOkAction())
|
||||
box.add(Box.createHorizontalGlue())
|
||||
box.add(createJButtonForAction(CancelAction()))
|
||||
box.add(Box.createHorizontalStrut(8))
|
||||
box.add(okButton)
|
||||
|
||||
val actions = createActions()
|
||||
for (i in actions.size - 1 downTo 0) {
|
||||
box.add(createJButtonForAction(actions[i]))
|
||||
if (i != 0) {
|
||||
box.add(Box.createHorizontalStrut(8))
|
||||
}
|
||||
}
|
||||
|
||||
return box
|
||||
}
|
||||
|
||||
protected open fun createActions(): List<AbstractAction> {
|
||||
return listOf(createOkAction(), CancelAction())
|
||||
}
|
||||
|
||||
protected open fun createOkAction(): AbstractAction {
|
||||
return OkAction()
|
||||
}
|
||||
@@ -103,7 +171,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
|
||||
val panel = JPanel(BorderLayout())
|
||||
panel.add(titleLabel, BorderLayout.CENTER)
|
||||
panel.preferredSize = Dimension(-1, titleBar.height.toInt())
|
||||
panel.preferredSize = Dimension(-1, titleBarHeight)
|
||||
|
||||
|
||||
return panel
|
||||
@@ -126,8 +194,35 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_W, toolkit.menuShortcutKeyMaskEx), "close")
|
||||
|
||||
rootPane.actionMap.put("close", object : AnAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
doCancelAction()
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val c = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
|
||||
val popups: List<JPopupMenu> = SwingUtils.getDescendantsOfType(
|
||||
JPopupMenu::class.java,
|
||||
c as Container, true
|
||||
)
|
||||
|
||||
var openPopup = false
|
||||
for (p in popups) {
|
||||
p.isVisible = false
|
||||
openPopup = true
|
||||
}
|
||||
|
||||
val window = c as? Window ?: SwingUtilities.windowForComponent(c)
|
||||
if (window != null) {
|
||||
val windows = window.ownedWindows
|
||||
for (w in windows) {
|
||||
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
|
||||
openPopup = true
|
||||
w.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openPopup) {
|
||||
return
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater { doCancelAction() }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -145,30 +240,20 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
}
|
||||
})
|
||||
|
||||
if (SystemInfo.isWindows) {
|
||||
addWindowListener(object : WindowAdapter(), ThemeChangeListener {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
ThemeManager.instance.removeThemeChangeListener(this)
|
||||
}
|
||||
|
||||
override fun windowOpened(e: WindowEvent) {
|
||||
onChanged()
|
||||
ThemeManager.instance.addThemeChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onChanged() {
|
||||
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun initTitleBar() {
|
||||
titleBar.height = titleBarHeight
|
||||
titleBar.putProperty("controls.visible", controlsVisible)
|
||||
if (JBR.isWindowDecorationsSupported()) {
|
||||
JBR.getWindowDecorations().setCustomTitleBar(this, titleBar)
|
||||
override fun addNotify() {
|
||||
super.addNotify()
|
||||
|
||||
// 显示后触发一次重绘制
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
this.controlsVisible = controlsVisible
|
||||
this.titleBarHeight = titleBarHeight
|
||||
this.titleIconVisible = titleIconVisible
|
||||
this.titleVisible = titleVisible
|
||||
this.fullWindowContent = fullWindowContent
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected open fun doOKAction() {
|
||||
@@ -184,7 +269,8 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
putValue(DEFAULT_ACTION, true)
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
doOKAction()
|
||||
}
|
||||
|
||||
@@ -192,7 +278,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
|
||||
protected inner class CancelAction : AnAction(I18n.getString("termora.cancel")) {
|
||||
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
doCancelAction()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,17 @@ package app.termora
|
||||
|
||||
import app.termora.AES.decodeBase64
|
||||
import app.termora.AES.encodeBase64String
|
||||
import app.termora.db.Database
|
||||
|
||||
class PasswordWrongException : RuntimeException()
|
||||
|
||||
class Doorman private constructor() {
|
||||
private val properties get() = Database.instance.properties
|
||||
class Doorman private constructor() : Disposable {
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private var key = byteArrayOf()
|
||||
|
||||
companion object {
|
||||
val instance by lazy { Doorman() }
|
||||
fun getInstance(): Doorman {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(Doorman::class) { Doorman() }
|
||||
}
|
||||
}
|
||||
|
||||
fun isWorking(): Boolean {
|
||||
@@ -82,4 +83,8 @@ class Doorman private constructor() {
|
||||
checkIsWorking()
|
||||
return key.contentEquals(convertKey(password))
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
key = byteArrayOf()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.AES.decodeBase64
|
||||
import app.termora.db.Database
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.terminal.ControlCharacters
|
||||
import cash.z.ecc.android.bip39.Mnemonics
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
@@ -17,7 +18,6 @@ import org.slf4j.LoggerFactory
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.KeyAdapter
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.imageio.ImageIO
|
||||
@@ -95,7 +95,7 @@ class DoormanDialog(owner: Window?) : DialogWrapper(owner) {
|
||||
.add(safeBtn).xy(4, rows).apply { rows += step }
|
||||
.add(tip).xyw(2, rows, 4, "center, fill").apply { rows += step }
|
||||
.add(JXHyperlink(object : AnAction(I18n.getString("termora.doorman.forget-password")) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val option = OptionPane.showConfirmDialog(
|
||||
this@DoormanDialog, I18n.getString("termora.doorman.forget-password-message"),
|
||||
options = arrayOf(
|
||||
@@ -130,10 +130,11 @@ class DoormanDialog(owner: Window?) : DialogWrapper(owner) {
|
||||
}
|
||||
|
||||
try {
|
||||
val keyBackup = Database.instance.properties.getString("doorman-key-backup")
|
||||
val keyBackup = Database.getDatabase()
|
||||
.properties.getString("doorman-key-backup")
|
||||
?: throw IllegalStateException("doorman-key-backup is null")
|
||||
val key = AES.ECB.decrypt(entropy, keyBackup.decodeBase64())
|
||||
Doorman.instance.work(key)
|
||||
Doorman.getInstance().work(key)
|
||||
} catch (e: Exception) {
|
||||
OptionPane.showMessageDialog(
|
||||
this, I18n.getString("termora.doorman.mnemonic-data-corrupted"),
|
||||
@@ -157,7 +158,7 @@ class DoormanDialog(owner: Window?) : DialogWrapper(owner) {
|
||||
}
|
||||
|
||||
try {
|
||||
Doorman.instance.work(passwordTextField.password)
|
||||
Doorman.getInstance().work(passwordTextField.password)
|
||||
} catch (e: Exception) {
|
||||
if (e is PasswordWrongException) {
|
||||
OptionPane.showMessageDialog(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
||||
init {
|
||||
generalOption.portTextField.value = host.port
|
||||
@@ -10,15 +10,14 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
||||
generalOption.protocolTypeComboBox.selectedItem = host.protocol
|
||||
generalOption.usernameTextField.text = host.username
|
||||
generalOption.hostTextField.text = host.host
|
||||
generalOption.passwordTextField.text = host.authentication.password
|
||||
generalOption.remarkTextArea.text = host.remark
|
||||
generalOption.authenticationTypeComboBox.selectedItem = host.authentication.type
|
||||
if (host.authentication.type == AuthenticationType.PublicKey) {
|
||||
val ohKeyPair = KeyManager.instance.getOhKeyPair(host.authentication.password)
|
||||
if (ohKeyPair != null) {
|
||||
generalOption.publicKeyTextField.text = ohKeyPair.name
|
||||
generalOption.publicKeyTextField.putClientProperty(OhKeyPair::class, ohKeyPair)
|
||||
}
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
generalOption.passwordTextField.text = host.authentication.password
|
||||
} else if (host.authentication.type == AuthenticationType.PublicKey) {
|
||||
generalOption.publicKeyComboBox.selectedItem = host.authentication.password
|
||||
} else if (host.authentication.type == AuthenticationType.SSHAgent) {
|
||||
generalOption.sshAgentComboBox.selectedItem = host.authentication.password
|
||||
}
|
||||
|
||||
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
|
||||
@@ -31,8 +30,32 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
||||
terminalOption.charsetComboBox.selectedItem = host.options.encoding
|
||||
terminalOption.environmentTextArea.text = host.options.env
|
||||
terminalOption.startupCommandTextField.text = host.options.startupCommand
|
||||
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
|
||||
|
||||
tunnelingOption.tunnelings.addAll(host.tunnelings)
|
||||
tunnelingOption.x11ForwardingCheckBox.isSelected = host.options.enableX11Forwarding
|
||||
tunnelingOption.x11ServerTextField.text = StringUtils.defaultIfBlank(host.options.x11Forwarding, "localhost:0")
|
||||
|
||||
if (host.options.jumpHosts.isNotEmpty()) {
|
||||
val hosts = HostManager.getInstance().hosts().associateBy { it.id }
|
||||
for (id in host.options.jumpHosts) {
|
||||
jumpHostsOption.jumpHosts.add(hosts[id] ?: continue)
|
||||
}
|
||||
}
|
||||
|
||||
jumpHostsOption.filter = { it.id != host.id }
|
||||
|
||||
val serialComm = host.options.serialComm
|
||||
if (serialComm.port.isNotBlank()) {
|
||||
serialCommOption.serialPortComboBox.selectedItem = serialComm.port
|
||||
}
|
||||
serialCommOption.baudRateComboBox.selectedItem = serialComm.baudRate
|
||||
serialCommOption.dataBitsComboBox.selectedItem = serialComm.dataBits
|
||||
serialCommOption.parityComboBox.selectedItem = serialComm.parity
|
||||
serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits
|
||||
serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl
|
||||
|
||||
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
|
||||
}
|
||||
|
||||
override fun getHost(): Host {
|
||||
|
||||
156
src/main/kotlin/app/termora/FilterableHostTreeModel.kt
Normal file
@@ -0,0 +1,156 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import java.util.function.Function
|
||||
import javax.swing.JTree
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.event.TreeModelEvent
|
||||
import javax.swing.event.TreeModelListener
|
||||
import javax.swing.tree.DefaultMutableTreeNode
|
||||
import javax.swing.tree.TreeModel
|
||||
import javax.swing.tree.TreeNode
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class FilterableHostTreeModel(
|
||||
private val tree: JTree,
|
||||
/**
|
||||
* 如果返回 true 则空文件夹也展示
|
||||
*/
|
||||
private val showEmptyFolder: () -> Boolean = { true }
|
||||
) : TreeModel {
|
||||
private val model = tree.model
|
||||
private val root = ReferenceTreeNode(model.root)
|
||||
private var listeners = emptyArray<TreeModelListener>()
|
||||
private var filters = emptyArray<Function<HostTreeNode, Boolean>>()
|
||||
private val mapping = mutableMapOf<TreeNode, ReferenceTreeNode>()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param a 旧的
|
||||
* @param b 新的
|
||||
*/
|
||||
private fun cloneTree(a: HostTreeNode, b: DefaultMutableTreeNode) {
|
||||
b.removeAllChildren()
|
||||
for (c in a.children()) {
|
||||
if (c !is HostTreeNode) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (c.data.protocol != Protocol.Folder) {
|
||||
if (filters.isNotEmpty() && filters.none { it.apply(c) }) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
val n = ReferenceTreeNode(c).apply { mapping[c] = this }.apply { b.add(this) }
|
||||
|
||||
// 文件夹递归复制
|
||||
if (c.host.protocol == Protocol.Folder) {
|
||||
cloneTree(c, n)
|
||||
}
|
||||
|
||||
// 如果是文件夹
|
||||
if (c.host.protocol == Protocol.Folder) {
|
||||
if (n.childCount == 0) {
|
||||
if (showEmptyFolder.invoke()) {
|
||||
continue
|
||||
}
|
||||
n.removeFromParent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
model.addTreeModelListener(object : TreeModelListener {
|
||||
override fun treeNodesChanged(e: TreeModelEvent) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun treeNodesInserted(e: TreeModelEvent) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun treeNodesRemoved(e: TreeModelEvent) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun treeStructureChanged(e: TreeModelEvent) {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getRoot(): Any {
|
||||
return root.userObject
|
||||
}
|
||||
|
||||
override fun getChild(parent: Any, index: Int): Any {
|
||||
val c = map(parent)?.getChildAt(index)
|
||||
if (c is ReferenceTreeNode) {
|
||||
return c.userObject
|
||||
}
|
||||
throw IndexOutOfBoundsException("Index out of bounds")
|
||||
}
|
||||
|
||||
override fun getChildCount(parent: Any): Int {
|
||||
return map(parent)?.childCount ?: 0
|
||||
}
|
||||
|
||||
private fun map(parent: Any): ReferenceTreeNode? {
|
||||
if (parent is TreeNode) {
|
||||
return mapping[parent]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun isLeaf(node: Any?): Boolean {
|
||||
return (node as TreeNode).isLeaf
|
||||
}
|
||||
|
||||
override fun valueForPathChanged(path: TreePath, newValue: Any) {
|
||||
|
||||
}
|
||||
|
||||
override fun getIndexOfChild(parent: Any, child: Any): Int {
|
||||
val c = map(parent) ?: return -1
|
||||
for (i in 0 until c.childCount) {
|
||||
val e = c.getChildAt(i)
|
||||
if (e is ReferenceTreeNode && e.userObject == child) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
override fun addTreeModelListener(l: TreeModelListener) {
|
||||
listeners = ArrayUtils.addAll(listeners, l)
|
||||
}
|
||||
|
||||
override fun removeTreeModelListener(l: TreeModelListener) {
|
||||
listeners = ArrayUtils.removeElement(listeners, l)
|
||||
}
|
||||
|
||||
fun addFilter(f: Function<HostTreeNode, Boolean>) {
|
||||
filters = ArrayUtils.add(filters, f)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
mapping.clear()
|
||||
mapping[model.root as TreeNode] = root
|
||||
cloneTree(model.root as HostTreeNode, root)
|
||||
SwingUtilities.updateComponentTreeUI(tree)
|
||||
}
|
||||
|
||||
fun getModel(): TreeModel {
|
||||
return model
|
||||
}
|
||||
|
||||
private class ReferenceTreeNode(any: Any) : DefaultMutableTreeNode(any)
|
||||
|
||||
}
|
||||
@@ -5,6 +5,17 @@ import org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
|
||||
|
||||
fun Map<*, *>.toPropertiesString(): String {
|
||||
val env = StringBuilder()
|
||||
for ((i, e) in entries.withIndex()) {
|
||||
env.append(e.key).append('=').append(e.value)
|
||||
if (i != size - 1) {
|
||||
env.appendLine()
|
||||
}
|
||||
}
|
||||
return env.toString()
|
||||
}
|
||||
|
||||
fun UUID.toSimpleString(): String {
|
||||
return toString().replace("-", StringUtils.EMPTY)
|
||||
}
|
||||
@@ -13,6 +24,14 @@ enum class Protocol {
|
||||
Folder,
|
||||
SSH,
|
||||
Local,
|
||||
Serial,
|
||||
RDP,
|
||||
|
||||
/**
|
||||
* 交互式的 SFTP,此协议只在系统内部交互不应该暴露给用户也不应该持久化
|
||||
*/
|
||||
@Transient
|
||||
SFTPPty
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +39,7 @@ enum class AuthenticationType {
|
||||
No,
|
||||
Password,
|
||||
PublicKey,
|
||||
SSHAgent,
|
||||
KeyboardInteractive,
|
||||
}
|
||||
|
||||
@@ -39,6 +59,53 @@ data class Authentication(
|
||||
}
|
||||
}
|
||||
|
||||
enum class SerialCommParity {
|
||||
None,
|
||||
Even,
|
||||
Odd,
|
||||
Mark,
|
||||
Space
|
||||
}
|
||||
|
||||
enum class SerialCommFlowControl {
|
||||
None,
|
||||
RTS_CTS,
|
||||
XON_XOFF,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SerialComm(
|
||||
/**
|
||||
* 串口
|
||||
*/
|
||||
val port: String = StringUtils.EMPTY,
|
||||
|
||||
/**
|
||||
* 波特率
|
||||
*/
|
||||
val baudRate: Int = 9600,
|
||||
|
||||
/**
|
||||
* 数据位:5、6、7、8
|
||||
*/
|
||||
val dataBits: Int = 8,
|
||||
|
||||
/**
|
||||
* 停止位: 1、1.5、2
|
||||
*/
|
||||
val stopBits: String = "1",
|
||||
|
||||
/**
|
||||
* 校验位
|
||||
*/
|
||||
val parity: SerialCommParity = SerialCommParity.None,
|
||||
|
||||
/**
|
||||
* 流控
|
||||
*/
|
||||
val flowControl: SerialCommFlowControl = SerialCommFlowControl.None,
|
||||
)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class Options(
|
||||
@@ -58,6 +125,30 @@ data class Options(
|
||||
* 连接成功后立即发送命令
|
||||
*/
|
||||
val startupCommand: String = StringUtils.EMPTY,
|
||||
/**
|
||||
* SSH 心跳间隔
|
||||
*/
|
||||
val heartbeatInterval: Int = 30,
|
||||
|
||||
/**
|
||||
* 串口配置
|
||||
*/
|
||||
val serialComm: SerialComm = SerialComm(),
|
||||
|
||||
/**
|
||||
* SFTP 默认目录
|
||||
*/
|
||||
val sftpDefaultDirectory: String = StringUtils.EMPTY,
|
||||
|
||||
/**
|
||||
* X11 Forwarding
|
||||
*/
|
||||
val enableX11Forwarding: Boolean = false,
|
||||
|
||||
/**
|
||||
* X11 Server,Format: host.port. default: localhost:0
|
||||
*/
|
||||
val x11Forwarding: String = StringUtils.EMPTY,
|
||||
) {
|
||||
companion object {
|
||||
val Default = Options()
|
||||
@@ -135,6 +226,27 @@ data class EncryptedHost(
|
||||
var updateDate: Long = 0L,
|
||||
)
|
||||
|
||||
/**
|
||||
* 被删除的数据
|
||||
*/
|
||||
@Serializable
|
||||
data class DeletedData(
|
||||
/**
|
||||
* 被删除的 ID
|
||||
*/
|
||||
val id: String = StringUtils.EMPTY,
|
||||
|
||||
/**
|
||||
* 数据类型:Host、Keymap、KeyPair、KeywordHighlight、Macro、Snippet
|
||||
*/
|
||||
val type: String = StringUtils.EMPTY,
|
||||
|
||||
/**
|
||||
* 被删除的时间
|
||||
*/
|
||||
val deleteDate: Long,
|
||||
)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class Host(
|
||||
@@ -186,7 +298,7 @@ data class Host(
|
||||
val tunnelings: List<Tunneling> = emptyList(),
|
||||
|
||||
/**
|
||||
* 排序
|
||||
* 排序,越小越靠前
|
||||
*/
|
||||
val sort: Long = 0,
|
||||
/**
|
||||
@@ -233,4 +345,8 @@ data class Host(
|
||||
result = 31 * result + ownerId.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.UIManager
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
|
||||
class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
|
||||
private val pane = if (host != null) EditHostOptionsPane(host) else HostOptionsPane()
|
||||
@@ -33,6 +40,82 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
|
||||
return panel
|
||||
}
|
||||
|
||||
override fun createActions(): List<AbstractAction> {
|
||||
return listOf(createOkAction(), createTestConnectionAction(), CancelAction())
|
||||
}
|
||||
|
||||
private fun createTestConnectionAction(): AbstractAction {
|
||||
return object : AnAction(I18n.getString("termora.new-host.test-connection")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
if (!pane.validateFields()) {
|
||||
return
|
||||
}
|
||||
|
||||
putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...")
|
||||
isEnabled = false
|
||||
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
// 因为测试连接的时候从数据库读取会导致失效,所以这里生成随机ID
|
||||
testConnection(pane.getHost().copy(id = UUID.randomUUID().toSimpleString()))
|
||||
withContext(Dispatchers.Swing) {
|
||||
putValue(NAME, I18n.getString("termora.new-host.test-connection"))
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun testConnection(host: Host) {
|
||||
val owner = this
|
||||
if (host.protocol == Protocol.Local) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(owner, I18n.getString("termora.new-host.test-connection-successful"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (host.protocol == Protocol.SSH) {
|
||||
testSSH(host)
|
||||
} else if (host.protocol == Protocol.Serial) {
|
||||
testSerial(host)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, ExceptionUtils.getMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.new-host.test-connection-successful")
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun testSSH(host: Host) {
|
||||
var client: SshClient? = null
|
||||
var session: ClientSession? = null
|
||||
try {
|
||||
client = SshClients.openClient(host, this)
|
||||
session = SshClients.openSession(host, client)
|
||||
} finally {
|
||||
session?.close()
|
||||
client?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testSerial(host: Host) {
|
||||
Serials.openPort(host).closePort()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
if (!pane.validateFields()) {
|
||||
|
||||
@@ -1,54 +1,52 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.db.Database
|
||||
import java.util.*
|
||||
|
||||
interface HostListener : EventListener {
|
||||
fun hostAdded(host: Host) {}
|
||||
fun hostRemoved(id: String) {}
|
||||
fun hostsChanged() {}
|
||||
}
|
||||
|
||||
|
||||
class HostManager private constructor() {
|
||||
companion object {
|
||||
val instance by lazy { HostManager() }
|
||||
fun getInstance(): HostManager {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(HostManager::class) { HostManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val database get() = Database.instance
|
||||
private val listeners = mutableListOf<HostListener>()
|
||||
private val database get() = Database.getDatabase()
|
||||
private var hosts = mutableMapOf<String, Host>()
|
||||
|
||||
fun addHost(host: Host, notify: Boolean = true) {
|
||||
/**
|
||||
* 修改缓存并存入数据库
|
||||
*/
|
||||
fun addHost(host: Host) {
|
||||
assertEventDispatchThread()
|
||||
database.addHost(host)
|
||||
if (notify) listeners.forEach { it.hostAdded(host) }
|
||||
if (host.deleted) {
|
||||
removeHost(host.id)
|
||||
} else {
|
||||
database.addHost(host)
|
||||
hosts[host.id] = host
|
||||
}
|
||||
}
|
||||
|
||||
fun removeHost(id: String) {
|
||||
assertEventDispatchThread()
|
||||
hosts.entries.removeIf { it.value.id == id || it.value.parentId == id }
|
||||
database.removeHost(id)
|
||||
listeners.forEach { it.hostRemoved(id) }
|
||||
|
||||
DeleteDataManager.getInstance().removeHost(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 第一次调用从数据库中获取,后续从缓存中获取
|
||||
*/
|
||||
fun hosts(): List<Host> {
|
||||
return database.getHosts()
|
||||
if (hosts.isEmpty()) {
|
||||
database.getHosts().filter { !it.deleted }
|
||||
.forEach { hosts[it.id] = it }
|
||||
}
|
||||
return hosts.values.filter { !it.deleted }
|
||||
.sortedWith(compareBy<Host> { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort })
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
assertEventDispatchThread()
|
||||
database.removeAllHost()
|
||||
listeners.forEach { it.hostsChanged() }
|
||||
/**
|
||||
* 从缓存中获取
|
||||
*/
|
||||
fun getHost(id: String): Host? {
|
||||
return hosts[id]
|
||||
}
|
||||
|
||||
fun addHostListener(listener: HostListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeHostListener(listener: HostListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,32 +1,48 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.KeyManagerDialog
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import com.fazecast.jSerialComm.SerialPort
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatComboBox
|
||||
import com.formdev.flatlaf.ui.FlatTextBorder
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.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.event.*
|
||||
import java.nio.charset.Charset
|
||||
import javax.swing.*
|
||||
import javax.swing.table.DefaultTableCellRenderer
|
||||
import javax.swing.table.DefaultTableModel
|
||||
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
open class HostOptionsPane : OptionsPane() {
|
||||
protected val tunnelingOption = TunnelingOption()
|
||||
protected val generalOption = GeneralOption()
|
||||
protected val proxyOption = ProxyOption()
|
||||
protected val terminalOption = TerminalOption()
|
||||
protected val owner: Window? get() = SwingUtilities.getWindowAncestor(this)
|
||||
protected val jumpHostsOption = JumpHostsOption()
|
||||
protected val serialCommOption = SerialCommOption()
|
||||
protected val sftpOption = SFTPOption()
|
||||
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
|
||||
|
||||
init {
|
||||
addOption(generalOption)
|
||||
addOption(proxyOption)
|
||||
addOption(tunnelingOption)
|
||||
addOption(jumpHostsOption)
|
||||
addOption(terminalOption)
|
||||
addOption(serialCommOption)
|
||||
addOption(sftpOption)
|
||||
|
||||
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
|
||||
}
|
||||
@@ -39,17 +55,22 @@ open class HostOptionsPane : OptionsPane() {
|
||||
val port = (generalOption.portTextField.value ?: 22) as Int
|
||||
var authentication = Authentication.No
|
||||
var proxy = Proxy.No
|
||||
val authenticationType = generalOption.authenticationTypeComboBox.selectedItem as AuthenticationType
|
||||
|
||||
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
|
||||
if (authenticationType == AuthenticationType.Password) {
|
||||
authentication = authentication.copy(
|
||||
type = AuthenticationType.Password,
|
||||
type = authenticationType,
|
||||
password = String(generalOption.passwordTextField.password)
|
||||
)
|
||||
} else if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
|
||||
val keyPair = generalOption.publicKeyTextField.getClientProperty(OhKeyPair::class) as OhKeyPair?
|
||||
} else if (authenticationType == AuthenticationType.PublicKey) {
|
||||
authentication = authentication.copy(
|
||||
type = AuthenticationType.PublicKey,
|
||||
password = keyPair?.id ?: StringUtils.EMPTY
|
||||
type = authenticationType,
|
||||
password = generalOption.publicKeyComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
|
||||
)
|
||||
} else if (authenticationType == AuthenticationType.SSHAgent) {
|
||||
authentication = authentication.copy(
|
||||
type = authenticationType,
|
||||
password = generalOption.sshAgentComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,10 +85,26 @@ open class HostOptionsPane : OptionsPane() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val serialComm = SerialComm(
|
||||
port = serialCommOption.serialPortComboBox.selectedItem?.toString() ?: StringUtils.EMPTY,
|
||||
baudRate = serialCommOption.baudRateComboBox.selectedItem?.toString()?.toIntOrNull() ?: 9600,
|
||||
dataBits = serialCommOption.dataBitsComboBox.selectedItem as Int? ?: 8,
|
||||
stopBits = serialCommOption.stopBitsComboBox.selectedItem as String? ?: "1",
|
||||
parity = serialCommOption.parityComboBox.selectedItem as SerialCommParity,
|
||||
flowControl = serialCommOption.flowControlComboBox.selectedItem as SerialCommFlowControl
|
||||
)
|
||||
|
||||
val options = Options.Default.copy(
|
||||
encoding = terminalOption.charsetComboBox.selectedItem as String,
|
||||
env = terminalOption.environmentTextArea.text,
|
||||
startupCommand = terminalOption.startupCommandTextField.text
|
||||
startupCommand = terminalOption.startupCommandTextField.text,
|
||||
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
|
||||
jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
|
||||
serialComm = serialComm,
|
||||
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text,
|
||||
enableX11Forwarding = tunnelingOption.x11ForwardingCheckBox.isSelected,
|
||||
x11Forwarding = tunnelingOption.x11ServerTextField.text,
|
||||
)
|
||||
|
||||
return Host(
|
||||
@@ -99,6 +136,12 @@ open class HostOptionsPane : OptionsPane() {
|
||||
if (validateField(generalOption.usernameTextField)) {
|
||||
return false
|
||||
}
|
||||
} else if (host.protocol == Protocol.Serial) {
|
||||
if (validateField(serialCommOption.serialPortComboBox)
|
||||
|| validateField(serialCommOption.baudRateComboBox)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
@@ -106,7 +149,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
return false
|
||||
}
|
||||
} else if (host.authentication.type == AuthenticationType.PublicKey) {
|
||||
if (validateField(generalOption.publicKeyTextField)) {
|
||||
if (validateField(generalOption.publicKeyComboBox)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -127,6 +170,17 @@ open class HostOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
// tunnel
|
||||
if (tunnelingOption.x11ForwardingCheckBox.isSelected) {
|
||||
if (validateField(tunnelingOption.x11ServerTextField)) {
|
||||
return false
|
||||
}
|
||||
val segments = tunnelingOption.x11ServerTextField.text.split(":")
|
||||
if (segments.size != 2 || segments[1].toIntOrNull() == null) {
|
||||
setOutlineError(tunnelingOption.x11ServerTextField)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -136,9 +190,27 @@ open class HostOptionsPane : OptionsPane() {
|
||||
*/
|
||||
private fun validateField(textField: JTextField): Boolean {
|
||||
if (textField.isEnabled && textField.text.isBlank()) {
|
||||
selectOptionJComponent(textField)
|
||||
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
|
||||
textField.requestFocusInWindow()
|
||||
setOutlineError(textField)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun setOutlineError(textField: JTextField) {
|
||||
selectOptionJComponent(textField)
|
||||
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
|
||||
textField.requestFocusInWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 true 表示有错误
|
||||
*/
|
||||
private fun validateField(comboBox: JComboBox<*>): Boolean {
|
||||
val selectedItem = comboBox.selectedItem
|
||||
if (comboBox.isEnabled && (selectedItem == null || (selectedItem is String && selectedItem.isBlank()))) {
|
||||
selectOptionJComponent(comboBox)
|
||||
comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
|
||||
comboBox.requestFocusInWindow()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -153,7 +225,8 @@ open class HostOptionsPane : OptionsPane() {
|
||||
private val passwordPanel = JPanel(BorderLayout())
|
||||
private val chooseKeyBtn = JButton(Icons.greyKey)
|
||||
val passwordTextField = OutlinePasswordField(255)
|
||||
val publicKeyTextField = OutlineTextField()
|
||||
val sshAgentComboBox = OutlineComboBox<String>()
|
||||
val publicKeyComboBox = OutlineComboBox<String>()
|
||||
val remarkTextArea = FixedLengthTextArea(512)
|
||||
val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
|
||||
|
||||
@@ -165,9 +238,13 @@ open class HostOptionsPane : OptionsPane() {
|
||||
private fun initView() {
|
||||
add(getCenterComponent(), BorderLayout.CENTER)
|
||||
|
||||
publicKeyTextField.isEditable = false
|
||||
publicKeyComboBox.isEditable = false
|
||||
chooseKeyBtn.isFocusable = false
|
||||
|
||||
// 只有 Windows 允许修改
|
||||
sshAgentComboBox.isEditable = SystemInfo.isWindows
|
||||
sshAgentComboBox.isEnabled = SystemInfo.isWindows
|
||||
|
||||
protocolTypeComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
@@ -186,6 +263,28 @@ open class HostOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
var text = StringUtils.EMPTY
|
||||
if (value is String) {
|
||||
text = KeyManager.getInstance().getOhKeyPair(value)?.name ?: text
|
||||
}
|
||||
return super.getListCellRendererComponent(
|
||||
list,
|
||||
text,
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
authenticationTypeComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
@@ -220,10 +319,23 @@ open class HostOptionsPane : OptionsPane() {
|
||||
|
||||
protocolTypeComboBox.addItem(Protocol.SSH)
|
||||
protocolTypeComboBox.addItem(Protocol.Local)
|
||||
protocolTypeComboBox.addItem(Protocol.Serial)
|
||||
protocolTypeComboBox.addItem(Protocol.RDP)
|
||||
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.No)
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.Password)
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.PublicKey)
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.SSHAgent)
|
||||
|
||||
if (SystemInfo.isWindows) {
|
||||
// 不要修改 addItem 的顺序,因为第一个是默认的
|
||||
sshAgentComboBox.addItem(PageantConnector.DESCRIPTOR.identityAgent)
|
||||
sshAgentComboBox.addItem(WinPipeConnector.DESCRIPTOR.identityAgent)
|
||||
sshAgentComboBox.placeholderText = PageantConnector.DESCRIPTOR.identityAgent
|
||||
} else {
|
||||
sshAgentComboBox.addItem(UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
|
||||
sshAgentComboBox.placeholderText = UnixDomainSocketConnector.DESCRIPTOR.identityAgent
|
||||
}
|
||||
|
||||
authenticationTypeComboBox.selectedItem = AuthenticationType.Password
|
||||
|
||||
@@ -264,14 +376,20 @@ open class HostOptionsPane : OptionsPane() {
|
||||
dialog.pack()
|
||||
dialog.setLocationRelativeTo(null)
|
||||
dialog.isVisible = true
|
||||
if (dialog.ok) {
|
||||
val lastKeyPair = dialog.getLasOhKeyPair()
|
||||
if (lastKeyPair != null) {
|
||||
publicKeyTextField.putClientProperty(OhKeyPair::class, lastKeyPair)
|
||||
publicKeyTextField.text = lastKeyPair.name
|
||||
publicKeyTextField.outline = null
|
||||
}
|
||||
|
||||
val selectedItem = publicKeyComboBox.selectedItem
|
||||
|
||||
publicKeyComboBox.removeAllItems()
|
||||
for (keyPair in KeyManager.getInstance().getOhKeyPairs()) {
|
||||
publicKeyComboBox.addItem(keyPair.id)
|
||||
}
|
||||
publicKeyComboBox.selectedItem = selectedItem
|
||||
|
||||
if (!dialog.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
publicKeyComboBox.selectedItem = dialog.getLasOhKeyPair()?.id ?: return
|
||||
}
|
||||
|
||||
private fun refreshStates() {
|
||||
@@ -279,15 +397,19 @@ open class HostOptionsPane : OptionsPane() {
|
||||
portTextField.isEnabled = true
|
||||
usernameTextField.isEnabled = true
|
||||
authenticationTypeComboBox.isEnabled = true
|
||||
publicKeyComboBox.isEnabled = true
|
||||
passwordTextField.isEnabled = true
|
||||
chooseKeyBtn.isEnabled = true
|
||||
|
||||
if (protocolTypeComboBox.selectedItem == Protocol.Local) {
|
||||
if (protocolTypeComboBox.selectedItem == Protocol.Local
|
||||
|| protocolTypeComboBox.selectedItem == Protocol.Serial
|
||||
) {
|
||||
hostTextField.isEnabled = false
|
||||
portTextField.isEnabled = false
|
||||
usernameTextField.isEnabled = false
|
||||
authenticationTypeComboBox.isEnabled = false
|
||||
passwordTextField.isEnabled = false
|
||||
publicKeyComboBox.isEnabled = false
|
||||
chooseKeyBtn.isEnabled = false
|
||||
}
|
||||
|
||||
@@ -364,13 +486,21 @@ open class HostOptionsPane : OptionsPane() {
|
||||
passwordPanel.removeAll()
|
||||
|
||||
if (authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
|
||||
val selectedItem = publicKeyComboBox.selectedItem
|
||||
publicKeyComboBox.removeAllItems()
|
||||
for (pair in KeyManager.getInstance().getOhKeyPairs()) {
|
||||
publicKeyComboBox.addItem(pair.id)
|
||||
}
|
||||
publicKeyComboBox.selectedItem = selectedItem
|
||||
passwordPanel.add(
|
||||
FormBuilder.create()
|
||||
.layout(FormLayout("default:grow, 4dlu, left:pref", "pref"))
|
||||
.add(publicKeyTextField).xy(1, 1)
|
||||
.add(publicKeyComboBox).xy(1, 1)
|
||||
.add(chooseKeyBtn).xy(3, 1)
|
||||
.build(), BorderLayout.CENTER
|
||||
)
|
||||
} else if (authenticationTypeComboBox.selectedItem == AuthenticationType.SSHAgent) {
|
||||
passwordPanel.add(sshAgentComboBox, BorderLayout.CENTER)
|
||||
} else {
|
||||
passwordPanel.add(passwordTextField, BorderLayout.CENTER)
|
||||
}
|
||||
@@ -508,6 +638,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
protected inner class TerminalOption : JPanel(BorderLayout()), Option {
|
||||
val charsetComboBox = JComboBox<String>()
|
||||
val startupCommandTextField = OutlineTextField()
|
||||
val heartbeatIntervalTextField = IntSpinner(30, minimum = 3, maximum = Int.MAX_VALUE)
|
||||
val environmentTextArea = FixedLengthTextArea(2048)
|
||||
|
||||
|
||||
@@ -563,7 +694,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, $formMargin, default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin"
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
@@ -571,6 +702,8 @@ open class HostOptionsPane : OptionsPane() {
|
||||
val panel = FormBuilder.create().layout(layout)
|
||||
.add("${I18n.getString("termora.new-host.terminal.encoding")}:").xy(1, rows)
|
||||
.add(charsetComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.terminal.heartbeat-interval")}:").xy(1, rows)
|
||||
.add(heartbeatIntervalTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.terminal.startup-commands")}:").xy(1, rows)
|
||||
.add(startupCommandTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.terminal.env")}:").xy(1, rows)
|
||||
@@ -583,8 +716,58 @@ open class HostOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class SFTPOption : JPanel(BorderLayout()), Option {
|
||||
val defaultDirectoryField = OutlineTextField(255)
|
||||
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
add(getCenterComponent(), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
return Icons.folder
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
return "SFTP"
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, $formMargin",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val panel = FormBuilder.create().layout(layout)
|
||||
.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
|
||||
.add(defaultDirectoryField).xy(3, rows).apply { rows += step }
|
||||
.build()
|
||||
|
||||
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class TunnelingOption : JPanel(BorderLayout()), Option {
|
||||
val tunnelings = mutableListOf<Tunneling>()
|
||||
val x11ForwardingCheckBox = JCheckBox("X DISPLAY:")
|
||||
val x11ServerTextField = OutlineTextField(255)
|
||||
|
||||
private val model = object : DefaultTableModel() {
|
||||
override fun getRowCount(): Int {
|
||||
@@ -631,6 +814,12 @@ open class HostOptionsPane : OptionsPane() {
|
||||
model.addColumn(I18n.getString("termora.new-host.tunneling.table.destination"))
|
||||
|
||||
|
||||
table.putClientProperty(
|
||||
FlatClientProperties.STYLE, mapOf(
|
||||
"showHorizontalLines" to true,
|
||||
"showVerticalLines" to true,
|
||||
)
|
||||
)
|
||||
table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS
|
||||
table.border = BorderFactory.createEmptyBorder()
|
||||
table.fillsViewportHeight = true
|
||||
@@ -653,13 +842,36 @@ open class HostOptionsPane : OptionsPane() {
|
||||
box.add(Box.createHorizontalStrut(4))
|
||||
box.add(deleteBtn)
|
||||
|
||||
add(JLabel("TCP/IP Forwarding:"), BorderLayout.NORTH)
|
||||
add(scrollPane, BorderLayout.CENTER)
|
||||
add(box, BorderLayout.SOUTH)
|
||||
x11ForwardingCheckBox.isFocusable = false
|
||||
|
||||
if (x11ServerTextField.text.isBlank()) {
|
||||
x11ServerTextField.text = "localhost:0"
|
||||
}
|
||||
|
||||
val x11Forwarding = Box.createHorizontalBox()
|
||||
x11Forwarding.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createTitledBorder("X11 Forwarding"),
|
||||
BorderFactory.createEmptyBorder(4, 4, 4, 4)
|
||||
)
|
||||
x11Forwarding.add(x11ForwardingCheckBox)
|
||||
x11Forwarding.add(x11ServerTextField)
|
||||
|
||||
x11ServerTextField.isEnabled = x11ForwardingCheckBox.isSelected
|
||||
|
||||
val panel = JPanel(BorderLayout())
|
||||
panel.add(JLabel("TCP/IP Forwarding:"), BorderLayout.NORTH)
|
||||
panel.add(scrollPane, BorderLayout.CENTER)
|
||||
panel.add(box, BorderLayout.SOUTH)
|
||||
panel.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
|
||||
|
||||
add(panel, BorderLayout.CENTER)
|
||||
add(x11Forwarding, BorderLayout.SOUTH)
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
x11ForwardingCheckBox.addChangeListener { x11ServerTextField.isEnabled = x11ForwardingCheckBox.isSelected }
|
||||
|
||||
addBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent?) {
|
||||
val dialog = PortForwardingDialog(SwingUtilities.getWindowAncestor(this@HostOptionsPane))
|
||||
@@ -839,4 +1051,289 @@ open class HostOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class SerialCommOption : JPanel(BorderLayout()), Option {
|
||||
val serialPortComboBox = OutlineComboBox<String>()
|
||||
val baudRateComboBox = OutlineComboBox<Int>()
|
||||
val dataBitsComboBox = OutlineComboBox<Int>()
|
||||
val parityComboBox = OutlineComboBox<SerialCommParity>()
|
||||
val stopBitsComboBox = OutlineComboBox<String>()
|
||||
val flowControlComboBox = OutlineComboBox<SerialCommFlowControl>()
|
||||
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
|
||||
serialPortComboBox.isEditable = true
|
||||
|
||||
baudRateComboBox.isEditable = true
|
||||
baudRateComboBox.addItem(9600)
|
||||
baudRateComboBox.addItem(19200)
|
||||
baudRateComboBox.addItem(38400)
|
||||
baudRateComboBox.addItem(57600)
|
||||
baudRateComboBox.addItem(115200)
|
||||
|
||||
dataBitsComboBox.addItem(5)
|
||||
dataBitsComboBox.addItem(6)
|
||||
dataBitsComboBox.addItem(7)
|
||||
dataBitsComboBox.addItem(8)
|
||||
dataBitsComboBox.selectedItem = 8
|
||||
|
||||
parityComboBox.addItem(SerialCommParity.None)
|
||||
parityComboBox.addItem(SerialCommParity.Even)
|
||||
parityComboBox.addItem(SerialCommParity.Odd)
|
||||
parityComboBox.addItem(SerialCommParity.Mark)
|
||||
parityComboBox.addItem(SerialCommParity.Space)
|
||||
|
||||
stopBitsComboBox.addItem("1")
|
||||
stopBitsComboBox.addItem("1.5")
|
||||
stopBitsComboBox.addItem("2")
|
||||
stopBitsComboBox.selectedItem = "1"
|
||||
|
||||
flowControlComboBox.addItem(SerialCommFlowControl.None)
|
||||
flowControlComboBox.addItem(SerialCommFlowControl.RTS_CTS)
|
||||
flowControlComboBox.addItem(SerialCommFlowControl.XON_XOFF)
|
||||
|
||||
flowControlComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
val text = value?.toString() ?: StringUtils.EMPTY
|
||||
return super.getListCellRendererComponent(
|
||||
list,
|
||||
text.replace('_', '/'),
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
add(getCenterComponent(), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentShown(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
for (commPort in SerialPort.getCommPorts()) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
serialPortComboBox.addItem(commPort.systemPortName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
return Icons.plugin
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
return I18n.getString("termora.new-host.serial")
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, $formMargin",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val panel = FormBuilder.create().layout(layout)
|
||||
.add("${I18n.getString("termora.new-host.serial.port")}:").xy(1, rows)
|
||||
.add(serialPortComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.baud-rate")}:").xy(1, rows)
|
||||
.add(baudRateComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.data-bits")}:").xy(1, rows)
|
||||
.add(dataBitsComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.parity")}:").xy(1, rows)
|
||||
.add(parityComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.stop-bits")}:").xy(1, rows)
|
||||
.add(stopBitsComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.new-host.serial.flow-control")}:").xy(1, rows)
|
||||
.add(flowControlComboBox).xy(3, rows).apply { rows += step }
|
||||
.build()
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class JumpHostsOption : JPanel(BorderLayout()), Option {
|
||||
val jumpHosts = mutableListOf<Host>()
|
||||
var filter: (host: Host) -> Boolean = { true }
|
||||
|
||||
private val model = object : DefaultTableModel() {
|
||||
|
||||
override fun isCellEditable(row: Int, column: Int): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getRowCount(): Int {
|
||||
return jumpHosts.size
|
||||
}
|
||||
|
||||
|
||||
override fun getValueAt(row: Int, column: Int): Any {
|
||||
val host = jumpHosts.getOrNull(row) ?: return StringUtils.EMPTY
|
||||
return if (column == 0)
|
||||
host.name
|
||||
else "${host.host}:${host.port}"
|
||||
}
|
||||
}
|
||||
private val table = JTable(model)
|
||||
private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add"))
|
||||
private val moveUpBtn = JButton(I18n.getString("termora.transport.bookmarks.up"))
|
||||
private val moveDownBtn = JButton(I18n.getString("termora.transport.bookmarks.down"))
|
||||
private val deleteBtn = JButton(I18n.getString("termora.new-host.tunneling.delete"))
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
val scrollPane = JScrollPane(table)
|
||||
|
||||
model.addColumn(I18n.getString("termora.new-host.general.name"))
|
||||
model.addColumn(I18n.getString("termora.new-host.general.host"))
|
||||
|
||||
table.putClientProperty(
|
||||
FlatClientProperties.STYLE, mapOf(
|
||||
"showHorizontalLines" to true,
|
||||
"showVerticalLines" to true,
|
||||
)
|
||||
)
|
||||
table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS
|
||||
table.setDefaultRenderer(
|
||||
Any::class.java,
|
||||
DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER })
|
||||
table.fillsViewportHeight = true
|
||||
scrollPane.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createEmptyBorder(4, 0, 4, 0),
|
||||
BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
|
||||
)
|
||||
table.border = BorderFactory.createEmptyBorder()
|
||||
|
||||
moveUpBtn.isFocusable = false
|
||||
moveDownBtn.isFocusable = false
|
||||
deleteBtn.isFocusable = false
|
||||
moveUpBtn.isEnabled = false
|
||||
moveDownBtn.isEnabled = false
|
||||
deleteBtn.isEnabled = false
|
||||
addBtn.isFocusable = false
|
||||
|
||||
val box = Box.createHorizontalBox()
|
||||
box.add(addBtn)
|
||||
box.add(Box.createHorizontalStrut(4))
|
||||
box.add(deleteBtn)
|
||||
box.add(Box.createHorizontalStrut(4))
|
||||
box.add(moveUpBtn)
|
||||
box.add(Box.createHorizontalStrut(4))
|
||||
box.add(moveDownBtn)
|
||||
|
||||
add(JLabel("${getTitle()}:"), BorderLayout.NORTH)
|
||||
add(scrollPane, BorderLayout.CENTER)
|
||||
add(box, BorderLayout.SOUTH)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent?) {
|
||||
val dialog = NewHostTreeDialog(owner)
|
||||
dialog.setFilter { node -> jumpHosts.none { it.id == node.host.id } && filter.invoke(node.host) }
|
||||
dialog.setTreeName("HostOptionsPane.JumpHostsOption.Tree")
|
||||
dialog.setLocationRelativeTo(owner)
|
||||
dialog.isVisible = true
|
||||
val hosts = dialog.hosts
|
||||
if (hosts.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
hosts.forEach {
|
||||
val rowCount = model.rowCount
|
||||
jumpHosts.add(it)
|
||||
model.fireTableRowsInserted(rowCount, rowCount + 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
deleteBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val rows = table.selectedRows.sortedDescending()
|
||||
if (rows.isEmpty()) return
|
||||
for (row in rows) {
|
||||
jumpHosts.removeAt(row)
|
||||
model.fireTableRowsDeleted(row, row)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
table.selectionModel.addListSelectionListener {
|
||||
deleteBtn.isEnabled = table.selectedRowCount > 0
|
||||
moveUpBtn.isEnabled = deleteBtn.isEnabled && !table.selectedRows.contains(0)
|
||||
moveDownBtn.isEnabled = deleteBtn.isEnabled && !table.selectedRows.contains(table.rowCount - 1)
|
||||
}
|
||||
|
||||
|
||||
moveUpBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val rows = table.selectedRows.sorted()
|
||||
if (rows.isEmpty()) return
|
||||
|
||||
table.clearSelection()
|
||||
|
||||
for (row in rows) {
|
||||
val host = jumpHosts[(row)]
|
||||
jumpHosts.removeAt(row)
|
||||
jumpHosts.add(row - 1, host)
|
||||
table.addRowSelectionInterval(row - 1, row - 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
moveDownBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val rows = table.selectedRows.sortedDescending()
|
||||
if (rows.isEmpty()) return
|
||||
|
||||
table.clearSelection()
|
||||
|
||||
for (row in rows) {
|
||||
val host = jumpHosts[(row)]
|
||||
jumpHosts.removeAt(row)
|
||||
jumpHosts.add(row + 1, host)
|
||||
table.addRowSelectionInterval(row + 1, row + 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
return Icons.server
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
return I18n.getString("termora.new-host.jump-hosts")
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.terminal.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import java.beans.PropertyChangeEvent
|
||||
import javax.swing.Icon
|
||||
|
||||
abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
|
||||
protected val coroutineScope by lazy { CoroutineScope(Dispatchers.Swing) }
|
||||
protected val terminal = TerminalFactory.instance.createTerminal()
|
||||
abstract class HostTerminalTab(
|
||||
val windowScope: WindowScope,
|
||||
val host: Host,
|
||||
protected val terminal: Terminal = TerminalFactory.getInstance().createTerminal()
|
||||
) : PropertyTerminalTab(), DataProvider {
|
||||
companion object {
|
||||
val Host = DataKey(app.termora.Host::class)
|
||||
}
|
||||
|
||||
protected val coroutineScope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Swing) }
|
||||
protected val terminalModel get() = terminal.getTerminalModel()
|
||||
protected var unread = false
|
||||
set(value) {
|
||||
@@ -25,6 +35,7 @@ abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
|
||||
}
|
||||
|
||||
init {
|
||||
terminal.getTerminalModel().setData(Host, host)
|
||||
terminal.getTerminalModel().addDataListener(object : DataListener {
|
||||
override fun onChanged(key: DataKey<*>, data: Any) {
|
||||
if (key == VisualTerminal.Written) {
|
||||
@@ -51,6 +62,7 @@ abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
terminal.close()
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
@@ -60,4 +72,11 @@ abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
|
||||
unread = false
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == DataProviders.Terminal) {
|
||||
return terminal as T?
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.db.Database
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
|
||||
import com.formdev.flatlaf.icons.FlatTreeOpenIcon
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.Transferable
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import javax.swing.event.CellEditorListener
|
||||
import javax.swing.event.ChangeEvent
|
||||
import javax.swing.event.PopupMenuEvent
|
||||
import javax.swing.event.PopupMenuListener
|
||||
import javax.swing.tree.TreePath
|
||||
import javax.swing.tree.TreeSelectionModel
|
||||
|
||||
|
||||
class HostTree : JTree(), Disposable {
|
||||
private val hostManager get() = HostManager.instance
|
||||
private val editor = OutlineTextField(64)
|
||||
|
||||
val model = HostTreeModel()
|
||||
val searchableModel = SearchableHostTreeModel(model)
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
|
||||
private fun initView() {
|
||||
setModel(model)
|
||||
isEditable = true
|
||||
dropMode = DropMode.ON_OR_INSERT
|
||||
dragEnabled = true
|
||||
selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
|
||||
editor.preferredSize = Dimension(220, 0)
|
||||
|
||||
setCellRenderer(object : DefaultXTreeCellRenderer() {
|
||||
override fun getTreeCellRendererComponent(
|
||||
tree: JTree,
|
||||
value: Any,
|
||||
sel: Boolean,
|
||||
expanded: Boolean,
|
||||
leaf: Boolean,
|
||||
row: Int,
|
||||
hasFocus: Boolean
|
||||
): Component {
|
||||
val host = value as Host
|
||||
val c = super.getTreeCellRendererComponent(tree, host, sel, expanded, leaf, row, hasFocus)
|
||||
if (host.protocol == Protocol.Folder) {
|
||||
icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
|
||||
} else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) {
|
||||
icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal
|
||||
}
|
||||
return c
|
||||
}
|
||||
})
|
||||
|
||||
setCellEditor(object : DefaultCellEditor(editor) {
|
||||
override fun isCellEditable(e: EventObject?): Boolean {
|
||||
if (e is MouseEvent) {
|
||||
return false
|
||||
}
|
||||
return super.isCellEditable(e)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
val state = Database.instance.properties.getString("HostTreeExpansionState")
|
||||
if (state != null) {
|
||||
TreeUtils.loadExpansionState(this@HostTree, state)
|
||||
}
|
||||
}
|
||||
|
||||
override fun convertValueToText(
|
||||
value: Any?,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
leaf: Boolean,
|
||||
row: Int,
|
||||
hasFocus: Boolean
|
||||
): String {
|
||||
if (value is Host) {
|
||||
return value.name
|
||||
}
|
||||
return super.convertValueToText(value, selected, expanded, leaf, row, hasFocus)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
// 右键选中
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
if (!SwingUtilities.isRightMouseButton(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
requestFocusInWindow()
|
||||
|
||||
val selectionRows = selectionModel.selectionRows
|
||||
|
||||
val selRow = getClosestRowForLocation(e.x, e.y)
|
||||
if (selRow < 0) {
|
||||
selectionModel.clearSelection()
|
||||
return
|
||||
} else if (selectionRows != null && selectionRows.contains(selRow)) {
|
||||
return
|
||||
}
|
||||
|
||||
selectionPath = getPathForLocation(e.x, e.y)
|
||||
|
||||
setSelectionRow(selRow)
|
||||
}
|
||||
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val host = lastSelectedPathComponent
|
||||
if (host is Host && host.protocol != Protocol.Folder) {
|
||||
ActionManager.getInstance().getAction(Actions.OPEN_HOST)
|
||||
?.actionPerformed(OpenHostActionEvent(this, host))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// contextmenu
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
if (!(SwingUtilities.isRightMouseButton(e))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Objects.isNull(lastSelectedPathComponent)) {
|
||||
return
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater { showContextMenu(e) }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// rename
|
||||
getCellEditor().addCellEditorListener(object : CellEditorListener {
|
||||
override fun editingStopped(e: ChangeEvent) {
|
||||
val lastHost = lastSelectedPathComponent
|
||||
if (lastHost !is Host || editor.text.isBlank() || editor.text == lastHost.name) {
|
||||
return
|
||||
}
|
||||
runCatchingHost(lastHost.copy(name = editor.text))
|
||||
}
|
||||
|
||||
override fun editingCanceled(e: ChangeEvent) {
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// drag
|
||||
transferHandler = object : TransferHandler() {
|
||||
|
||||
override fun createTransferable(c: JComponent): Transferable {
|
||||
val nodes = selectionModel.selectionPaths
|
||||
.map { it.lastPathComponent }
|
||||
.filterIsInstance<Host>()
|
||||
.toMutableList()
|
||||
|
||||
val iterator = nodes.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val node = iterator.next()
|
||||
val parents = model.getPathToRoot(node).filter { it != node }
|
||||
if (parents.any { nodes.contains(it) }) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
return MoveHostTransferable(nodes)
|
||||
}
|
||||
|
||||
override fun getSourceActions(c: JComponent?): Int {
|
||||
return MOVE
|
||||
}
|
||||
|
||||
override fun canImport(support: TransferSupport): Boolean {
|
||||
if (!support.isDrop) {
|
||||
return false
|
||||
}
|
||||
val dropLocation = support.dropLocation
|
||||
if (dropLocation !is JTree.DropLocation || support.component != this@HostTree
|
||||
|| dropLocation.childIndex != -1
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
val lastNode = dropLocation.path.lastPathComponent
|
||||
if (lastNode !is Host || lastNode.protocol != Protocol.Folder) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) {
|
||||
val nodes = support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>
|
||||
if (nodes.any { it == lastNode }) {
|
||||
return false
|
||||
}
|
||||
for (parent in model.getPathToRoot(lastNode).filter { it != lastNode }) {
|
||||
if (nodes.any { it == parent }) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
support.setShowDropLocation(true)
|
||||
return support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)
|
||||
}
|
||||
|
||||
override fun importData(support: TransferSupport): Boolean {
|
||||
if (!support.isDrop) {
|
||||
return false
|
||||
}
|
||||
|
||||
val dropLocation = support.dropLocation
|
||||
if (dropLocation !is JTree.DropLocation) {
|
||||
return false
|
||||
}
|
||||
|
||||
val lastNode = dropLocation.path.lastPathComponent
|
||||
if (lastNode !is Host || lastNode.protocol != Protocol.Folder) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) {
|
||||
return false
|
||||
}
|
||||
|
||||
val hosts = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>)
|
||||
.filterIsInstance<Host>().toMutableList()
|
||||
if (hosts.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 记录展开的节点
|
||||
val expandedHosts = mutableListOf<String>()
|
||||
for (host in hosts) {
|
||||
model.visit(host) {
|
||||
if (it.protocol == Protocol.Folder) {
|
||||
if (isExpanded(TreePath(model.getPathToRoot(it)))) {
|
||||
expandedHosts.addFirst(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var now = System.currentTimeMillis()
|
||||
for (host in hosts) {
|
||||
model.removeNodeFromParent(host)
|
||||
val newHost = host.copy(
|
||||
parentId = lastNode.id,
|
||||
sort = ++now,
|
||||
updateDate = now
|
||||
)
|
||||
runCatchingHost(newHost)
|
||||
}
|
||||
|
||||
expandNode(lastNode)
|
||||
|
||||
// 展开
|
||||
for (id in expandedHosts) {
|
||||
model.getHost(id)?.let { expandNode(it) }
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun isPathEditable(path: TreePath?): Boolean {
|
||||
if (path == null) return false
|
||||
if (path.lastPathComponent == model.root) return false
|
||||
return super.isPathEditable(path)
|
||||
}
|
||||
|
||||
override fun getLastSelectedPathComponent(): Any? {
|
||||
val last = super.getLastSelectedPathComponent() ?: return null
|
||||
if (last is Host) {
|
||||
return model.getHost(last.id) ?: last
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
private fun showContextMenu(event: MouseEvent) {
|
||||
val lastHost = lastSelectedPathComponent
|
||||
if (lastHost !is Host) {
|
||||
return
|
||||
}
|
||||
|
||||
val popupMenu = FlatPopupMenu()
|
||||
val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new"))
|
||||
val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder"))
|
||||
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host"))
|
||||
|
||||
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open"))
|
||||
popupMenu.addSeparator()
|
||||
val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy"))
|
||||
val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove"))
|
||||
val rename = popupMenu.add(I18n.getString("termora.welcome.contextmenu.rename"))
|
||||
popupMenu.addSeparator()
|
||||
val expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all"))
|
||||
val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all"))
|
||||
popupMenu.addSeparator()
|
||||
popupMenu.add(newMenu)
|
||||
popupMenu.addSeparator()
|
||||
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
|
||||
|
||||
open.addActionListener {
|
||||
getSelectionNodes()
|
||||
.filter { it.protocol != Protocol.Folder }
|
||||
.forEach {
|
||||
ActionManager.getInstance()
|
||||
.getAction(Actions.OPEN_HOST)
|
||||
?.actionPerformed(OpenHostActionEvent(this, it))
|
||||
}
|
||||
}
|
||||
|
||||
rename.addActionListener {
|
||||
startEditingAtPath(TreePath(model.getPathToRoot(lastHost)))
|
||||
}
|
||||
|
||||
expandAll.addActionListener {
|
||||
getSelectionNodes().forEach { expandNode(it, true) }
|
||||
}
|
||||
|
||||
|
||||
colspanAll.addActionListener {
|
||||
selectionModel.selectionPaths.map { it.lastPathComponent }
|
||||
.filterIsInstance<Host>()
|
||||
.filter { it.protocol == Protocol.Folder }
|
||||
.forEach { collapseNode(it) }
|
||||
}
|
||||
|
||||
copy.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val parent = model.getParent(lastHost) ?: return
|
||||
val node = copyNode(parent, lastHost)
|
||||
selectionPath = TreePath(model.getPathToRoot(node))
|
||||
}
|
||||
})
|
||||
|
||||
remove.addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
"删除后无法恢复,你确定要删除吗?",
|
||||
I18n.getString("termora.remove"),
|
||||
JOptionPane.YES_NO_OPTION,
|
||||
JOptionPane.QUESTION_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
var lastParent: Host? = null
|
||||
while (!selectionModel.isSelectionEmpty) {
|
||||
val host = lastSelectedPathComponent ?: break
|
||||
if (host !is Host) {
|
||||
break
|
||||
} else {
|
||||
lastParent = model.getParent(host)
|
||||
}
|
||||
model.visit(host) { hostManager.removeHost(it.id) }
|
||||
}
|
||||
if (lastParent != null) {
|
||||
selectionPath = TreePath(model.getPathToRoot(lastParent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newFolder.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (lastHost.protocol != Protocol.Folder) {
|
||||
return
|
||||
}
|
||||
|
||||
val host = Host(
|
||||
id = UUID.randomUUID().toSimpleString(),
|
||||
protocol = Protocol.Folder,
|
||||
name = I18n.getString("termora.welcome.contextmenu.new.folder.name"),
|
||||
sort = System.currentTimeMillis(),
|
||||
parentId = lastHost.id
|
||||
)
|
||||
|
||||
runCatchingHost(host)
|
||||
|
||||
expandNode(lastHost)
|
||||
selectionPath = TreePath(model.getPathToRoot(host))
|
||||
startEditingAtPath(selectionPath)
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
newHost.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
showAddHostDialog()
|
||||
}
|
||||
})
|
||||
|
||||
property.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val dialog = HostDialog(SwingUtilities.getWindowAncestor(this@HostTree), lastHost)
|
||||
dialog.isVisible = true
|
||||
val host = dialog.host ?: return
|
||||
runCatchingHost(host)
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化状态
|
||||
newFolder.isEnabled = lastHost.protocol == Protocol.Folder
|
||||
newHost.isEnabled = newFolder.isEnabled
|
||||
remove.isEnabled = !getSelectionNodes().any { it == model.root }
|
||||
copy.isEnabled = remove.isEnabled
|
||||
rename.isEnabled = remove.isEnabled
|
||||
property.isEnabled = lastHost.protocol != Protocol.Folder
|
||||
|
||||
popupMenu.addPopupMenuListener(object : PopupMenuListener {
|
||||
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
|
||||
this@HostTree.grabFocus()
|
||||
}
|
||||
|
||||
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
|
||||
this@HostTree.requestFocusInWindow()
|
||||
}
|
||||
|
||||
override fun popupMenuCanceled(e: PopupMenuEvent) {
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
popupMenu.show(this, event.x, event.y)
|
||||
}
|
||||
|
||||
fun showAddHostDialog() {
|
||||
var lastHost = lastSelectedPathComponent
|
||||
if (lastHost !is Host) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lastHost.protocol != Protocol.Folder) {
|
||||
val p = model.getParent(lastHost) ?: return
|
||||
lastHost = p
|
||||
}
|
||||
|
||||
val dialog = HostDialog(SwingUtilities.getWindowAncestor(this))
|
||||
dialog.isVisible = true
|
||||
val host = (dialog.host ?: return).copy(parentId = lastHost.id)
|
||||
|
||||
runCatchingHost(host)
|
||||
|
||||
expandNode(lastHost)
|
||||
selectionPath = TreePath(model.getPathToRoot(host))
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun expandNode(node: Host, including: Boolean = false) {
|
||||
expandPath(TreePath(model.getPathToRoot(node)))
|
||||
if (including) {
|
||||
model.getChildren(node).forEach { expandNode(it, true) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun copyNode(
|
||||
parent: Host,
|
||||
host: Host,
|
||||
idGenerator: () -> String = { UUID.randomUUID().toSimpleString() }
|
||||
): Host {
|
||||
val now = System.currentTimeMillis()
|
||||
val newHost = host.copy(
|
||||
name = "${host.name} ${I18n.getString("termora.welcome.contextmenu.copy")}",
|
||||
id = idGenerator.invoke(),
|
||||
parentId = parent.id,
|
||||
updateDate = now,
|
||||
createDate = now,
|
||||
sort = now
|
||||
)
|
||||
|
||||
runCatchingHost(newHost)
|
||||
|
||||
if (host.protocol == Protocol.Folder) {
|
||||
for (child in model.getChildren(host)) {
|
||||
copyNode(newHost, child, idGenerator)
|
||||
}
|
||||
if (isExpanded(TreePath(model.getPathToRoot(host)))) {
|
||||
expandNode(newHost)
|
||||
}
|
||||
}
|
||||
|
||||
return newHost
|
||||
|
||||
}
|
||||
|
||||
private fun runCatchingHost(host: Host) {
|
||||
hostManager.addHost(host)
|
||||
}
|
||||
|
||||
private fun collapseNode(node: Host) {
|
||||
model.getChildren(node).forEach { collapseNode(it) }
|
||||
collapsePath(TreePath(model.getPathToRoot(node)))
|
||||
}
|
||||
|
||||
private fun getSelectionNodes(): List<Host> {
|
||||
val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent }
|
||||
.filterIsInstance<Host>()
|
||||
|
||||
if (selectionNodes.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val nodes = mutableListOf<Host>()
|
||||
val parents = mutableListOf<Host>()
|
||||
|
||||
for (node in selectionNodes) {
|
||||
if (node.protocol == Protocol.Folder) {
|
||||
parents.add(node)
|
||||
}
|
||||
nodes.add(node)
|
||||
}
|
||||
|
||||
while (parents.isNotEmpty()) {
|
||||
val p = parents.removeFirst()
|
||||
for (i in 0 until model.getChildCount(p)) {
|
||||
val child = model.getChild(p, i) as Host
|
||||
nodes.add(child)
|
||||
parents.add(child)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
Database.instance.properties.putString(
|
||||
"HostTreeExpansionState",
|
||||
TreeUtils.saveExpansionState(this)
|
||||
)
|
||||
}
|
||||
|
||||
private abstract class HostTreeNodeTransferable(val hosts: List<Host>) :
|
||||
Transferable {
|
||||
|
||||
override fun getTransferDataFlavors(): Array<DataFlavor> {
|
||||
return arrayOf(getDataFlavor())
|
||||
}
|
||||
|
||||
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean {
|
||||
return getDataFlavor() == flavor
|
||||
}
|
||||
|
||||
override fun getTransferData(flavor: DataFlavor): Any {
|
||||
return hosts
|
||||
}
|
||||
|
||||
abstract fun getDataFlavor(): DataFlavor
|
||||
}
|
||||
|
||||
private class MoveHostTransferable(hosts: List<Host>) : HostTreeNodeTransferable(hosts) {
|
||||
companion object {
|
||||
val dataFlavor =
|
||||
DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveHostTransferable::class.java.name}")
|
||||
}
|
||||
|
||||
override fun getDataFlavor(): DataFlavor {
|
||||
return dataFlavor
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import javax.swing.event.TreeModelEvent
|
||||
import javax.swing.event.TreeModelListener
|
||||
import javax.swing.tree.TreeModel
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class HostTreeModel : TreeModel {
|
||||
|
||||
val listeners = mutableListOf<TreeModelListener>()
|
||||
|
||||
private val hostManager get() = HostManager.instance
|
||||
private val hosts = mutableMapOf<String, Host>()
|
||||
private val myRoot by lazy {
|
||||
Host(
|
||||
id = "0",
|
||||
protocol = Protocol.Folder,
|
||||
name = I18n.getString("termora.welcome.my-hosts"),
|
||||
host = StringUtils.EMPTY,
|
||||
port = 0,
|
||||
remark = StringUtils.EMPTY,
|
||||
username = StringUtils.EMPTY
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
for (host in hostManager.hosts()) {
|
||||
hosts[host.id] = host
|
||||
}
|
||||
|
||||
hostManager.addHostListener(object : HostListener {
|
||||
override fun hostRemoved(id: String) {
|
||||
val host = hosts[id] ?: return
|
||||
removeNodeFromParent(host)
|
||||
}
|
||||
|
||||
override fun hostAdded(host: Host) {
|
||||
// 如果已经存在,那么是修改
|
||||
if (hosts.containsKey(host.id)) {
|
||||
val oldHost = hosts.getValue(host.id)
|
||||
// 父级结构变了
|
||||
if (oldHost.parentId != host.parentId) {
|
||||
hostRemoved(host.id)
|
||||
hostAdded(host)
|
||||
} else {
|
||||
hosts[host.id] = host
|
||||
val event = TreeModelEvent(this, getPathToRoot(host))
|
||||
listeners.forEach { it.treeStructureChanged(event) }
|
||||
}
|
||||
|
||||
} else {
|
||||
hosts[host.id] = host
|
||||
val parent = getParent(host) ?: return
|
||||
val path = TreePath(getPathToRoot(parent))
|
||||
val event = TreeModelEvent(this, path, intArrayOf(getIndexOfChild(parent, host)), arrayOf(host))
|
||||
listeners.forEach { it.treeNodesInserted(event) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun hostsChanged() {
|
||||
hosts.clear()
|
||||
for (host in hostManager.hosts()) {
|
||||
hosts[host.id] = host
|
||||
}
|
||||
val event = TreeModelEvent(this, getPathToRoot(root), null, null)
|
||||
listeners.forEach { it.treeStructureChanged(event) }
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
override fun getRoot(): Host {
|
||||
return myRoot
|
||||
}
|
||||
|
||||
override fun getChild(parent: Any?, index: Int): Any {
|
||||
return getChildren(parent)[index]
|
||||
}
|
||||
|
||||
override fun getChildCount(parent: Any?): Int {
|
||||
return getChildren(parent).size
|
||||
}
|
||||
|
||||
override fun isLeaf(node: Any?): Boolean {
|
||||
return getChildCount(node) == 0
|
||||
}
|
||||
|
||||
fun getParent(node: Host): Host? {
|
||||
if (node.parentId == root.id || root.id == node.id) {
|
||||
return root
|
||||
}
|
||||
return hosts.values.firstOrNull { it.id == node.parentId }
|
||||
}
|
||||
|
||||
override fun valueForPathChanged(path: TreePath?, newValue: Any?) {
|
||||
|
||||
}
|
||||
|
||||
override fun getIndexOfChild(parent: Any?, child: Any?): Int {
|
||||
return getChildren(parent).indexOf(child)
|
||||
}
|
||||
|
||||
override fun addTreeModelListener(listener: TreeModelListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeTreeModelListener(listener: TreeModelListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅从结构中删除
|
||||
*/
|
||||
fun removeNodeFromParent(host: Host) {
|
||||
val parent = getParent(host) ?: return
|
||||
val index = getIndexOfChild(parent, host)
|
||||
val event = TreeModelEvent(this, TreePath(getPathToRoot(parent)), intArrayOf(index), arrayOf(host))
|
||||
hosts.remove(host.id)
|
||||
listeners.forEach { it.treeNodesRemoved(event) }
|
||||
}
|
||||
|
||||
fun visit(host: Host, visitor: (host: Host) -> Unit) {
|
||||
if (host.protocol == Protocol.Folder) {
|
||||
getChildren(host).forEach { visit(it, visitor) }
|
||||
visitor.invoke(host)
|
||||
} else {
|
||||
visitor.invoke(host)
|
||||
}
|
||||
}
|
||||
|
||||
fun getHost(id: String): Host? {
|
||||
return hosts[id]
|
||||
}
|
||||
|
||||
fun getPathToRoot(host: Host): Array<Host> {
|
||||
|
||||
if (host.id == root.id) {
|
||||
return arrayOf(root)
|
||||
}
|
||||
|
||||
val parents = mutableListOf(host)
|
||||
var pId = host.parentId
|
||||
while (pId != root.id) {
|
||||
val e = hosts[(pId)] ?: break
|
||||
parents.addFirst(e)
|
||||
pId = e.parentId
|
||||
}
|
||||
parents.addFirst(root)
|
||||
return parents.toTypedArray()
|
||||
}
|
||||
|
||||
fun getChildren(parent: Any?): List<Host> {
|
||||
val pId = if (parent is Host) parent.id else root.id
|
||||
return hosts.values.filter { it.parentId == pId }
|
||||
.sortedWith(compareBy<Host> { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort })
|
||||
}
|
||||
}
|
||||
111
src/main/kotlin/app/termora/HostTreeNode.kt
Normal file
@@ -0,0 +1,111 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
|
||||
import com.formdev.flatlaf.icons.FlatTreeOpenIcon
|
||||
import javax.swing.Icon
|
||||
import javax.swing.tree.TreeNode
|
||||
|
||||
class HostTreeNode(host: Host) : SimpleTreeNode<Host>(host) {
|
||||
companion object {
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
}
|
||||
|
||||
var host: Host
|
||||
get() = data
|
||||
set(value) = setUserObject(value)
|
||||
|
||||
override val isFolder: Boolean
|
||||
get() = data.protocol == Protocol.Folder
|
||||
|
||||
override val id: String
|
||||
get() = data.id
|
||||
|
||||
/**
|
||||
* 如果要重新赋值,记得修改 [Host.updateDate] 否则下次取出时可能时缓存的
|
||||
*/
|
||||
override var data: Host
|
||||
get() {
|
||||
val cacheHost = hostManager.getHost((userObject as Host).id)
|
||||
val myHost = userObject as Host
|
||||
if (cacheHost == null) {
|
||||
return myHost
|
||||
}
|
||||
return if (cacheHost.updateDate > myHost.updateDate) cacheHost else myHost
|
||||
}
|
||||
set(value) = setUserObject(value)
|
||||
|
||||
override val folderCount
|
||||
get() = children().toList().count { if (it is HostTreeNode) it.data.protocol == Protocol.Folder else false }
|
||||
|
||||
override fun getParent(): HostTreeNode? {
|
||||
return super.getParent() as HostTreeNode?
|
||||
}
|
||||
|
||||
override fun getAllChildren(): List<HostTreeNode> {
|
||||
return super.getAllChildren().filterIsInstance<HostTreeNode>()
|
||||
}
|
||||
|
||||
override fun getIcon(selected: Boolean, expanded: Boolean, hasFocus: Boolean): Icon {
|
||||
return when (host.protocol) {
|
||||
Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
|
||||
Protocol.Serial -> if (selected && hasFocus) Icons.plugin.dark else Icons.plugin
|
||||
Protocol.RDP -> if (selected && hasFocus) Icons.microsoftWindows.dark else Icons.microsoftWindows
|
||||
else -> if (selected && hasFocus) Icons.terminal.dark else Icons.terminal
|
||||
}
|
||||
}
|
||||
|
||||
fun childrenNode(): List<HostTreeNode> {
|
||||
return children?.map { it as HostTreeNode } ?: emptyList()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 深度克隆
|
||||
* @param scopes 克隆的范围
|
||||
*/
|
||||
fun clone(scopes: Set<Protocol> = emptySet()): HostTreeNode {
|
||||
val newNode = clone() as HostTreeNode
|
||||
deepClone(newNode, this, scopes)
|
||||
return newNode
|
||||
}
|
||||
|
||||
private fun deepClone(newNode: HostTreeNode, oldNode: HostTreeNode, scopes: Set<Protocol> = emptySet()) {
|
||||
for (child in oldNode.childrenNode()) {
|
||||
if (scopes.isNotEmpty() && !scopes.contains(child.data.protocol)) continue
|
||||
val newChildNode = child.clone() as HostTreeNode
|
||||
deepClone(newChildNode, child, scopes)
|
||||
newNode.add(newChildNode)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clone(): Any {
|
||||
val newNode = HostTreeNode(data)
|
||||
newNode.children = null
|
||||
newNode.parent = null
|
||||
return newNode
|
||||
}
|
||||
|
||||
override fun isNodeChild(aNode: TreeNode?): Boolean {
|
||||
if (aNode is HostTreeNode) {
|
||||
for (node in childrenNode()) {
|
||||
if (node.data == aNode.data) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.isNodeChild(aNode)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as HostTreeNode
|
||||
|
||||
return data == other.data
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return data.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnAction
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon.ColorFilter
|
||||
import org.jdesktop.swingx.JXHyperlink
|
||||
|
||||
@@ -40,12 +40,17 @@ object I18n {
|
||||
}
|
||||
|
||||
fun getString(key: String, vararg args: Any): String {
|
||||
val text = getString(key)
|
||||
if (args.isNotEmpty()) {
|
||||
return MessageFormat.format(text, *args)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
|
||||
fun getString(key: String): String {
|
||||
try {
|
||||
val text = substitutor.replace(bundle.getString(key))
|
||||
if (args.isNotEmpty()) {
|
||||
return MessageFormat.format(text, *args)
|
||||
}
|
||||
return text
|
||||
return substitutor.replace(bundle.getString(key))
|
||||
} catch (e: MissingResourceException) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(e.message, e)
|
||||
@@ -54,4 +59,5 @@ object I18n {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,19 +1,40 @@
|
||||
package app.termora
|
||||
|
||||
object Icons {
|
||||
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
|
||||
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
|
||||
val closeSmall by lazy { DynamicIcon("icons/closeSmall.svg", "icons/closeSmall_dark.svg") }
|
||||
val closeSmallHovered by lazy { DynamicIcon("icons/closeSmallHovered.svg", "icons/closeSmallHovered_dark.svg") }
|
||||
val plugin by lazy { DynamicIcon("icons/plugin.svg", "icons/plugin_dark.svg") }
|
||||
val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") }
|
||||
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
|
||||
val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }
|
||||
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
|
||||
val settingSync by lazy { DynamicIcon("icons/settingSync.svg", "icons/settingSync_dark.svg") }
|
||||
val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") }
|
||||
val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") }
|
||||
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
|
||||
val inspectionsEye by lazy { DynamicIcon("icons/inspectionsEye.svg", "icons/inspectionsEye_dark.svg") }
|
||||
val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
|
||||
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
|
||||
val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") }
|
||||
val regex by lazy { DynamicIcon("icons/regex.svg", "icons/regex_dark.svg") }
|
||||
val vcs by lazy { DynamicIcon("icons/vcs.svg", "icons/vcs_dark.svg") }
|
||||
val dumpThreads by lazy { DynamicIcon("icons/dumpThreads.svg", "icons/dumpThreads_dark.svg") }
|
||||
val supertypes by lazy { DynamicIcon("icons/supertypes.svg", "icons/supertypes_dark.svg") }
|
||||
val fitContent by lazy { DynamicIcon("icons/fitContent.svg", "icons/fitContent_dark.svg") }
|
||||
val settings by lazy { DynamicIcon("icons/settings.svg", "icons/settings_dark.svg") }
|
||||
val copy by lazy { DynamicIcon("icons/copy.svg", "icons/copy_dark.svg") }
|
||||
val delete by lazy { DynamicIcon("icons/delete.svg", "icons/delete_dark.svg") }
|
||||
val pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") }
|
||||
val empty by lazy { DynamicIcon("icons/empty.svg") }
|
||||
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") }
|
||||
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
|
||||
val locate by lazy { DynamicIcon("icons/locate.svg", "icons/locate_dark.svg") }
|
||||
val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") }
|
||||
val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") }
|
||||
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
|
||||
val warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_dark.svg") }
|
||||
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
|
||||
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
|
||||
val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") }
|
||||
@@ -26,28 +47,33 @@ object Icons {
|
||||
val rec by lazy { DynamicIcon("icons/rec.svg", "icons/rec_dark.svg") }
|
||||
val stop by lazy { DynamicIcon("icons/stop.svg", "icons/stop_dark.svg") }
|
||||
val find by lazy { DynamicIcon("icons/find.svg", "icons/find_dark.svg") }
|
||||
val bookmarks by lazy { DynamicIcon("icons/bookmarks.svg", "icons/bookmarks_dark.svg") }
|
||||
val bookmarksOff by lazy { DynamicIcon("icons/bookmarksOff.svg", "icons/bookmarksOff_dark.svg") }
|
||||
val keyboard by lazy { DynamicIcon("icons/keyboard.svg", "icons/keyboard_dark.svg") }
|
||||
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") }
|
||||
val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
|
||||
val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") }
|
||||
val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_dark.svg") }
|
||||
val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") }
|
||||
val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") }
|
||||
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
|
||||
val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") }
|
||||
val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_dark.svg") }
|
||||
val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_dark.svg") }
|
||||
val azure by lazy { DynamicIcon("icons/azure.svg", "icons/azure_dark.svg") }
|
||||
val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") }
|
||||
val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") }
|
||||
val microsoft by lazy { DynamicIcon("icons/microsoft.svg", "icons/microsoft_dark.svg") }
|
||||
val microsoftWindows by lazy { DynamicIcon("icons/microsoftWindows.svg", "icons/microsoftWindows_dark.svg") }
|
||||
val tencent by lazy { DynamicIcon("icons/tencent.svg") }
|
||||
val google by lazy { DynamicIcon("icons/google-small.svg") }
|
||||
val aliyun by lazy { DynamicIcon("icons/aliyun.svg") }
|
||||
val yandexCloud by lazy { DynamicIcon("icons/yandexCloud.svg") }
|
||||
val aws by lazy { DynamicIcon("icons/aws.svg","icons/aws_dark.svg") }
|
||||
val aws by lazy { DynamicIcon("icons/aws.svg", "icons/aws_dark.svg") }
|
||||
val huawei by lazy { DynamicIcon("icons/huawei.svg") }
|
||||
val baidu by lazy { DynamicIcon("icons/baiduyun.svg") }
|
||||
val tianyi by lazy { DynamicIcon("icons/tianyiyun.svg") }
|
||||
val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg","icons/digitalocean_dark.svg") }
|
||||
val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg", "icons/digitalocean_dark.svg") }
|
||||
val terminalUnread by lazy { DynamicIcon("icons/terminalUnread.svg", "icons/terminalUnread_dark.svg") }
|
||||
val dbPrimitive by lazy { DynamicIcon("icons/dbPrimitive.svg", "icons/dbPrimitive_dark.svg") }
|
||||
val linux by lazy { DynamicIcon("icons/linux.svg", "icons/linux_dark.svg") }
|
||||
@@ -55,6 +81,7 @@ object Icons {
|
||||
val network by lazy { DynamicIcon("icons/network.svg", "icons/network_dark.svg") }
|
||||
val server by lazy { DynamicIcon("icons/server.svg", "icons/server_dark.svg") }
|
||||
val runAnything by lazy { DynamicIcon("icons/runAnything.svg", "icons/runAnything_dark.svg") }
|
||||
val run by lazy { DynamicIcon("icons/run.svg", "icons/run_dark.svg") }
|
||||
val uiForm by lazy { DynamicIcon("icons/uiForm.svg", "icons/uiForm_dark.svg") }
|
||||
val cloud by lazy { DynamicIcon("icons/cloud.svg", "icons/cloud_dark.svg") }
|
||||
val externalLink by lazy { DynamicIcon("icons/externalLink.svg", "icons/externalLink_dark.svg") }
|
||||
@@ -64,10 +91,32 @@ object Icons {
|
||||
val split by lazy { DynamicIcon("icons/split.svg", "icons/split_dark.svg") }
|
||||
val setKey by lazy { DynamicIcon("icons/setKey.svg", "icons/setKey_dark.svg") }
|
||||
val greyKey by lazy { DynamicIcon("icons/greyKey.svg", "icons/greyKey_dark.svg") }
|
||||
val refresh by lazy { DynamicIcon("icons/refresh.svg", "icons/refresh_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 folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
|
||||
val file by lazy { DynamicIcon("icons/file.svg", "icons/file_dark.svg") }
|
||||
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
|
||||
val left by lazy { DynamicIcon("icons/left.svg", "icons/left_dark.svg") }
|
||||
val right by lazy { DynamicIcon("icons/right.svg", "icons/right_dark.svg") }
|
||||
val dotListFiles by lazy { DynamicIcon("icons/dotListFiles.svg", "icons/dotListFiles_dark.svg") }
|
||||
val anyType by lazy { DynamicIcon("icons/anyType.svg", "icons/anyType_dark.svg") }
|
||||
val toolWindowJsonPath by lazy { DynamicIcon("icons/toolWindowJsonPath.svg", "icons/toolWindowJsonPath_dark.svg") }
|
||||
val codeSpan by lazy { DynamicIcon("icons/codeSpan.svg", "icons/codeSpan_dark.svg") }
|
||||
val fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
|
||||
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") }
|
||||
val applyNotConflictsLeft by lazy {
|
||||
DynamicIcon(
|
||||
"icons/applyNotConflictsLeft.svg",
|
||||
"icons/applyNotConflictsLeft_dark.svg"
|
||||
)
|
||||
}
|
||||
val applyNotConflictsRight by lazy {
|
||||
DynamicIcon(
|
||||
"icons/applyNotConflictsRight.svg",
|
||||
"icons/applyNotConflictsRight_dark.svg"
|
||||
)
|
||||
}
|
||||
val expand by lazy { DynamicIcon("icons/expand.svg", "icons/expand_dark.svg") }
|
||||
val collapse by lazy { DynamicIcon("icons/collapse.svg", "icons/collapse_dark.svg") }
|
||||
val expandAll by lazy { DynamicIcon("icons/expandAll.svg", "icons/expandAll_dark.svg") }
|
||||
@@ -79,5 +128,6 @@ object Icons {
|
||||
val listKey by lazy { DynamicIcon("icons/listKey.svg", "icons/listKey_dark.svg") }
|
||||
val forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") }
|
||||
val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_dark.svg") }
|
||||
val nvidia by lazy { DynamicIcon("icons/nvidia.svg", "icons/nvidia_dark.svg") }
|
||||
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.extras.components.FlatTextField
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.Window
|
||||
import java.awt.event.KeyAdapter
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.UIManager
|
||||
|
||||
class InputDialog(
|
||||
owner: Window,
|
||||
title: String,
|
||||
text: String = StringUtils.EMPTY,
|
||||
placeholderText: String = StringUtils.EMPTY
|
||||
) : DialogWrapper(owner) {
|
||||
private val textField = FlatTextField()
|
||||
private var text: String? = null
|
||||
|
||||
init {
|
||||
setSize(340, 60)
|
||||
setLocationRelativeTo(owner)
|
||||
|
||||
super.setTitle(title)
|
||||
|
||||
isResizable = false
|
||||
isModal = true
|
||||
controlsVisible = false
|
||||
titleBarHeight = UIManager.getInt("TabbedPane.tabHeight") * 0.8f
|
||||
|
||||
|
||||
textField.placeholderText = placeholderText
|
||||
textField.text = text
|
||||
textField.addKeyListener(object : KeyAdapter() {
|
||||
override fun keyPressed(e: KeyEvent) {
|
||||
if (e.keyCode == KeyEvent.VK_ENTER) {
|
||||
if (textField.text.isBlank()) {
|
||||
return
|
||||
}
|
||||
doOKAction()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
textField.background = UIManager.getColor("window")
|
||||
textField.border = BorderFactory.createEmptyBorder(0, 13, 0, 13)
|
||||
|
||||
return textField
|
||||
}
|
||||
|
||||
fun getText(): String? {
|
||||
isVisible = true
|
||||
return text
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
text = null
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
text = textField.text
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
override fun createSouthPanel(): JComponent? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,58 @@ import com.formdev.flatlaf.util.SystemInfo
|
||||
import java.util.*
|
||||
|
||||
|
||||
class LightLaf : FlatLightLaf(), ColorTheme {
|
||||
interface LafTag
|
||||
interface LightLafTag : LafTag
|
||||
interface DarkLafTag : LafTag
|
||||
|
||||
class DraculaLaf : FlatPropertiesLaf("Dracula", Properties().apply {
|
||||
putAll(
|
||||
mapOf(
|
||||
"@baseTheme" to "dark",
|
||||
"@background" to "#282935",
|
||||
"@windowText" to "#eaeaea",
|
||||
)
|
||||
)
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Basic.BACKGROUND -> 0x282935
|
||||
TerminalColor.Basic.FOREGROUND -> 0xeaeaea
|
||||
TerminalColor.Basic.SELECTION_BACKGROUND -> 0x56596b
|
||||
TerminalColor.Basic.SELECTION_FOREGROUND -> 0xfeffff
|
||||
TerminalColor.Basic.HYPERLINK -> 0x255ab4
|
||||
|
||||
TerminalColor.Cursor.BACKGROUND -> 0xc7c7c7
|
||||
|
||||
TerminalColor.Find.BACKGROUND -> 0xffff00
|
||||
TerminalColor.Find.FOREGROUND -> 0x282935
|
||||
|
||||
TerminalColor.Normal.BLACK -> 0
|
||||
TerminalColor.Normal.RED -> 0xef766d
|
||||
TerminalColor.Normal.GREEN -> 0x88f397
|
||||
TerminalColor.Normal.YELLOW -> 0xf4f8a7
|
||||
TerminalColor.Normal.BLUE -> 0xc4a9f4
|
||||
TerminalColor.Normal.MAGENTA -> 0xf297cd
|
||||
TerminalColor.Normal.CYAN -> 0xaceafb
|
||||
TerminalColor.Normal.WHITE -> 0xc7c7c7
|
||||
|
||||
TerminalColor.Bright.BLACK -> 0x676767
|
||||
TerminalColor.Bright.RED -> 0xef766d
|
||||
TerminalColor.Bright.GREEN -> 0x88f397
|
||||
TerminalColor.Bright.YELLOW -> 0xf4f8a7
|
||||
TerminalColor.Bright.BLUE -> 0xc4a9f4
|
||||
TerminalColor.Bright.MAGENTA -> 0xf297cd
|
||||
TerminalColor.Bright.CYAN -> 0xaceafb
|
||||
TerminalColor.Bright.WHITE -> 0xfeffff
|
||||
|
||||
else -> Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class LightLaf : FlatLightLaf(), ColorTheme, LightLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0
|
||||
@@ -36,7 +87,7 @@ class LightLaf : FlatLightLaf(), ColorTheme {
|
||||
}
|
||||
|
||||
|
||||
class DarkLaf : FlatDarkLaf(), ColorTheme {
|
||||
class DarkLaf : FlatDarkLaf(), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0
|
||||
@@ -65,7 +116,7 @@ class DarkLaf : FlatDarkLaf(), ColorTheme {
|
||||
}
|
||||
}
|
||||
|
||||
class iTerm2DarkLaf : FlatDarkLaf(), ColorTheme {
|
||||
class iTerm2DarkLaf : FlatDarkLaf(), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
|
||||
@@ -113,7 +164,7 @@ class TermiusLightLaf : FlatPropertiesLaf("Termius Light", Properties().apply {
|
||||
"@windowText" to "#32364a",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, LightLafTag {
|
||||
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
|
||||
@@ -156,14 +207,14 @@ class TermiusDarkLaf : FlatPropertiesLaf("Termius Dark", Properties().apply {
|
||||
"@windowText" to "#21b568",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Basic.SELECTION_BACKGROUND,
|
||||
TerminalColor.Cursor.BACKGROUND -> 0x21b568
|
||||
|
||||
TerminalColor.Basic.SELECTION_FOREGROUND ->0
|
||||
TerminalColor.Basic.SELECTION_FOREGROUND -> 0
|
||||
|
||||
TerminalColor.Basic.FOREGROUND -> 0x21b568
|
||||
|
||||
@@ -198,7 +249,7 @@ class NovelLaf : FlatPropertiesLaf("Novel", Properties().apply {
|
||||
"@windowText" to "#3b2322",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, LightLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x000000
|
||||
@@ -237,7 +288,7 @@ class AtomOneDarkLaf : FlatPropertiesLaf("Atom One Dark", Properties().apply {
|
||||
"@windowText" to "#abb2bf",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x000000
|
||||
@@ -275,7 +326,7 @@ class AtomOneLightLaf : FlatPropertiesLaf("Atom One Light", Properties().apply {
|
||||
"@windowText" to "#383a42",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, LightLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x000000
|
||||
@@ -313,7 +364,7 @@ class EverforestDarkLaf : FlatPropertiesLaf("Everforest Dark", Properties().appl
|
||||
"@windowText" to "#d3c6aa",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x42494e
|
||||
@@ -350,7 +401,7 @@ class EverforestLightLaf : FlatPropertiesLaf("Everforest Light", Properties().ap
|
||||
"@windowText" to "#5c6a72",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, LightLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x42494e
|
||||
@@ -387,7 +438,7 @@ class NightOwlLaf : FlatPropertiesLaf("Night Owl", Properties().apply {
|
||||
"@windowText" to "#d6deeb",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x072945
|
||||
@@ -424,7 +475,7 @@ class LightOwlLaf : FlatPropertiesLaf("Light Owl", Properties().apply {
|
||||
"@windowText" to "#403f53",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, LightLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x403f53
|
||||
@@ -461,7 +512,7 @@ class AuraLaf : FlatPropertiesLaf("Aura", Properties().apply {
|
||||
"@windowText" to "#edecee",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x1c1b22
|
||||
@@ -498,7 +549,7 @@ class Cobalt2Laf : FlatPropertiesLaf("Cobalt2", Properties().apply {
|
||||
"@windowText" to "#ffffff",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x000000
|
||||
@@ -535,7 +586,7 @@ class OctocatDarkLaf : FlatPropertiesLaf("Octocat Dark", Properties().apply {
|
||||
"@windowText" to "#8b949e",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x000000
|
||||
@@ -572,7 +623,7 @@ class OctocatLightLaf : FlatPropertiesLaf("Octocat Light", Properties().apply {
|
||||
"@windowText" to "#3e3e3e",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, LightLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x000000
|
||||
@@ -609,7 +660,7 @@ class AyuDarkLaf : FlatPropertiesLaf("Ayu Dark", Properties().apply {
|
||||
"@windowText" to "#e6e1cf",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x000000
|
||||
@@ -646,7 +697,7 @@ class AyuLightLaf : FlatPropertiesLaf("Ayu Light", Properties().apply {
|
||||
"@windowText" to "#5c6773",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, LightLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x000000
|
||||
@@ -683,7 +734,7 @@ class HomebrewLaf : FlatPropertiesLaf("Homebrew", Properties().apply {
|
||||
"@windowText" to "#00ff00",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x2e2e2e
|
||||
@@ -722,7 +773,7 @@ class ProLaf : FlatPropertiesLaf("Pro", Properties().apply {
|
||||
"@windowText" to "#f2f2f2",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x2e2e2e
|
||||
@@ -761,7 +812,7 @@ class NordLightLaf : FlatPropertiesLaf("Nord Light", Properties().apply {
|
||||
"@windowText" to "#414858",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, LightLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x2c3344
|
||||
@@ -800,7 +851,7 @@ class NordDarkLaf : FlatPropertiesLaf("Nord Dark", Properties().apply {
|
||||
"@windowText" to "#d8dee9",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x3b4252
|
||||
@@ -840,7 +891,7 @@ class GitHubLightLaf : FlatPropertiesLaf("GitHub Light", Properties().apply {
|
||||
"@windowText" to "#3e3e3e",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, LightLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x3e3e3e
|
||||
@@ -879,7 +930,7 @@ class GitHubDarkLaf : FlatPropertiesLaf("GitHub Dark", Properties().apply {
|
||||
"@windowText" to "#8b949e",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x000000
|
||||
@@ -919,7 +970,7 @@ class ChalkLaf : FlatPropertiesLaf("Chalk", Properties().apply {
|
||||
"@windowText" to "#d2d8d9",
|
||||
)
|
||||
)
|
||||
}), ColorTheme {
|
||||
}), ColorTheme, DarkLafTag {
|
||||
override fun getColor(color: TerminalColor): Int {
|
||||
return when (color) {
|
||||
TerminalColor.Normal.BLACK -> 0x7d8b8f
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.PtyConnectorDelegate
|
||||
import app.termora.terminal.PtyProcessConnector
|
||||
import org.apache.commons.io.Charsets
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class LocalTerminalTab(host: Host) : PtyHostTerminalTab(host) {
|
||||
class LocalTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
PtyHostTerminalTab(windowScope, host) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(LocalTerminalTab::class.java)
|
||||
}
|
||||
|
||||
override suspend fun openPtyConnector(): PtyConnector {
|
||||
val winSize = terminalPanel.winSize()
|
||||
val ptyConnector = PtyConnectorFactory.instance.createPtyConnector(
|
||||
val ptyConnector = PtyConnectorFactory.getInstance().createPtyConnector(
|
||||
winSize.rows, winSize.cols,
|
||||
host.options.envs(),
|
||||
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
|
||||
@@ -17,4 +28,42 @@ class LocalTerminalTab(host: Host) : PtyHostTerminalTab(host) {
|
||||
return ptyConnector
|
||||
}
|
||||
|
||||
|
||||
override fun willBeClose(): Boolean {
|
||||
val ptyProcessConnector = getPtyProcessConnector() ?: return true
|
||||
val process = ptyProcessConnector.process
|
||||
var consoleProcessCount = 0
|
||||
|
||||
try {
|
||||
val processHandle = ProcessHandle.of(process.pid()).getOrNull()
|
||||
if (processHandle != null) {
|
||||
consoleProcessCount = processHandle.children().count().toInt()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有正在运行的进程
|
||||
if (consoleProcessCount < 1) return true
|
||||
|
||||
val owner = SwingUtilities.getWindowAncestor(terminalPanel) ?: return true
|
||||
return OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
I18n.getString("termora.tabbed.local-tab.close-prompt"),
|
||||
messageType = JOptionPane.INFORMATION_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
) == JOptionPane.OK_OPTION
|
||||
}
|
||||
|
||||
|
||||
private fun getPtyProcessConnector(): PtyProcessConnector? {
|
||||
var p = getPtyConnector() as PtyConnector?
|
||||
while (p != null) {
|
||||
if (p is PtyProcessConnector) return p
|
||||
if (p is PtyConnectorDelegate) p = p.ptyConnector
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.jetbrains.JBR
|
||||
import com.jetbrains.WindowDecorations.CustomTitleBar
|
||||
import java.awt.Rectangle
|
||||
import java.awt.Window
|
||||
import javax.swing.RootPaneContainer
|
||||
|
||||
class LogicCustomTitleBar(private val titleBar: CustomTitleBar) : CustomTitleBar {
|
||||
companion object {
|
||||
fun createCustomTitleBar(rootPaneContainer: RootPaneContainer): CustomTitleBar {
|
||||
if (!JBR.isWindowDecorationsSupported()) {
|
||||
return LogicCustomTitleBar(object : CustomTitleBar {
|
||||
override fun getHeight(): Float {
|
||||
val bounds = rootPaneContainer.rootPane
|
||||
.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
|
||||
if (bounds is Rectangle) {
|
||||
return bounds.height.toFloat()
|
||||
}
|
||||
return 0f
|
||||
}
|
||||
|
||||
override fun setHeight(height: Float) {
|
||||
rootPaneContainer.rootPane.putClientProperty(
|
||||
FlatClientProperties.TITLE_BAR_HEIGHT,
|
||||
height.toInt()
|
||||
)
|
||||
}
|
||||
|
||||
override fun getProperties(): MutableMap<String, Any> {
|
||||
return mutableMapOf()
|
||||
}
|
||||
|
||||
override fun putProperties(m: MutableMap<String, *>?) {
|
||||
|
||||
}
|
||||
|
||||
override fun putProperty(key: String?, value: Any?) {
|
||||
if (key == "controls.visible" && value is Boolean) {
|
||||
rootPaneContainer.rootPane.putClientProperty(
|
||||
FlatClientProperties.TITLE_BAR_SHOW_CLOSE,
|
||||
value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLeftInset(): Float {
|
||||
return 0f
|
||||
}
|
||||
|
||||
override fun getRightInset(): Float {
|
||||
val bounds = rootPaneContainer.rootPane
|
||||
.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
|
||||
if (bounds is Rectangle) {
|
||||
return bounds.width.toFloat()
|
||||
}
|
||||
return 0f
|
||||
}
|
||||
|
||||
override fun forceHitTest(client: Boolean) {
|
||||
|
||||
}
|
||||
|
||||
override fun getContainingWindow(): Window {
|
||||
return rootPaneContainer as Window
|
||||
}
|
||||
})
|
||||
}
|
||||
return JBR.getWindowDecorations().createCustomTitleBar()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getHeight(): Float {
|
||||
return titleBar.height
|
||||
}
|
||||
|
||||
override fun setHeight(height: Float) {
|
||||
titleBar.height = height
|
||||
}
|
||||
|
||||
override fun getProperties(): MutableMap<String, Any> {
|
||||
return titleBar.properties
|
||||
}
|
||||
|
||||
override fun putProperties(m: MutableMap<String, *>?) {
|
||||
titleBar.putProperties(m)
|
||||
}
|
||||
|
||||
override fun putProperty(key: String?, value: Any?) {
|
||||
titleBar.putProperty(key, value)
|
||||
}
|
||||
|
||||
override fun getLeftInset(): Float {
|
||||
return titleBar.leftInset
|
||||
}
|
||||
|
||||
override fun getRightInset(): Float {
|
||||
return titleBar.rightInset
|
||||
}
|
||||
|
||||
override fun forceHitTest(client: Boolean) {
|
||||
titleBar.forceHitTest(client)
|
||||
}
|
||||
|
||||
override fun getContainingWindow(): Window {
|
||||
return titleBar.containingWindow
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package app.termora
|
||||
|
||||
fun main() {
|
||||
ApplicationRunner().run()
|
||||
ApplicationInitializr().run()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.PtyConnectorDelegate
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
|
||||
/**
|
||||
* 当开启转发时,会获取到所有的 [PtyConnector] 然后跳过中间层,直接找到最近的一个 [MultiplePtyConnector],如果找不到那就以最后一个匹配不到的为准 [getMultiplePtyConnector]。
|
||||
*/
|
||||
class MultiplePtyConnector(private val myConnector: PtyConnector) : PtyConnectorDelegate(myConnector) {
|
||||
|
||||
private val isMultiple get() = ActionManager.getInstance().isSelected(Actions.MULTIPLE)
|
||||
private val ptyConnectors get() = PtyConnectorFactory.instance.getPtyConnectors()
|
||||
|
||||
override fun write(buffer: ByteArray, offset: Int, len: Int) {
|
||||
if (isMultiple) {
|
||||
for (connector in ptyConnectors) {
|
||||
getMultiplePtyConnector(connector).write(buffer, offset, len)
|
||||
}
|
||||
} else {
|
||||
myConnector.write(buffer, offset, len)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getMultiplePtyConnector(connector: PtyConnector): PtyConnector {
|
||||
if (connector is MultiplePtyConnector) {
|
||||
val c = connector.myConnector
|
||||
if (c is MultiplePtyConnector) {
|
||||
return getMultiplePtyConnector(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
if (connector is PtyConnectorDelegate) {
|
||||
val c = connector.ptyConnector
|
||||
if (c != null) {
|
||||
return getMultiplePtyConnector(c)
|
||||
}
|
||||
}
|
||||
|
||||
return connector
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
package app.termora
|
||||
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.actions.MultipleAction
|
||||
import app.termora.terminal.Terminal
|
||||
import app.termora.terminal.TerminalColor
|
||||
import app.termora.terminal.TextStyle
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
import app.termora.terminal.panel.TerminalDisplay
|
||||
import app.termora.terminal.panel.TerminalPaintListener
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.Color
|
||||
import java.awt.Graphics
|
||||
import java.util.*
|
||||
|
||||
class MultipleTerminalListener : TerminalPaintListener {
|
||||
override fun after(
|
||||
@@ -19,9 +25,9 @@ class MultipleTerminalListener : TerminalPaintListener {
|
||||
terminalDisplay: TerminalDisplay,
|
||||
terminal: Terminal
|
||||
) {
|
||||
if (!ActionManager.getInstance().isSelected(Actions.MULTIPLE)) {
|
||||
return
|
||||
}
|
||||
val windowScope = AnActionEvent(terminalPanel, StringUtils.EMPTY, EventObject(terminalPanel))
|
||||
.getData(DataProviders.WindowScope) ?: return
|
||||
if (!MultipleAction.getInstance(windowScope).isSelected) return
|
||||
|
||||
val oldFont = g.font
|
||||
val colorPalette = terminal.getTerminalModel().getColorPalette()
|
||||
@@ -31,13 +37,25 @@ class MultipleTerminalListener : TerminalPaintListener {
|
||||
// 正在搜索那么需要下移
|
||||
val finding = terminal.getTerminalModel().getData(TerminalPanel.Finding, false)
|
||||
|
||||
// 如果悬浮窗正在显示,那么需要下移
|
||||
val floatingToolBar = terminalPanel.getData(FloatingToolbarPanel.FloatingToolbar)?.isVisible == true
|
||||
|
||||
var y = g.fontMetrics.ascent
|
||||
if (finding) {
|
||||
y += g.fontMetrics.height + g.fontMetrics.ascent / 2
|
||||
}
|
||||
|
||||
if (floatingToolBar) {
|
||||
y += g.fontMetrics.height + g.fontMetrics.ascent / 2
|
||||
}
|
||||
|
||||
|
||||
g.font = font
|
||||
g.color = Color(colorPalette.getColor(TerminalColor.Normal.RED))
|
||||
g.drawString(
|
||||
text,
|
||||
terminalDisplay.width - width - terminalPanel.getAverageCharWidth() / 2,
|
||||
g.fontMetrics.ascent + if (finding)
|
||||
g.fontMetrics.height + g.fontMetrics.ascent / 2 else 0
|
||||
y
|
||||
)
|
||||
g.font = oldFont
|
||||
}
|
||||
|
||||
@@ -1,11 +1,270 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.*
|
||||
import java.awt.image.BufferedImage
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import kotlin.math.abs
|
||||
|
||||
class MyTabbedPane : FlatTabbedPane() {
|
||||
|
||||
private val dragMouseAdaptor = DragMouseAdaptor()
|
||||
private val terminalTabbedManager
|
||||
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
|
||||
.getData(DataProviders.TerminalTabbedManager)
|
||||
private val owner
|
||||
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
|
||||
.getData(DataProviders.TermoraFrame) as TermoraFrame
|
||||
|
||||
init {
|
||||
isFocusable = false
|
||||
initEvents()
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
styleMap = mapOf(
|
||||
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
|
||||
"hoverColor" to UIManager.getColor("TabbedPane.background"),
|
||||
)
|
||||
super.updateUI()
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addMouseListener(dragMouseAdaptor)
|
||||
addMouseMotionListener(dragMouseAdaptor)
|
||||
}
|
||||
|
||||
override fun processMouseEvent(e: MouseEvent) {
|
||||
// Shift + Click ===> close tab
|
||||
if (e.id == MouseEvent.MOUSE_CLICKED && SwingUtilities.isLeftMouseButton(e) && isShiftPressedOnly(e.modifiersEx)) {
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
tabCloseCallback?.accept(this, index)
|
||||
return
|
||||
}
|
||||
} else if (e.id == MouseEvent.MOUSE_PRESSED && isShiftPressedOnly(e.modifiersEx)) {
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
super.processMouseEvent(e)
|
||||
}
|
||||
|
||||
private fun isShiftPressedOnly(modifiersEx: Int): Boolean {
|
||||
return (modifiersEx and InputEvent.ALT_DOWN_MASK) == 0
|
||||
&& (modifiersEx and InputEvent.ALT_GRAPH_DOWN_MASK) == 0
|
||||
&& (modifiersEx and InputEvent.CTRL_DOWN_MASK) == 0
|
||||
&& (modifiersEx and InputEvent.SHIFT_DOWN_MASK) != 0
|
||||
}
|
||||
|
||||
override fun setSelectedIndex(index: Int) {
|
||||
val oldIndex = selectedIndex
|
||||
super.setSelectedIndex(index)
|
||||
firePropertyChange("selectedIndex", oldIndex,index)
|
||||
firePropertyChange("selectedIndex", oldIndex, index)
|
||||
}
|
||||
|
||||
|
||||
private inner class DragMouseAdaptor : MouseAdapter(), KeyEventDispatcher {
|
||||
private var mousePressedPoint = Point()
|
||||
private var tabIndex = 0 - 1
|
||||
private var cancelled = false
|
||||
private var window: Window? = null
|
||||
private var terminalTab: TerminalTab? = null
|
||||
private var isDragging = false
|
||||
private var lastVisitTabIndex = -1
|
||||
private var releasedPoint = Point()
|
||||
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index < 0 || !isTabClosable(index)) {
|
||||
tabIndex = -1
|
||||
mousePressedPoint = Point()
|
||||
return
|
||||
}
|
||||
tabIndex = index
|
||||
mousePressedPoint = e.point
|
||||
}
|
||||
|
||||
override fun mouseDragged(e: MouseEvent) {
|
||||
// 如果正在拖拽中,那么修改 Window 的位置
|
||||
if (isDragging) {
|
||||
window?.location = e.locationOnScreen
|
||||
lastVisitTabIndex = indexAtLocation(e.x, e.y)
|
||||
} else if (tabIndex >= 0) { // 这里之所以判断是确保在 mousePressed 时已经确定了 Tab
|
||||
// 有的时候会太灵敏,这里容错一下
|
||||
val diff = 5
|
||||
if (abs(mousePressedPoint.y - e.y) >= diff || abs(mousePressedPoint.x - e.x) >= diff) {
|
||||
startDrag(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDrag(e: MouseEvent) {
|
||||
if (isDragging) return
|
||||
val terminalTabbedManager = terminalTabbedManager ?: return
|
||||
val window = JDialog(owner).also { this.window = it }
|
||||
window.isUndecorated = true
|
||||
val image = createTabImage(tabIndex)
|
||||
window.size = Dimension(image.width, image.height)
|
||||
window.add(JLabel(ImageIcon(image)))
|
||||
window.location = e.locationOnScreen
|
||||
window.addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||
.removeKeyEventDispatcher(this@DragMouseAdaptor)
|
||||
}
|
||||
|
||||
override fun windowOpened(e: WindowEvent) {
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||
.addKeyEventDispatcher(this@DragMouseAdaptor)
|
||||
}
|
||||
})
|
||||
|
||||
// 暂时关闭 Tab
|
||||
terminalTabbedManager.closeTerminalTab(terminalTabbedManager.getTerminalTabs()[tabIndex].also {
|
||||
terminalTab = it
|
||||
}, false)
|
||||
|
||||
window.isVisible = true
|
||||
|
||||
isDragging = true
|
||||
cancelled = false
|
||||
}
|
||||
|
||||
private fun stopDrag() {
|
||||
if (!isDragging) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是取消,那么不需要移动到其它窗口
|
||||
val c = if (cancelled) owner else getTopMostWindowUnderMouse()
|
||||
|
||||
// 如果等于 null 表示在空地方释放,那么单独一个窗口
|
||||
if (c == null) {
|
||||
val window = TermoraFrameManager.getInstance().createWindow()
|
||||
dragToAnotherWindow(owner, window)
|
||||
window.location = releasedPoint
|
||||
window.isVisible = true
|
||||
} else if (c != owner && c is TermoraFrame) { // 如果在某个窗口内释放,那么就移动到某个窗口内
|
||||
dragToAnotherWindow(owner, c)
|
||||
} else {
|
||||
val tab = this.terminalTab
|
||||
val terminalTabbedManager = terminalTabbedManager
|
||||
if (tab != null && terminalTabbedManager != null) {
|
||||
moveTab(
|
||||
terminalTabbedManager,
|
||||
tab,
|
||||
lastVisitTabIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// reset
|
||||
window?.dispose()
|
||||
isDragging = false
|
||||
tabIndex = -1
|
||||
cancelled = false
|
||||
lastVisitTabIndex = -1
|
||||
}
|
||||
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
releasedPoint = e.point
|
||||
stopDrag()
|
||||
}
|
||||
|
||||
private fun createTabImage(index: Int): BufferedImage {
|
||||
val tabBounds = getBoundsAt(index)
|
||||
val image = BufferedImage(tabBounds.width, tabBounds.height, BufferedImage.TYPE_INT_ARGB)
|
||||
val g2 = image.createGraphics()
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
|
||||
g2.translate(-tabBounds.x, -tabBounds.y)
|
||||
paint(g2)
|
||||
g2.dispose()
|
||||
return image
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
|
||||
if (e.keyCode == KeyEvent.VK_ESCAPE) {
|
||||
cancelled = true
|
||||
stopDrag()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getTopMostWindowUnderMouse(): Window? {
|
||||
val mouseLocation = MouseInfo.getPointerInfo().location
|
||||
val owner = owner
|
||||
if (owner.isVisible && owner.bounds.contains(mouseLocation)) {
|
||||
return owner
|
||||
}
|
||||
|
||||
val windows = Window.getWindows()
|
||||
// 倒序遍历,最上层的窗口优先匹配
|
||||
for (i in windows.indices.reversed()) {
|
||||
val window = windows[i]
|
||||
if (window !is TermoraFrame) {
|
||||
continue
|
||||
}
|
||||
if (window.isVisible && window.bounds.contains(mouseLocation)) {
|
||||
val topComponent = SwingUtilities.getDeepestComponentAt(
|
||||
window,
|
||||
mouseLocation.x - window.x, mouseLocation.y - window.y
|
||||
)
|
||||
if (topComponent != null) {
|
||||
return SwingUtilities.getWindowAncestor(topComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
private fun dragToAnotherWindow(oldFrame: TermoraFrame, frame: TermoraFrame) {
|
||||
val tab = this.terminalTab ?: return
|
||||
val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return
|
||||
val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return
|
||||
val location = Point(MouseInfo.getPointerInfo().location)
|
||||
SwingUtilities.convertPointFromScreen(location, tabbedPane)
|
||||
val index = tabbedPane.indexAtLocation(location.x, location.y)
|
||||
|
||||
|
||||
moveTab(
|
||||
tabbedManager,
|
||||
tab,
|
||||
index
|
||||
)
|
||||
|
||||
if (frame.hasFocus()) {
|
||||
return
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
frame.requestFocus()
|
||||
tabbedPane.selectedComponent?.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveTab(terminalTabbedManager: TerminalTabbedManager, tab: TerminalTab, lastVisitTabIndex: Int) {
|
||||
// 如果是手动取消
|
||||
if (cancelled) {
|
||||
terminalTabbedManager.addTerminalTab(tabIndex, tab)
|
||||
} else if (lastVisitTabIndex > 0) {
|
||||
terminalTabbedManager.addTerminalTab(lastVisitTabIndex, tab)
|
||||
} else if (lastVisitTabIndex == 0) {
|
||||
terminalTabbedManager.addTerminalTab(1, tab)
|
||||
} else {
|
||||
terminalTabbedManager.addTerminalTab(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
41
src/main/kotlin/app/termora/NativeStringComparator.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import de.jangassen.jfa.foundation.Foundation
|
||||
import de.jangassen.jfa.foundation.Foundation.NSAutoreleasePool
|
||||
import java.text.Collator
|
||||
import java.util.*
|
||||
|
||||
class NativeStringComparator private constructor() : Comparator<String> {
|
||||
private val collator by lazy { Collator.getInstance(Locale.getDefault()).apply { strength = Collator.PRIMARY } }
|
||||
|
||||
companion object {
|
||||
fun getInstance(): NativeStringComparator {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(NativeStringComparator::class) { NativeStringComparator() }
|
||||
}
|
||||
|
||||
private const val SORT_DIGITSASNUMBERS: Int = 0x00000008
|
||||
|
||||
}
|
||||
|
||||
override fun compare(o1: String, o2: String): Int {
|
||||
if (SystemInfo.isWindows) {
|
||||
// CompareStringEx returns 1, 2, 3 respectively instead of -1, 0, 1
|
||||
return MyKernel32.INSTANCE.CompareStringEx(SORT_DIGITSASNUMBERS, o1, o2) - 2
|
||||
} else if (SystemInfo.isMacOS) {
|
||||
val pool = NSAutoreleasePool()
|
||||
try {
|
||||
val a = Foundation.nsString(o1)
|
||||
val b = Foundation.nsString(o2)
|
||||
return Foundation.invoke(a, "localizedStandardCompare:", b).toInt()
|
||||
} finally {
|
||||
pool.drain()
|
||||
}
|
||||
}
|
||||
|
||||
return collator.compare(o1, o2)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
923
src/main/kotlin/app/termora/NewHostTree.kt
Normal file
@@ -0,0 +1,923 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.actions.OpenHostAction
|
||||
import app.termora.sftp.SFTPActionEvent
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.*
|
||||
import org.apache.commons.csv.CSVFormat
|
||||
import org.apache.commons.csv.CSVParser
|
||||
import org.apache.commons.csv.CSVPrinter
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import org.apache.commons.io.filefilter.FileFilterUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.client.config.hosts.HostConfigEntry
|
||||
import org.ini4j.Ini
|
||||
import org.ini4j.Reg
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.w3c.dom.Element
|
||||
import org.w3c.dom.NodeList
|
||||
import java.awt.Component
|
||||
import java.awt.event.*
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.function.Function
|
||||
import javax.swing.*
|
||||
import javax.swing.event.PopupMenuEvent
|
||||
import javax.swing.event.PopupMenuListener
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
import javax.swing.tree.TreePath
|
||||
import javax.swing.tree.TreeSelectionModel
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import javax.xml.xpath.XPathConstants
|
||||
import javax.xml.xpath.XPathFactory
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
class NewHostTree : SimpleTree() {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(NewHostTree::class.java)
|
||||
private val CSV_HEADERS = arrayOf("Folders", "Label", "Hostname", "Port", "Username", "Protocol")
|
||||
}
|
||||
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||
private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
|
||||
private val sftpAction get() = ActionManager.getInstance().getAction(app.termora.Actions.SFTP)
|
||||
private var isShowMoreInfo
|
||||
get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
|
||||
set(value) = properties.putString("HostTree.showMoreInfo", value.toString())
|
||||
private var isPopupMenu = false
|
||||
override val model = NewHostTreeModel()
|
||||
|
||||
/**
|
||||
* 是否允许显示右键菜单
|
||||
*/
|
||||
var contextmenu = true
|
||||
|
||||
/**
|
||||
* 是否允许双击连接
|
||||
*/
|
||||
var doubleClickConnection = true
|
||||
|
||||
init {
|
||||
initViews()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
|
||||
private fun initViews() {
|
||||
super.setModel(model)
|
||||
isEditable = true
|
||||
dragEnabled = true
|
||||
isRootVisible = true
|
||||
dropMode = DropMode.ON_OR_INSERT
|
||||
selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
|
||||
|
||||
// renderer
|
||||
setCellRenderer(object : DefaultXTreeCellRenderer() {
|
||||
override fun getTreeCellRendererComponent(
|
||||
tree: JTree,
|
||||
value: Any,
|
||||
sel: Boolean,
|
||||
expanded: Boolean,
|
||||
leaf: Boolean,
|
||||
row: Int,
|
||||
hasFocus: Boolean
|
||||
): Component {
|
||||
val node = value as HostTreeNode
|
||||
val host = node.host
|
||||
var text = host.name
|
||||
|
||||
// 是否显示更多信息
|
||||
if (isShowMoreInfo) {
|
||||
val color = if (sel) {
|
||||
if (tree.hasFocus() || isPopupMenu) {
|
||||
UIManager.getColor("textHighlightText")
|
||||
} else {
|
||||
this.foreground
|
||||
}
|
||||
} else {
|
||||
UIManager.getColor("textInactiveText")
|
||||
}
|
||||
|
||||
val fontTag = Function<String, String> {
|
||||
"""<font color=rgb(${color.red},${color.green},${color.blue})>${it}</font>"""
|
||||
}
|
||||
|
||||
// @formatter:off
|
||||
if (host.protocol == Protocol.SSH || host.protocol == Protocol.RDP) {
|
||||
text = "<html>${host.name} ${fontTag.apply("${host.username}@${host.host}")}</html>"
|
||||
} else if (host.protocol == Protocol.Serial) {
|
||||
text = "<html>${host.name} ${fontTag.apply(host.options.serialComm.port)}</html>"
|
||||
} else if (host.protocol == Protocol.Folder) {
|
||||
text = "<html>${host.name}${fontTag.apply(" (${node.getAllChildren().size})")}</html>"
|
||||
}
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
|
||||
|
||||
icon = node.getIcon(sel, expanded, tree.hasFocus() || isPopupMenu)
|
||||
return c
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
// double click
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val lastNode = lastSelectedPathComponent as? HostTreeNode ?: return
|
||||
if (lastNode.host.protocol != Protocol.Folder) {
|
||||
val path = tree.getClosestPathForLocation(e.x, e.y) ?: return
|
||||
val bounds = tree.getRowBounds(tree.getRowForPath(path)) ?: return
|
||||
if ((e.y >= bounds.y && e.y < (bounds.y + bounds.height)).not()) return
|
||||
openHostAction?.actionPerformed(OpenHostActionEvent(e.source, lastNode.host, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addKeyListener(object : KeyAdapter() {
|
||||
override fun keyPressed(e: KeyEvent) {
|
||||
if (e.keyCode == KeyEvent.VK_ENTER && doubleClickConnection) {
|
||||
val nodes = getSelectionSimpleTreeNodes()
|
||||
if (nodes.size == 1 && nodes.first().host.protocol == Protocol.Folder) {
|
||||
val path = TreePath(model.getPathToRoot(nodes.first()))
|
||||
if (isExpanded(path)) {
|
||||
collapsePath(path)
|
||||
} else {
|
||||
expandPath(path)
|
||||
}
|
||||
} else {
|
||||
for (node in getSelectionSimpleTreeNodes(true)) {
|
||||
openHostAction?.actionPerformed(OpenHostActionEvent(e.source, node.host, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun showContextmenu(evt: MouseEvent) {
|
||||
if (!contextmenu) return
|
||||
val lastNode = lastSelectedPathComponent
|
||||
if (lastNode !is HostTreeNode) return
|
||||
|
||||
val nodes = getSelectionSimpleTreeNodes()
|
||||
val fullNodes = getSelectionSimpleTreeNodes(true)
|
||||
val lastNodeParent = lastNode.parent ?: model.root
|
||||
val lastHost = lastNode.host
|
||||
|
||||
val popupMenu = FlatPopupMenu()
|
||||
val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new"))
|
||||
val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder"))
|
||||
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host"))
|
||||
val importMenu = JMenu(I18n.getString("termora.welcome.contextmenu.import"))
|
||||
val csvMenu = importMenu.add("CSV")
|
||||
val xShellMenu = importMenu.add("Xshell")
|
||||
val puTTYMenu = importMenu.add("PuTTY")
|
||||
val electermMenu = importMenu.add("electerm")
|
||||
val finalShellMenu = importMenu.add("FinalShell")
|
||||
val windTermMenu = importMenu.add("WindTerm")
|
||||
val secureCRTMenu = importMenu.add("SecureCRT")
|
||||
val sshMenu = importMenu.add(".ssh/config")
|
||||
val mobaXtermMenu = importMenu.add("MobaXterm")
|
||||
|
||||
|
||||
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect"))
|
||||
val openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu
|
||||
val openWithSFTP = openWith.add("SFTP")
|
||||
val openWithSFTPCommand = openWith.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
|
||||
val openInNewWindow = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open-in-new-window"))
|
||||
popupMenu.addSeparator()
|
||||
val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy"))
|
||||
val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove"))
|
||||
val rename = popupMenu.add(I18n.getString("termora.welcome.contextmenu.rename"))
|
||||
popupMenu.addSeparator()
|
||||
val refresh = popupMenu.add(I18n.getString("termora.welcome.contextmenu.refresh"))
|
||||
val expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all"))
|
||||
val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all"))
|
||||
popupMenu.addSeparator()
|
||||
popupMenu.add(importMenu)
|
||||
popupMenu.add(newMenu)
|
||||
popupMenu.addSeparator()
|
||||
val showMoreInfo = JCheckBoxMenuItem(I18n.getString("termora.welcome.contextmenu.show-more-info"))
|
||||
showMoreInfo.isSelected = isShowMoreInfo
|
||||
showMoreInfo.addActionListener {
|
||||
isShowMoreInfo = !isShowMoreInfo
|
||||
SwingUtilities.updateComponentTreeUI(tree)
|
||||
}
|
||||
popupMenu.add(showMoreInfo)
|
||||
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
|
||||
|
||||
xShellMenu.addActionListener { importHosts(lastNode, ImportType.Xshell) }
|
||||
puTTYMenu.addActionListener { importHosts(lastNode, ImportType.PuTTY) }
|
||||
secureCRTMenu.addActionListener { importHosts(lastNode, ImportType.SecureCRT) }
|
||||
electermMenu.addActionListener { importHosts(lastNode, ImportType.electerm) }
|
||||
mobaXtermMenu.addActionListener { importHosts(lastNode, ImportType.MobaXterm) }
|
||||
sshMenu.addActionListener { importHosts(lastNode, ImportType.SSH) }
|
||||
finalShellMenu.addActionListener { importHosts(lastNode, ImportType.FinalShell) }
|
||||
csvMenu.addActionListener { importHosts(lastNode, ImportType.CSV) }
|
||||
windTermMenu.addActionListener { importHosts(lastNode, ImportType.WindTerm) }
|
||||
open.addActionListener { openHosts(it, false) }
|
||||
openInNewWindow.addActionListener { openHosts(it, true) }
|
||||
openWithSFTP.addActionListener { openWithSFTP(it) }
|
||||
openWithSFTPCommand.addActionListener { openWithSFTPCommand(it) }
|
||||
newFolder.addActionListener {
|
||||
val host = Host(
|
||||
id = UUID.randomUUID().toSimpleString(),
|
||||
protocol = Protocol.Folder,
|
||||
name = I18n.getString("termora.welcome.contextmenu.new.folder.name"),
|
||||
sort = System.currentTimeMillis(),
|
||||
parentId = lastHost.id
|
||||
)
|
||||
hostManager.addHost(host)
|
||||
val newNode = HostTreeNode(host)
|
||||
model.insertNodeInto(newNode, lastNode, lastNode.folderCount)
|
||||
selectionPath = TreePath(model.getPathToRoot(newNode))
|
||||
startEditingAtPath(selectionPath)
|
||||
}
|
||||
remove.addActionListener(object : ActionListener {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (nodes.isEmpty()) return
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(tree),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
I18n.getString("termora.remove"),
|
||||
JOptionPane.YES_NO_OPTION,
|
||||
JOptionPane.QUESTION_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
for (c in nodes) {
|
||||
hostManager.addHost(c.host.copy(deleted = true, updateDate = System.currentTimeMillis()))
|
||||
model.removeNodeFromParent(c)
|
||||
// 将所有子孙也删除
|
||||
for (child in c.getAllChildren()) {
|
||||
hostManager.addHost(
|
||||
child.host.copy(
|
||||
deleted = true,
|
||||
updateDate = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
copy.addActionListener {
|
||||
for (c in nodes) {
|
||||
val p = c.parent ?: continue
|
||||
val newNode = copyNode(c, p.host.id)
|
||||
model.insertNodeInto(newNode, p, lastNodeParent.getIndex(c) + 1)
|
||||
selectionPath = TreePath(model.getPathToRoot(newNode))
|
||||
}
|
||||
}
|
||||
rename.addActionListener { startEditingAtPath(TreePath(model.getPathToRoot(lastNode))) }
|
||||
expandAll.addActionListener {
|
||||
for (node in fullNodes) {
|
||||
expandPath(TreePath(model.getPathToRoot(node)))
|
||||
}
|
||||
}
|
||||
colspanAll.addActionListener {
|
||||
for (node in fullNodes.reversed()) {
|
||||
collapsePath(TreePath(model.getPathToRoot(node)))
|
||||
}
|
||||
}
|
||||
newHost.addActionListener(object : ActionListener {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val dialog = HostDialog(owner)
|
||||
dialog.setLocationRelativeTo(owner)
|
||||
dialog.isVisible = true
|
||||
val host = (dialog.host ?: return).copy(parentId = lastHost.id)
|
||||
hostManager.addHost(host)
|
||||
val newNode = HostTreeNode(host)
|
||||
model.insertNodeInto(newNode, lastNode, lastNode.childCount)
|
||||
selectionPath = TreePath(model.getPathToRoot(newNode))
|
||||
}
|
||||
})
|
||||
property.addActionListener(object : ActionListener {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val dialog = HostDialog(owner, lastHost)
|
||||
dialog.title = lastHost.name
|
||||
dialog.setLocationRelativeTo(owner)
|
||||
dialog.isVisible = true
|
||||
val host = dialog.host ?: return
|
||||
lastNode.host = host
|
||||
hostManager.addHost(host)
|
||||
model.nodeStructureChanged(lastNode)
|
||||
}
|
||||
})
|
||||
refresh.addActionListener { refreshNode(lastNode) }
|
||||
|
||||
newMenu.isEnabled = lastHost.protocol == Protocol.Folder
|
||||
remove.isEnabled = getSelectionSimpleTreeNodes().none { it == model.root }
|
||||
copy.isEnabled = remove.isEnabled
|
||||
rename.isEnabled = remove.isEnabled
|
||||
property.isEnabled = lastHost.protocol != Protocol.Folder
|
||||
refresh.isEnabled = lastHost.protocol == Protocol.Folder
|
||||
importMenu.isEnabled = lastHost.protocol == Protocol.Folder
|
||||
|
||||
// 如果选中了 SSH 服务器,那么才启用
|
||||
openWithSFTP.isEnabled = fullNodes.map { it.host }.any { it.protocol == Protocol.SSH }
|
||||
openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled
|
||||
openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled }
|
||||
|
||||
popupMenu.addPopupMenuListener(object : PopupMenuListener {
|
||||
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
|
||||
isPopupMenu = true
|
||||
}
|
||||
|
||||
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
|
||||
isPopupMenu = false
|
||||
}
|
||||
|
||||
override fun popupMenuCanceled(e: PopupMenuEvent?) {
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
popupMenu.show(this, evt.x, evt.y)
|
||||
}
|
||||
|
||||
override fun onRenamed(node: SimpleTreeNode<*>, text: String) {
|
||||
val lastNode = node as? HostTreeNode ?: return
|
||||
lastNode.host = lastNode.host.copy(name = text, updateDate = System.currentTimeMillis())
|
||||
model.nodeStructureChanged(lastNode)
|
||||
hostManager.addHost(lastNode.host)
|
||||
}
|
||||
|
||||
override fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>) {
|
||||
val nNode = node as? HostTreeNode ?: return
|
||||
val nParent = parent as? HostTreeNode ?: return
|
||||
nNode.data = nNode.data.copy(parentId = nParent.id, updateDate = System.currentTimeMillis())
|
||||
hostManager.addHost(nNode.host)
|
||||
}
|
||||
|
||||
|
||||
private fun copyNode(
|
||||
node: HostTreeNode,
|
||||
parentId: String,
|
||||
idGenerator: () -> String = { UUID.randomUUID().toSimpleString() },
|
||||
level: Int = 0
|
||||
): HostTreeNode {
|
||||
|
||||
val host = node.host
|
||||
val now = host.sort + 1
|
||||
val name = if (level == 0) "${host.name} ${I18n.getString("termora.welcome.contextmenu.copy")}"
|
||||
else host.name
|
||||
|
||||
val newHost = host.copy(
|
||||
id = idGenerator.invoke(),
|
||||
name = name,
|
||||
parentId = parentId,
|
||||
updateDate = System.currentTimeMillis(),
|
||||
createDate = System.currentTimeMillis(),
|
||||
sort = now
|
||||
)
|
||||
val newNode = HostTreeNode(newHost)
|
||||
|
||||
hostManager.addHost(newHost)
|
||||
|
||||
if (host.protocol == Protocol.Folder) {
|
||||
for (child in node.children()) {
|
||||
if (child is HostTreeNode) {
|
||||
newNode.add(copyNode(child, newHost.id, idGenerator, level + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newNode
|
||||
|
||||
}
|
||||
|
||||
override fun getSelectionSimpleTreeNodes(include: Boolean): List<HostTreeNode> {
|
||||
return super.getSelectionSimpleTreeNodes(include).filterIsInstance<HostTreeNode>()
|
||||
}
|
||||
|
||||
|
||||
private fun openHosts(evt: EventObject, openInNewWindow: Boolean) {
|
||||
assertEventDispatchThread()
|
||||
val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol != Protocol.Folder }
|
||||
if (nodes.isEmpty()) return
|
||||
val source = if (openInNewWindow)
|
||||
TermoraFrameManager.getInstance().createWindow().apply { isVisible = true }
|
||||
else evt.source
|
||||
nodes.forEach { openHostAction.actionPerformed(OpenHostActionEvent(source, it, evt)) }
|
||||
}
|
||||
|
||||
private fun openWithSFTP(evt: EventObject) {
|
||||
val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH }
|
||||
if (nodes.isEmpty()) return
|
||||
|
||||
for (node in nodes) {
|
||||
sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt))
|
||||
}
|
||||
}
|
||||
|
||||
private fun openWithSFTPCommand(evt: EventObject) {
|
||||
val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH }
|
||||
if (nodes.isEmpty()) return
|
||||
for (host in nodes) {
|
||||
openHostAction.actionPerformed(OpenHostActionEvent(this, host.copy(protocol = Protocol.SFTPPty), evt))
|
||||
}
|
||||
}
|
||||
|
||||
private fun importHosts(folder: HostTreeNode, type: ImportType) {
|
||||
try {
|
||||
doImportHosts(folder, type)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
OptionPane.showMessageDialog(owner, ExceptionUtils.getMessage(e), messageType = JOptionPane.ERROR_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun doImportHosts(folder: HostTreeNode, type: ImportType) {
|
||||
val chooser = JFileChooser()
|
||||
chooser.fileSelectionMode = JFileChooser.FILES_ONLY
|
||||
chooser.isAcceptAllFileFilterUsed = false
|
||||
chooser.isMultiSelectionEnabled = false
|
||||
|
||||
when (type) {
|
||||
ImportType.WindTerm -> chooser.fileFilter = FileNameExtensionFilter("WindTerm (*.sessions)", "sessions")
|
||||
ImportType.SSH -> chooser.fileFilter = FileNameExtensionFilter("SSH (config)", "config")
|
||||
ImportType.CSV -> chooser.fileFilter = FileNameExtensionFilter("CSV (*.csv)", "csv")
|
||||
ImportType.SecureCRT -> chooser.fileFilter = FileNameExtensionFilter("SecureCRT (*.xml)", "xml")
|
||||
ImportType.electerm -> chooser.fileFilter = FileNameExtensionFilter("electerm (*.json)", "json")
|
||||
ImportType.PuTTY -> chooser.fileFilter = FileNameExtensionFilter("PuTTY (*.reg)", "reg")
|
||||
ImportType.MobaXterm -> chooser.fileFilter =
|
||||
FileNameExtensionFilter("MobaXterm (*.mobaconf,*.ini)", "ini", "mobaconf")
|
||||
|
||||
ImportType.Xshell -> {
|
||||
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
chooser.dialogTitle = "Xshell Sessions"
|
||||
chooser.isAcceptAllFileFilterUsed = true
|
||||
}
|
||||
|
||||
ImportType.FinalShell -> {
|
||||
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
chooser.isAcceptAllFileFilterUsed = true
|
||||
}
|
||||
}
|
||||
|
||||
val dir = properties.getString("NewHostTree.ImportHosts.defaultDir", StringUtils.EMPTY)
|
||||
if (dir.isNotBlank()) {
|
||||
val file = FileUtils.getFile(dir)
|
||||
if (file.exists()) {
|
||||
chooser.currentDirectory = file
|
||||
}
|
||||
}
|
||||
|
||||
// csv template
|
||||
if (type == ImportType.CSV) {
|
||||
val code = OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
I18n.getString("termora.welcome.contextmenu.import.csv.download-template"),
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
messageType = JOptionPane.QUESTION_MESSAGE,
|
||||
options = arrayOf(
|
||||
I18n.getString("termora.welcome.contextmenu.import"),
|
||||
I18n.getString("termora.welcome.contextmenu.download")
|
||||
),
|
||||
initialValue = I18n.getString("termora.welcome.contextmenu.import")
|
||||
)
|
||||
if (code == JOptionPane.DEFAULT_OPTION) {
|
||||
return
|
||||
} else if (code != JOptionPane.YES_OPTION) {
|
||||
chooser.setSelectedFile(File("termora_import.csv"))
|
||||
if (chooser.showSaveDialog(owner) == JFileChooser.APPROVE_OPTION) {
|
||||
CSVPrinter(
|
||||
FileWriter(chooser.selectedFile, Charsets.UTF_8),
|
||||
CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()
|
||||
).use { printer ->
|
||||
printer.printRecord("Projects/Dev", "Web Server", "192.168.1.1", "22", "root", "SSH")
|
||||
printer.printRecord("Projects/Prod", "Web Server", "serverhost.com", "2222", "root", "SSH")
|
||||
printer.printRecord(StringUtils.EMPTY, "Web Server", "serverhost.com", "2222", "user", "SSH")
|
||||
}
|
||||
OptionPane.openFileInFolder(
|
||||
owner,
|
||||
chooser.selectedFile,
|
||||
I18n.getString("termora.welcome.contextmenu.import.csv.download-template-done-open-folder"),
|
||||
I18n.getString("termora.welcome.contextmenu.import.csv.download-template-done")
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 选择文件
|
||||
if (type != ImportType.SSH) {
|
||||
val code = chooser.showOpenDialog(owner)
|
||||
if (code != JFileChooser.APPROVE_OPTION) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val file = chooser.selectedFile
|
||||
if (file != null && file.parentFile != null) {
|
||||
properties.putString(
|
||||
"NewHostTree.ImportHosts.defaultDir",
|
||||
(if (FileUtils.isDirectory(file)) file else file.parentFile).absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
val nodes = when (type) {
|
||||
ImportType.SSH -> parseFromSSH(folder)
|
||||
ImportType.WindTerm -> parseFromWindTerm(folder, file)
|
||||
ImportType.SecureCRT -> parseFromSecureCRT(folder, file)
|
||||
ImportType.MobaXterm -> parseFromMobaXterm(folder, file)
|
||||
ImportType.PuTTY -> parseFromPuTTY(folder, file)
|
||||
ImportType.Xshell -> parseFromXshell(folder, file)
|
||||
ImportType.FinalShell -> parseFromFinalShell(folder, file)
|
||||
ImportType.electerm -> parseFromElecterm(folder, file)
|
||||
ImportType.CSV -> file.bufferedReader().use { parseFromCSV(folder, it) }
|
||||
}
|
||||
|
||||
if (nodes.isEmpty()) return
|
||||
|
||||
for (node in nodes) {
|
||||
node.host = node.host.copy(parentId = folder.host.id, updateDate = System.currentTimeMillis())
|
||||
if (folder.getIndex(node) != -1) {
|
||||
continue
|
||||
}
|
||||
model.insertNodeInto(
|
||||
node,
|
||||
folder,
|
||||
if (node.host.protocol == Protocol.Folder) folder.folderCount else folder.childCount
|
||||
)
|
||||
}
|
||||
|
||||
for (node in nodes) {
|
||||
hostManager.addHost(node.host)
|
||||
node.getAllChildren().forEach { hostManager.addHost(it.host) }
|
||||
}
|
||||
|
||||
// 重新加载
|
||||
model.reload(folder)
|
||||
|
||||
// expand root
|
||||
expandPath(TreePath(model.getPathToRoot(folder)))
|
||||
}
|
||||
|
||||
private fun parseFromWindTerm(folder: HostTreeNode, file: File): List<HostTreeNode> {
|
||||
val sessions = ohMyJson.runCatching { ohMyJson.parseToJsonElement(file.readText()).jsonArray }
|
||||
.onFailure { OptionPane.showMessageDialog(owner, ExceptionUtils.getMessage(it)) }
|
||||
.getOrNull() ?: return emptyList()
|
||||
|
||||
val sw = StringWriter()
|
||||
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
|
||||
for (i in 0 until sessions.size) {
|
||||
val json = sessions[i].jsonObject
|
||||
val protocol = json["session.protocol"]?.jsonPrimitive?.content ?: "SSH"
|
||||
if (!StringUtils.equalsIgnoreCase("SSH", protocol)) continue
|
||||
val label = json["session.label"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
val target = json["session.target"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
val port = json["session.port"]?.jsonPrimitive?.intOrNull ?: 22
|
||||
val group = json["session.group"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
val groups = group.split(">")
|
||||
printer.printRecord(groups.joinToString("/"), label, target, port, StringUtils.EMPTY, "SSH")
|
||||
}
|
||||
}
|
||||
|
||||
return parseFromCSV(folder, StringReader(sw.toString()))
|
||||
}
|
||||
|
||||
private fun parseFromSSH(folder: HostTreeNode): List<HostTreeNode> {
|
||||
val entries = HostConfigEntry.readHostConfigEntries(HostConfigEntry.getDefaultHostConfigFile())
|
||||
|
||||
val sw = StringWriter()
|
||||
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
|
||||
for (entry in entries) {
|
||||
printer.printRecord(
|
||||
StringUtils.EMPTY,
|
||||
StringUtils.defaultString(entry.host),
|
||||
StringUtils.defaultString(entry.hostName),
|
||||
if (entry.port == 0) 22 else entry.port,
|
||||
StringUtils.defaultString(entry.username),
|
||||
"SSH"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return parseFromCSV(folder, StringReader(sw.toString()))
|
||||
}
|
||||
|
||||
private fun parseFromSecureCRT(folder: HostTreeNode, file: File): List<HostTreeNode> {
|
||||
val xPath = XPathFactory.newInstance().newXPath()
|
||||
val db = DocumentBuilderFactory.newInstance().newDocumentBuilder()
|
||||
val doc = db.parse(file)
|
||||
val sessionElement = xPath.compile("/VanDyke/key[@name='Sessions']")
|
||||
.evaluate(doc, XPathConstants.NODE) as Element? ?: return emptyList()
|
||||
val nodeList = xPath.compile(".//key[not(key)]").evaluate(sessionElement, XPathConstants.NODESET) as NodeList
|
||||
if (nodeList.length == 0) return emptyList()
|
||||
|
||||
val sw = StringWriter()
|
||||
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
|
||||
for (i in 0 until nodeList.length) {
|
||||
val ele = nodeList.item(i) as Element
|
||||
val protocol = xPath.compile("./string[@name='Protocol Name']/text()").evaluate(ele)
|
||||
if (!StringUtils.equalsIgnoreCase(protocol, "SSH2")) continue
|
||||
val label = ele.getAttribute("name")
|
||||
if (StringUtils.isBlank(label)) continue
|
||||
val hostname = xPath.compile("./string[@name='Hostname']/text()").evaluate(ele)
|
||||
if (StringUtils.isBlank(hostname)) continue
|
||||
val username = xPath.compile("./string[@name='Username']/text()").evaluate(ele)
|
||||
val port = xPath.compile("./dword[@name='[SSH2] Port']/text()").evaluate(ele)?.toIntOrNull() ?: 22
|
||||
|
||||
|
||||
val folders = mutableListOf<String>()
|
||||
var p = ele.parentNode as Element
|
||||
while (p != sessionElement) {
|
||||
folders.addFirst(p.getAttribute("name"))
|
||||
p = p.parentNode as Element
|
||||
}
|
||||
printer.printRecord(folders.joinToString("/"), label, hostname, port.toString(), username, "SSH")
|
||||
}
|
||||
}
|
||||
|
||||
return parseFromCSV(folder, StringReader(sw.toString()))
|
||||
}
|
||||
|
||||
private fun parseFromPuTTY(folder: HostTreeNode, file: File): List<HostTreeNode> {
|
||||
val reg = Reg(file)
|
||||
val prefix = "HKEY_CURRENT_USER\\Software\\SimonTatham\\PuTTY\\Sessions\\"
|
||||
|
||||
|
||||
val sw = StringWriter()
|
||||
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
|
||||
for (key in reg.keys) {
|
||||
if (!key.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
val properties = reg[key]?.toProperties() ?: continue
|
||||
val label = StringUtils.removeStart(key, prefix)
|
||||
val hostname = properties.getProperty("HostName")
|
||||
val username = properties.getProperty("UserName")
|
||||
val port = properties.getProperty("PortNumber")
|
||||
printer.printRecord(StringUtils.EMPTY, label, hostname, port.toString(), username, "SSH")
|
||||
}
|
||||
}
|
||||
|
||||
return parseFromCSV(folder, StringReader(sw.toString()))
|
||||
}
|
||||
|
||||
private fun parseFromMobaXterm(folder: HostTreeNode, file: File): List<HostTreeNode> {
|
||||
val ini = Ini()
|
||||
ini.config.isEscapeKeyOnly = true
|
||||
ini.load(file)
|
||||
|
||||
val bookmarks = mutableListOf<String>()
|
||||
for (key in ini.keys) {
|
||||
if (key.startsWith("Bookmarks")) {
|
||||
bookmarks.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
val sw = StringWriter()
|
||||
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
|
||||
|
||||
for (bookmark in bookmarks) {
|
||||
val properties = (ini[bookmark] ?: continue).toProperties()
|
||||
// 删除不必要元素
|
||||
properties.remove("ImgNum")
|
||||
val folders = FilenameUtils.separatorsToUnix(
|
||||
(properties.remove("SubRep")
|
||||
?: StringUtils.EMPTY).toString()
|
||||
)
|
||||
|
||||
for (key in properties.stringPropertyNames()) {
|
||||
val segments = properties.getProperty(key).split("%")
|
||||
if (segments.isEmpty()) continue
|
||||
// ssh: #109#0
|
||||
// telnet: #98#1
|
||||
if (segments.first() != "#109#0") continue
|
||||
val hostname = segments.getOrNull(1) ?: StringUtils.EMPTY
|
||||
val port = segments.getOrNull(2) ?: 22
|
||||
printer.printRecord(folders, key, hostname, port, StringUtils.EMPTY, "SSH")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parseFromCSV(folder, StringReader(sw.toString()))
|
||||
}
|
||||
|
||||
private fun parseFromXshell(folder: HostTreeNode, dir: File): List<HostTreeNode> {
|
||||
val files = FileUtils.listFiles(dir, arrayOf("xsh"), true)
|
||||
if (files.isEmpty()) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.welcome.contextmenu.import.xshell-folder-empty")
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val sw = StringWriter()
|
||||
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
|
||||
for (file in files) {
|
||||
val ini = Ini(file)
|
||||
val protocol = ini.get("CONNECTION", "Protocol") ?: "SSH"
|
||||
if (!StringUtils.equalsIgnoreCase("SSH", protocol)) continue
|
||||
val folders = FilenameUtils.separatorsToUnix(file.parentFile.relativeTo(dir).toString())
|
||||
val hostname = ini.get("CONNECTION", "Host") ?: StringUtils.EMPTY
|
||||
val label = file.nameWithoutExtension
|
||||
val port = ini.get("CONNECTION", "Port")?.toIntOrNull() ?: 22
|
||||
val username = ini.get("CONNECTION:AUTHENTICATION", "UserName") ?: StringUtils.EMPTY
|
||||
printer.printRecord(folders, label, hostname, port, username, "SSH")
|
||||
}
|
||||
}
|
||||
|
||||
return parseFromCSV(folder, StringReader(sw.toString()))
|
||||
}
|
||||
|
||||
private fun parseFromFinalShell(folder: HostTreeNode, dir: File): List<HostTreeNode> {
|
||||
val files = FileUtils.listFiles(
|
||||
dir,
|
||||
FileFilterUtils.suffixFileFilter("_connect_config.json"),
|
||||
FileFilterUtils.trueFileFilter()
|
||||
)
|
||||
|
||||
if (files.isEmpty()) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.welcome.contextmenu.import.finalshell-folder-empty")
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val sw = StringWriter()
|
||||
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
|
||||
for (file in files) {
|
||||
try {
|
||||
val json = ohMyJson.runCatching { ohMyJson.parseToJsonElement(file.readText()) }
|
||||
.getOrNull()?.jsonObject ?: continue
|
||||
val username = json["user_name"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
val label = json["name"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
val host = json["host"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
val port = json["port"]?.jsonPrimitive?.intOrNull ?: 22
|
||||
if (StringUtils.isAllBlank(host, label)) continue
|
||||
val folders = FilenameUtils.separatorsToUnix(file.parentFile.relativeTo(dir).toString())
|
||||
printer.printRecord(folders, StringUtils.defaultIfBlank(label, host), host, port, username, "SSH")
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(file.absolutePath, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parseFromCSV(folder, StringReader(sw.toString()))
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ElectermGroup(
|
||||
val id: String = StringUtils.EMPTY,
|
||||
val title: String = StringUtils.EMPTY,
|
||||
val bookmarkIds: Set<String> = emptySet(),
|
||||
val bookmarkGroupIds: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
private fun parseFromElecterm(folder: HostTreeNode, file: File): List<HostTreeNode> {
|
||||
val json = ohMyJson.parseToJsonElement(file.readText()).jsonObject
|
||||
val bookmarks = json["bookmarks"]?.jsonArray ?: return emptyList()
|
||||
val bookmarkGroups = ohMyJson.decodeFromJsonElement<List<ElectermGroup>>(
|
||||
json["bookmarkGroups"]?.jsonArray ?: JsonArray(emptyList())
|
||||
)
|
||||
|
||||
|
||||
val sw = StringWriter()
|
||||
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
|
||||
for (i in 0 until bookmarks.size) {
|
||||
val host = bookmarks[i].jsonObject
|
||||
val type = host["type"]?.jsonPrimitive?.content ?: "SSH"
|
||||
if (!StringUtils.equalsIgnoreCase(type, "SSH")) continue
|
||||
val hostname = host["host"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
val id = host["id"]?.jsonPrimitive?.content ?: continue
|
||||
val title = host["title"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
if (StringUtils.isAllBlank(title, hostname)) continue
|
||||
val username = host["username"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
val port = host["port"]?.jsonPrimitive?.intOrNull ?: 22
|
||||
|
||||
val folderNames = mutableListOf<String>()
|
||||
var group = bookmarkGroups.find { it.bookmarkIds.contains(id) }
|
||||
while (group != null && group.id != "default") {
|
||||
folderNames.addFirst(group.title)
|
||||
group = bookmarkGroups.find { it.bookmarkGroupIds.contains(group?.id ?: StringUtils.EMPTY) }
|
||||
}
|
||||
|
||||
printer.printRecord(
|
||||
folderNames.joinToString("/"),
|
||||
StringUtils.defaultIfBlank(title, hostname),
|
||||
hostname,
|
||||
port,
|
||||
username,
|
||||
"SSH"
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return parseFromCSV(folder, StringReader(sw.toString()))
|
||||
}
|
||||
|
||||
private fun parseFromCSV(folderNode: HostTreeNode, sr: Reader): List<HostTreeNode> {
|
||||
val records = CSVParser.builder()
|
||||
.setFormat(CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).setSkipHeaderRecord(true).get())
|
||||
.setCharset(Charsets.UTF_8)
|
||||
.setReader(sr)
|
||||
.get()
|
||||
.use { it.records }
|
||||
// 把现有目录提取出来,避免重复创建
|
||||
val nodes = folderNode.clone(setOf(Protocol.Folder))
|
||||
.childrenNode().filter { it.host.protocol == Protocol.Folder }
|
||||
.toMutableList()
|
||||
|
||||
for (record in records) {
|
||||
val map = mutableMapOf<String, String>()
|
||||
for (e in record.parser.headerMap.keys) {
|
||||
map[e] = record.get(e)
|
||||
}
|
||||
|
||||
val folder = map["Folders"] ?: StringUtils.EMPTY
|
||||
val label = map["Label"] ?: StringUtils.EMPTY
|
||||
val hostname = map["Hostname"] ?: StringUtils.EMPTY
|
||||
val port = map["Port"]?.toIntOrNull() ?: 22
|
||||
val username = map["Username"] ?: StringUtils.EMPTY
|
||||
val protocol = map["Protocol"] ?: "SSH"
|
||||
if (!StringUtils.equalsIgnoreCase(protocol, "SSH")) continue
|
||||
if (StringUtils.isAllBlank(hostname, label)) continue
|
||||
|
||||
var p: HostTreeNode? = null
|
||||
if (folder.isNotBlank()) {
|
||||
for ((j, name) in folder.split("/").withIndex()) {
|
||||
val folders = if (j == 0 || p == null) nodes
|
||||
else p.children().toList().filterIsInstance<HostTreeNode>()
|
||||
val n = HostTreeNode(
|
||||
Host(
|
||||
name = name, protocol = Protocol.Folder,
|
||||
parentId = p?.host?.id ?: StringUtils.EMPTY
|
||||
)
|
||||
)
|
||||
val cp = folders.find { it.host.protocol == Protocol.Folder && it.host.name == name }
|
||||
if (cp != null) {
|
||||
p = cp
|
||||
continue
|
||||
}
|
||||
if (p == null) {
|
||||
p = n
|
||||
nodes.add(n)
|
||||
} else {
|
||||
p.add(n)
|
||||
p = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val n = HostTreeNode(
|
||||
Host(
|
||||
name = StringUtils.defaultIfBlank(label, hostname),
|
||||
host = hostname,
|
||||
port = port,
|
||||
username = username,
|
||||
protocol = Protocol.SSH,
|
||||
parentId = p?.host?.id ?: StringUtils.EMPTY,
|
||||
)
|
||||
)
|
||||
|
||||
if (p == null) {
|
||||
nodes.add(n)
|
||||
} else {
|
||||
p.add(n)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
|
||||
private enum class ImportType {
|
||||
WindTerm,
|
||||
CSV,
|
||||
Xshell,
|
||||
PuTTY,
|
||||
SecureCRT,
|
||||
MobaXterm,
|
||||
SSH,
|
||||
FinalShell,
|
||||
electerm,
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
95
src/main/kotlin/app/termora/NewHostTreeDialog.kt
Normal file
@@ -0,0 +1,95 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.function.Function
|
||||
import javax.swing.*
|
||||
|
||||
class NewHostTreeDialog(
|
||||
owner: Window,
|
||||
) : DialogWrapper(owner) {
|
||||
var hosts = emptyList<Host>()
|
||||
var allowMulti = true
|
||||
|
||||
private var filter: Function<HostTreeNode, Boolean> = Function<HostTreeNode, Boolean> { true }
|
||||
private val tree = NewHostTree()
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 250, UIManager.getInt("Dialog.height") - 150)
|
||||
isModal = true
|
||||
isResizable = false
|
||||
controlsVisible = false
|
||||
title = I18n.getString("termora.transport.sftp.select-host")
|
||||
|
||||
tree.contextmenu = false
|
||||
tree.doubleClickConnection = false
|
||||
tree.dragEnabled = false
|
||||
|
||||
tree.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val node = tree.getLastSelectedPathNode() ?: return
|
||||
if (node.isFolder) return
|
||||
doOKAction()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
init()
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
}
|
||||
|
||||
fun setFilter(filter: Function<HostTreeNode, Boolean>) {
|
||||
tree.model = FilterableHostTreeModel(tree) { false }.apply {
|
||||
addFilter(filter)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val scrollPane = JScrollPane(tree)
|
||||
scrollPane.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
|
||||
BorderFactory.createEmptyBorder(4, 6, 4, 6)
|
||||
)
|
||||
|
||||
return scrollPane
|
||||
}
|
||||
|
||||
|
||||
override fun doCancelAction() {
|
||||
hosts = emptyList()
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
hosts = tree.getSelectionSimpleTreeNodes(true)
|
||||
.filter { filter.apply(it) }
|
||||
.map { it.host }
|
||||
|
||||
if (hosts.isEmpty()) return
|
||||
if (!allowMulti && hosts.size > 1) return
|
||||
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
fun setTreeName(treeName: String) {
|
||||
Disposer.register(disposable, object : Disposable {
|
||||
private val key = "${treeName}.state"
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
|
||||
init {
|
||||
TreeUtils.loadExpansionState(tree, properties.getString(key, StringUtils.EMPTY))
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
properties.putString(key, TreeUtils.saveExpansionState(tree))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
82
src/main/kotlin/app/termora/NewHostTreeModel.kt
Normal file
@@ -0,0 +1,82 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import javax.swing.tree.MutableTreeNode
|
||||
import javax.swing.tree.TreeNode
|
||||
|
||||
|
||||
class NewHostTreeModel : SimpleTreeModel<Host>(
|
||||
HostTreeNode(
|
||||
Host(
|
||||
id = "0",
|
||||
protocol = Protocol.Folder,
|
||||
name = I18n.getString("termora.welcome.my-hosts"),
|
||||
host = StringUtils.EMPTY,
|
||||
port = 0,
|
||||
remark = StringUtils.EMPTY,
|
||||
username = StringUtils.EMPTY
|
||||
)
|
||||
)
|
||||
) {
|
||||
private val Host.isRoot get() = this.parentId == "0" || this.parentId.isBlank()
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
|
||||
init {
|
||||
reload()
|
||||
}
|
||||
|
||||
|
||||
override fun getRoot(): HostTreeNode {
|
||||
return super.getRoot() as HostTreeNode
|
||||
}
|
||||
|
||||
|
||||
override fun reload(parent: TreeNode) {
|
||||
|
||||
if (parent !is HostTreeNode) {
|
||||
super.reload(parent)
|
||||
return
|
||||
}
|
||||
|
||||
parent.removeAllChildren()
|
||||
|
||||
val hosts = hostManager.hosts()
|
||||
val nodes = linkedMapOf<String, HostTreeNode>()
|
||||
|
||||
// 遍历 Host 列表,构建树节点
|
||||
for (host in hosts) {
|
||||
val node = HostTreeNode(host)
|
||||
nodes[host.id] = node
|
||||
}
|
||||
|
||||
for (host in hosts) {
|
||||
val node = nodes[host.id] ?: continue
|
||||
if (host.isRoot) continue
|
||||
val p = nodes[host.parentId] ?: continue
|
||||
p.add(node)
|
||||
}
|
||||
|
||||
for ((_, v) in nodes.entries) {
|
||||
if (parent.host.id == v.host.parentId) {
|
||||
parent.add(v)
|
||||
}
|
||||
}
|
||||
|
||||
super.reload(parent)
|
||||
}
|
||||
|
||||
override fun insertNodeInto(newChild: MutableTreeNode, parent: MutableTreeNode, index: Int) {
|
||||
super.insertNodeInto(newChild, parent, index)
|
||||
// 重置所有排序
|
||||
if (parent is HostTreeNode) {
|
||||
for ((i, c) in parent.children().toList().filterIsInstance<HostTreeNode>().withIndex()) {
|
||||
val sort = i.toLong()
|
||||
if (c.host.sort == sort) continue
|
||||
c.host = c.host.copy(sort = sort, updateDate = System.currentTimeMillis())
|
||||
hostManager.addHost(c.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
7
src/main/kotlin/app/termora/NotifyListener.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import java.util.*
|
||||
|
||||
interface NotifyListener : EventListener {
|
||||
fun addNotify()
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import java.awt.event.ActionEvent
|
||||
import app.termora.actions.AnActionEvent
|
||||
import java.util.*
|
||||
|
||||
class OpenHostActionEvent(source: Any, val host: Host) : ActionEvent(source, ACTION_PERFORMED, String())
|
||||
class OpenHostActionEvent(source: Any, val host: Host, event: EventObject) :
|
||||
AnActionEvent(source, String(), event)
|
||||
@@ -1,12 +1,15 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.native.osx.NativeMacLibrary
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatTextPane
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jetbrains.JBR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.jdesktop.swingx.JXLabel
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Desktop
|
||||
@@ -19,6 +22,8 @@ import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object OptionPane {
|
||||
private val coroutineScope = swingCoroutineScope
|
||||
|
||||
fun showConfirmDialog(
|
||||
parentComponent: Component?,
|
||||
message: Any,
|
||||
@@ -28,6 +33,7 @@ object OptionPane {
|
||||
icon: Icon? = null,
|
||||
options: Array<Any>? = null,
|
||||
initialValue: Any? = null,
|
||||
customizeDialog: (JDialog) -> Unit = {},
|
||||
): Int {
|
||||
|
||||
val panel = if (message is JComponent) {
|
||||
@@ -46,6 +52,9 @@ object OptionPane {
|
||||
override fun selectInitialValue() {
|
||||
super.selectInitialValue()
|
||||
if (message is JComponent) {
|
||||
if (message.getClientProperty("SKIP_requestFocusInWindow") == true) {
|
||||
return
|
||||
}
|
||||
message.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
@@ -56,6 +65,8 @@ object OptionPane {
|
||||
pane.selectInitialValue()
|
||||
}
|
||||
})
|
||||
dialog.setLocationRelativeTo(parentComponent)
|
||||
customizeDialog.invoke(dialog)
|
||||
dialog.isVisible = true
|
||||
dialog.dispose()
|
||||
val selectedValue = pane.value
|
||||
@@ -97,9 +108,8 @@ object OptionPane {
|
||||
val dialog = initDialog(pane.createDialog(parentComponent, title))
|
||||
if (duration.inWholeMilliseconds > 0) {
|
||||
dialog.addWindowListener(object : WindowAdapter() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun windowOpened(e: WindowEvent) {
|
||||
GlobalScope.launch(Dispatchers.Swing) {
|
||||
coroutineScope.launch(Dispatchers.Swing) {
|
||||
delay(duration.inWholeMilliseconds)
|
||||
if (dialog.isVisible) {
|
||||
dialog.isVisible = false
|
||||
@@ -113,6 +123,36 @@ object OptionPane {
|
||||
dialog.dispose()
|
||||
}
|
||||
|
||||
fun showInputDialog(
|
||||
parentComponent: Component?,
|
||||
title: String = UIManager.getString("OptionPane.messageDialogTitle"),
|
||||
value: String = StringUtils.EMPTY,
|
||||
placeholder: String = StringUtils.EMPTY,
|
||||
): String? {
|
||||
val pane = JOptionPane(StringUtils.EMPTY, JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION)
|
||||
val dialog = initDialog(pane.createDialog(parentComponent, title))
|
||||
pane.wantsInput = true
|
||||
pane.initialSelectionValue = value
|
||||
|
||||
val textField = SwingUtils.getDescendantsOfType(JTextField::class.java, pane, true).firstOrNull()
|
||||
if (textField?.name == "OptionPane.textField") {
|
||||
textField.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor),
|
||||
BorderFactory.createEmptyBorder(0, 0, 2, 0)
|
||||
)
|
||||
textField.background = UIManager.getColor("window")
|
||||
textField.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, placeholder)
|
||||
}
|
||||
|
||||
dialog.isVisible = true
|
||||
dialog.dispose()
|
||||
|
||||
val inputValue = pane.inputValue
|
||||
if (inputValue == JOptionPane.UNINITIALIZED_VALUE) return null
|
||||
|
||||
return inputValue as? String
|
||||
}
|
||||
|
||||
fun openFileInFolder(
|
||||
parentComponent: Component,
|
||||
file: File,
|
||||
@@ -122,7 +162,7 @@ object OptionPane {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop()
|
||||
.isSupported(Desktop.Action.BROWSE_FILE_DIR)
|
||||
) {
|
||||
if (JOptionPane.YES_OPTION == showConfirmDialog(
|
||||
if (yMessage.isEmpty() || JOptionPane.YES_OPTION == showConfirmDialog(
|
||||
parentComponent,
|
||||
yMessage,
|
||||
optionType = JOptionPane.YES_NO_OPTION
|
||||
@@ -140,14 +180,31 @@ object OptionPane {
|
||||
}
|
||||
|
||||
private fun initDialog(dialog: JDialog): JDialog {
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
dialog.rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_CLOSE, false)
|
||||
dialog.rootPane.putClientProperty(
|
||||
FlatClientProperties.TITLE_BAR_HEIGHT,
|
||||
UIManager.getInt("TabbedPane.tabHeight")
|
||||
)
|
||||
} else if (SystemInfo.isMacOS) {
|
||||
dialog.rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
|
||||
dialog.rootPane.putClientProperty("apple.awt.fullWindowContent", true)
|
||||
dialog.rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
|
||||
dialog.rootPane.putClientProperty(
|
||||
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING,
|
||||
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING_MEDIUM
|
||||
)
|
||||
|
||||
if (JBR.isWindowDecorationsSupported()) {
|
||||
|
||||
val windowDecorations = JBR.getWindowDecorations()
|
||||
val titleBar = windowDecorations.createCustomTitleBar()
|
||||
titleBar.putProperty("controls.visible", false)
|
||||
titleBar.height = UIManager.getInt("TabbedPane.tabHeight") - if (SystemInfo.isMacOS) 10f else 6f
|
||||
windowDecorations.setCustomTitleBar(dialog, titleBar)
|
||||
val height = UIManager.getInt("TabbedPane.tabHeight") - 10
|
||||
if (JBR.isWindowDecorationsSupported()) {
|
||||
val customTitleBar = JBR.getWindowDecorations().createCustomTitleBar()
|
||||
customTitleBar.putProperty("controls.visible", false)
|
||||
customTitleBar.height = height.toFloat()
|
||||
JBR.getWindowDecorations().setCustomTitleBar(dialog, customTitleBar)
|
||||
} else {
|
||||
NativeMacLibrary.setControlsVisible(dialog, false)
|
||||
}
|
||||
|
||||
val label = JLabel(dialog.title)
|
||||
label.putClientProperty(FlatClientProperties.STYLE, "font: bold")
|
||||
@@ -155,11 +212,9 @@ object OptionPane {
|
||||
box.add(Box.createHorizontalGlue())
|
||||
box.add(label)
|
||||
box.add(Box.createHorizontalGlue())
|
||||
box.preferredSize = Dimension(-1, titleBar.height.toInt())
|
||||
|
||||
box.preferredSize = Dimension(-1, height)
|
||||
dialog.contentPane.add(box, BorderLayout.NORTH)
|
||||
}
|
||||
|
||||
return dialog
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.beans.PropertyChangeEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.util.*
|
||||
|
||||
abstract class PropertyTerminalTab : TerminalTab {
|
||||
protected val listeners = mutableListOf<PropertyChangeListener>()
|
||||
@@ -26,6 +30,10 @@ abstract class PropertyTerminalTab : TerminalTab {
|
||||
|
||||
override fun onLostFocus() {
|
||||
hasFocus = false
|
||||
|
||||
// 切换标签时,尝试隐藏悬浮工具栏
|
||||
val evt = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
|
||||
evt.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerHide()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,41 +1,81 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.db.Database
|
||||
import app.termora.macro.MacroPtyConnector
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.PtyConnectorDelegate
|
||||
import app.termora.terminal.PtyProcessConnector
|
||||
import com.pty4j.PtyProcessBuilder
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.*
|
||||
|
||||
class PtyConnectorFactory {
|
||||
class PtyConnectorFactory : Disposable {
|
||||
private val ptyConnectors = Collections.synchronizedList(mutableListOf<PtyConnector>())
|
||||
private val database get() = Database.instance
|
||||
private val database get() = Database.getDatabase()
|
||||
|
||||
companion object {
|
||||
val instance by lazy { PtyConnectorFactory() }
|
||||
private val log = LoggerFactory.getLogger(PtyConnectorFactory::class.java)
|
||||
fun getInstance(): PtyConnectorFactory {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(PtyConnectorFactory::class) { PtyConnectorFactory() }
|
||||
}
|
||||
}
|
||||
|
||||
fun createPtyConnector(
|
||||
rows: Int = 24, cols: Int = 80,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
charset: Charset = StandardCharsets.UTF_8
|
||||
): PtyConnector {
|
||||
val command = database.terminal.localShell
|
||||
val commands = mutableListOf(command)
|
||||
if (SystemUtils.IS_OS_UNIX) {
|
||||
commands.add("-l")
|
||||
}
|
||||
return createPtyConnector(
|
||||
commands = commands.toTypedArray(),
|
||||
rows = rows,
|
||||
cols = cols,
|
||||
env = env,
|
||||
charset = charset
|
||||
)
|
||||
}
|
||||
|
||||
fun createPtyConnector(
|
||||
commands: Array<String>,
|
||||
rows: Int = 24, cols: Int = 80,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
directory: String = SystemUtils.USER_HOME,
|
||||
charset: Charset = StandardCharsets.UTF_8,
|
||||
): PtyConnector {
|
||||
val envs = mutableMapOf<String, String>()
|
||||
envs.putAll(System.getenv())
|
||||
envs["TERM"] = "xterm-256color"
|
||||
envs.putAll(env)
|
||||
|
||||
val command = database.terminal.localShell
|
||||
val ptyProcess = PtyProcessBuilder(arrayOf(command))
|
||||
if (SystemUtils.IS_OS_UNIX) {
|
||||
if (!envs.containsKey("LANG")) {
|
||||
val locale = Locale.getDefault()
|
||||
if (StringUtils.isNoneBlank(locale.language, locale.country)) {
|
||||
envs["LANG"] = "${locale.language}_${locale.country}.${Charset.defaultCharset().name()}"
|
||||
} else {
|
||||
envs["LANG"] = "en_US.UTF-8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("command: {} , envs: {}", commands.joinToString(" "), envs)
|
||||
}
|
||||
|
||||
val ptyProcess = PtyProcessBuilder(commands)
|
||||
.setEnvironment(envs)
|
||||
.setInitialRows(rows)
|
||||
.setInitialColumns(cols)
|
||||
.setConsole(false)
|
||||
.setDirectory(SystemUtils.USER_HOME)
|
||||
.setDirectory(StringUtils.defaultIfBlank(directory, SystemUtils.USER_HOME))
|
||||
.setCygwin(false)
|
||||
.setUseWinConPty(SystemUtils.IS_OS_WINDOWS)
|
||||
.setRedirectErrorStream(false)
|
||||
@@ -47,20 +87,14 @@ class PtyConnectorFactory {
|
||||
}
|
||||
|
||||
fun decorate(ptyConnector: PtyConnector): PtyConnector {
|
||||
// 集成转发,如果PtyConnector支持转发那么应该在当前注释行前面代理
|
||||
val multiplePtyConnector = MultiplePtyConnector(ptyConnector)
|
||||
// 宏应该在转发前面执行,不然会导致重复录制
|
||||
val macroPtyConnector = MacroPtyConnector(multiplePtyConnector)
|
||||
// 宏
|
||||
val macroPtyConnector = MacroPtyConnector(ptyConnector)
|
||||
// 集成自动删除
|
||||
val autoRemovePtyConnector = AutoRemovePtyConnector(macroPtyConnector)
|
||||
ptyConnectors.add(autoRemovePtyConnector)
|
||||
return autoRemovePtyConnector
|
||||
}
|
||||
|
||||
fun getPtyConnectors(): List<PtyConnector> {
|
||||
return ptyConnectors
|
||||
}
|
||||
|
||||
private inner class AutoRemovePtyConnector(connector: PtyConnector) : PtyConnectorDelegate(connector) {
|
||||
override fun close() {
|
||||
ptyConnectors.remove(this)
|
||||
|
||||
@@ -3,6 +3,7 @@ package app.termora
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.Terminal
|
||||
import kotlinx.coroutines.delay
|
||||
import org.slf4j.LoggerFactory
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -11,9 +12,14 @@ class PtyConnectorReader(
|
||||
private val terminal: Terminal,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(PtyConnectorReader::class.java)
|
||||
}
|
||||
|
||||
suspend fun start() {
|
||||
var i: Int
|
||||
val buffer = CharArray(1024 * 8)
|
||||
|
||||
while ((ptyConnector.read(buffer).also { i = it }) != -1) {
|
||||
if (i == 0) {
|
||||
delay(10.milliseconds)
|
||||
@@ -22,6 +28,10 @@ class PtyConnectorReader(
|
||||
val text = String(buffer, 0, i)
|
||||
SwingUtilities.invokeLater { terminal.write(text) }
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("PtyConnectorReader stopped")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.terminal.ControlCharacters
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.PtyConnectorDelegate
|
||||
import app.termora.terminal.TerminalKeyEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.terminal.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
@@ -12,7 +10,12 @@ import java.awt.event.KeyEvent
|
||||
import javax.swing.JComponent
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
|
||||
abstract class PtyHostTerminalTab(
|
||||
windowScope: WindowScope,
|
||||
host: Host,
|
||||
terminal: Terminal = TerminalFactory.getInstance().createTerminal()
|
||||
) : HostTerminalTab(windowScope, host, terminal) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
|
||||
}
|
||||
@@ -20,9 +23,8 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
|
||||
|
||||
private var readerJob: Job? = null
|
||||
private val ptyConnectorDelegate = PtyConnectorDelegate()
|
||||
|
||||
protected val terminalPanel = TerminalPanelFactory.instance.createTerminalPanel(terminal, ptyConnectorDelegate)
|
||||
protected val ptyConnectorFactory get() = PtyConnectorFactory.instance
|
||||
protected val terminalPanel = TerminalPanelFactory.getInstance().createTerminalPanel(terminal, ptyConnectorDelegate)
|
||||
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance()
|
||||
|
||||
override fun start() {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
@@ -42,12 +44,16 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
|
||||
startPtyConnectorReader()
|
||||
|
||||
// 启动命令
|
||||
if (host.options.startupCommand.isNotBlank()) {
|
||||
if (host.options.startupCommand.isNotBlank() && host.protocol != Protocol.SFTPPty) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
delay(250.milliseconds)
|
||||
withContext(Dispatchers.Swing) {
|
||||
ptyConnector.write(host.options.startupCommand)
|
||||
ptyConnector.write(terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER)))
|
||||
val charset = ptyConnector.getCharset()
|
||||
ptyConnector.write(host.options.startupCommand.toByteArray(charset))
|
||||
ptyConnector.write(
|
||||
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
|
||||
.toByteArray(charset)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +66,10 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
|
||||
// 失败关闭
|
||||
stop()
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("\r\n${ControlCharacters.ESC}[31m")
|
||||
terminal.write(ExceptionUtils.getRootCauseMessage(e))
|
||||
@@ -105,9 +115,9 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
|
||||
|
||||
override fun dispose() {
|
||||
stop()
|
||||
Disposer.dispose(terminalPanel)
|
||||
super.dispose()
|
||||
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Host: {} disposed", host.name)
|
||||
}
|
||||
@@ -118,4 +128,14 @@ abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
|
||||
}
|
||||
|
||||
abstract suspend fun openPtyConnector(): PtyConnector
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == DataProviders.TerminalPanel) {
|
||||
return terminalPanel as T?
|
||||
} else if (dataKey == DataProviders.TerminalWriter) {
|
||||
return terminalPanel.getData(DataKey.TerminalWriter) as T?
|
||||
}
|
||||
return super.getData(dataKey)
|
||||
}
|
||||
}
|
||||
16
src/main/kotlin/app/termora/RememberFocusTerminalTab.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package app.termora
|
||||
|
||||
import java.awt.Component
|
||||
import java.awt.KeyboardFocusManager
|
||||
|
||||
abstract class RememberFocusTerminalTab : TerminalTab {
|
||||
private var lastFocusedComponent: Component? = null
|
||||
|
||||
override fun onLostFocus() {
|
||||
lastFocusedComponent = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
|
||||
}
|
||||
|
||||
override fun onGrabFocus() {
|
||||
lastFocusedComponent?.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
165
src/main/kotlin/app/termora/RequestAuthenticationDialog.kt
Normal file
@@ -0,0 +1,165 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import com.formdev.flatlaf.extras.components.FlatComboBox
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.event.ItemEvent
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(owner) {
|
||||
|
||||
private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
|
||||
private val rememberCheckBox = JCheckBox(I18n.getString("termora.new-host.general.remember"))
|
||||
private val passwordPanel = JPanel(BorderLayout())
|
||||
private val passwordPasswordField = OutlinePasswordField()
|
||||
private val usernameTextField = OutlineTextField()
|
||||
private val publicKeyComboBox = OutlineComboBox<OhKeyPair>()
|
||||
private val keyManager get() = KeyManager.getInstance()
|
||||
private var authentication = Authentication.No
|
||||
|
||||
init {
|
||||
isModal = true
|
||||
title = "SSH User Authentication"
|
||||
controlsVisible = false
|
||||
|
||||
init()
|
||||
|
||||
pack()
|
||||
|
||||
size = Dimension(max(380, size.width), size.height)
|
||||
preferredSize = size
|
||||
minimumSize = size
|
||||
|
||||
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
return super.getListCellRendererComponent(
|
||||
list,
|
||||
if (value is OhKeyPair) value.name else value,
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (keyPair in keyManager.getOhKeyPairs()) {
|
||||
publicKeyComboBox.addItem(keyPair)
|
||||
}
|
||||
|
||||
authenticationTypeComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
switchPasswordComponent()
|
||||
}
|
||||
}
|
||||
|
||||
if (host.authentication.type != AuthenticationType.No) {
|
||||
authenticationTypeComboBox.selectedItem = host.authentication.type
|
||||
}
|
||||
|
||||
usernameTextField.text = host.username
|
||||
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.Password)
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.PublicKey)
|
||||
val formMargin = "7dlu"
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
switchPasswordComponent()
|
||||
|
||||
return FormBuilder.create().padding("$formMargin, $formMargin, $formMargin, $formMargin")
|
||||
.layout(layout)
|
||||
.add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, 1)
|
||||
.add(authenticationTypeComboBox).xy(3, 1)
|
||||
.add("${I18n.getString("termora.new-host.general.username")}:").xy(1, 3)
|
||||
.add(usernameTextField).xy(3, 3)
|
||||
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 5)
|
||||
.add(passwordPanel).xy(3, 5)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun switchPasswordComponent() {
|
||||
passwordPanel.removeAll()
|
||||
if (authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
|
||||
passwordPanel.add(passwordPasswordField, BorderLayout.CENTER)
|
||||
} else if (authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
|
||||
passwordPanel.add(publicKeyComboBox, BorderLayout.CENTER)
|
||||
}
|
||||
passwordPanel.revalidate()
|
||||
passwordPanel.repaint()
|
||||
}
|
||||
|
||||
override fun createSouthPanel(): JComponent? {
|
||||
val box = super.createSouthPanel() ?: return null
|
||||
rememberCheckBox.isFocusable = false
|
||||
box.add(rememberCheckBox, 0)
|
||||
return box
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
authentication = Authentication.No
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
val type = authenticationTypeComboBox.selectedItem as AuthenticationType
|
||||
|
||||
if (type == AuthenticationType.Password) {
|
||||
if (passwordPasswordField.password.isEmpty()) {
|
||||
passwordPasswordField.requestFocusInWindow()
|
||||
return
|
||||
}
|
||||
} else if (type == AuthenticationType.PublicKey) {
|
||||
if (publicKeyComboBox.selectedItem == null) {
|
||||
publicKeyComboBox.requestFocusInWindow()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
authentication = authentication.copy(
|
||||
type = type,
|
||||
password = if (type == AuthenticationType.Password) String(passwordPasswordField.password)
|
||||
else (publicKeyComboBox.selectedItem as OhKeyPair).id
|
||||
)
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
fun getAuthentication(): Authentication {
|
||||
isModal = true
|
||||
SwingUtilities.invokeLater {
|
||||
if (usernameTextField.text.isBlank()) {
|
||||
usernameTextField.requestFocusInWindow()
|
||||
} else {
|
||||
passwordPasswordField.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
isVisible = true
|
||||
return authentication
|
||||
}
|
||||
|
||||
fun isRemembered(): Boolean {
|
||||
return rememberCheckBox.isSelected
|
||||
}
|
||||
|
||||
fun getUsername(): String {
|
||||
return usernameTextField.text
|
||||
}
|
||||
|
||||
}
|
||||
213
src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt
Normal file
@@ -0,0 +1,213 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.keymgr.KeyManager
|
||||
import app.termora.keymgr.OhKeyPairKeyPairProvider
|
||||
import app.termora.terminal.*
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.io.Charsets
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.sshd.client.ClientBuilder
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter
|
||||
import org.apache.sshd.common.util.net.SshdSocketAddress
|
||||
import java.awt.event.KeyEvent
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import javax.swing.Icon
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
|
||||
private val keyManager by lazy { KeyManager.getInstance() }
|
||||
private val tempFiles = mutableListOf<Path>()
|
||||
private var sshClient: SshClient? = null
|
||||
private var sshSession: ClientSession? = null
|
||||
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
|
||||
private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand
|
||||
private val defaultDirectory get() = Database.getDatabase().sftp.defaultDirectory
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(terminalPanel)
|
||||
|
||||
init {
|
||||
terminalPanel.dropFiles = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
val canSupports by lazy {
|
||||
val process = if (SystemInfo.isWindows) {
|
||||
ProcessBuilder("cmd.exe", "/c", "where", "sftp").start()
|
||||
} else {
|
||||
ProcessBuilder("which", "sftp").start()
|
||||
}
|
||||
process.waitFor()
|
||||
return@lazy process.exitValue() == 0
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun openPtyConnector(): PtyConnector {
|
||||
|
||||
val useJumpHosts = host.options.jumpHosts.isNotEmpty() || host.proxy.type != ProxyType.No
|
||||
val commands = mutableListOf(StringUtils.defaultIfBlank(sftpCommand, "sftp"))
|
||||
var host = this.host
|
||||
|
||||
// 如果配置了跳板机或者代理,那么通过 SSH 的端口转发到本地
|
||||
if (useJumpHosts) {
|
||||
host = host.copy(
|
||||
updateDate = System.currentTimeMillis(),
|
||||
tunnelings = listOf(
|
||||
Tunneling(
|
||||
type = TunnelingType.Local,
|
||||
sourceHost = SshdSocketAddress.LOCALHOST_NAME,
|
||||
destinationHost = SshdSocketAddress.LOCALHOST_NAME,
|
||||
destinationPort = host.port,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val sshClient = SshClients.openClient(host, owner).apply { sshClient = this }
|
||||
val sshSession = SshClients.openSession(host, sshClient).apply { sshSession = this }
|
||||
|
||||
// 打开通道
|
||||
for (tunneling in host.tunnelings) {
|
||||
val address = SshClients.openTunneling(sshSession, host, tunneling)
|
||||
host = host.copy(
|
||||
host = address.hostName,
|
||||
port = address.port,
|
||||
updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (useJumpHosts) {
|
||||
// 打开通道后忽略 key 检查
|
||||
commands.add("-o")
|
||||
commands.add("StrictHostKeyChecking=no")
|
||||
|
||||
// 不保存 known_hosts
|
||||
commands.add("-o")
|
||||
commands.add("UserKnownHostsFile=${if (SystemInfo.isWindows) "NUL" else "/dev/null"}")
|
||||
} else {
|
||||
// known_hosts
|
||||
commands.add("-o")
|
||||
commands.add("UserKnownHostsFile=${File(Application.getBaseDataDir(), "known_hosts").absolutePath}")
|
||||
}
|
||||
|
||||
// Compression
|
||||
commands.add("-o")
|
||||
commands.add("Compression=yes")
|
||||
|
||||
// HostKeyAlgorithms 让 SFTP 命令的顺序和 sshd 的一致 这样可以避免 known_hosts 文件不一致问题
|
||||
val hostKeyAlgorithms = ClientBuilder.setUpDefaultSignatureFactories(true).joinToString(",") { it.name }
|
||||
commands.add("-o")
|
||||
commands.add("HostKeyAlgorithms=${hostKeyAlgorithms}")
|
||||
|
||||
// 不使用配置文件
|
||||
commands.add("-F")
|
||||
commands.add("/dev/null")
|
||||
|
||||
// port
|
||||
commands.add("-P")
|
||||
commands.add(host.port.toString())
|
||||
|
||||
// 设置认证信息
|
||||
setAuthentication(commands, host)
|
||||
|
||||
|
||||
val envs = host.options.envs()
|
||||
if (envs.containsKey("CurrentDir")) {
|
||||
val currentDir = envs.getValue("CurrentDir")
|
||||
commands.add("${host.username}@${host.host}:${currentDir}")
|
||||
} else if (host.options.sftpDefaultDirectory.isNotBlank()) {
|
||||
commands.add("${host.username}@${host.host}:${host.options.sftpDefaultDirectory.trim()}")
|
||||
} else {
|
||||
commands.add("${host.username}@${host.host}")
|
||||
}
|
||||
|
||||
val directory = FileUtils.getFile(StringUtils.defaultIfBlank(defaultDirectory, SystemUtils.USER_HOME))
|
||||
|
||||
val winSize = terminalPanel.winSize()
|
||||
val ptyConnector = ptyConnectorFactory.createPtyConnector(
|
||||
commands = commands.toTypedArray(),
|
||||
rows = winSize.rows, cols = winSize.cols,
|
||||
env = host.options.envs(),
|
||||
charset = Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
|
||||
directory = if (directory.exists()) directory.absolutePath else SystemUtils.USER_HOME
|
||||
)
|
||||
|
||||
return ptyConnector
|
||||
}
|
||||
|
||||
private fun setAuthentication(commands: MutableList<String>, host: Host) {
|
||||
// 如果通过公钥连接
|
||||
if (host.authentication.type == AuthenticationType.PublicKey) {
|
||||
val ohKeyPair = keyManager.getOhKeyPair(host.authentication.password)
|
||||
if (ohKeyPair != null) {
|
||||
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(ohKeyPair)
|
||||
val privateKeyPath = Application.createSubTemporaryDir()
|
||||
val privateKeyFile = Files.createTempFile(privateKeyPath, Application.getName(), StringUtils.EMPTY)
|
||||
Files.newOutputStream(privateKeyFile)
|
||||
.use { OpenSSHKeyPairResourceWriter.INSTANCE.writePrivateKey(keyPair, null, null, it) }
|
||||
commands.add("-i")
|
||||
commands.add(privateKeyFile.toFile().absolutePath)
|
||||
tempFiles.add(privateKeyPath)
|
||||
}
|
||||
} else if (host.authentication.type == AuthenticationType.Password) {
|
||||
terminal.getTerminalModel().addDataListener(PasswordReporterDataListener(host).apply {
|
||||
lastPasswordReporterDataListener = this
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
// 删除密码监听
|
||||
lastPasswordReporterDataListener?.let { listener ->
|
||||
SwingUtilities.invokeLater { terminal.getTerminalModel().removeDataListener(listener) }
|
||||
}
|
||||
|
||||
IOUtils.closeQuietly(sshSession)
|
||||
IOUtils.closeQuietly(sshClient)
|
||||
|
||||
tempFiles.removeIf {
|
||||
FileUtils.deleteQuietly(it.toFile())
|
||||
true
|
||||
}
|
||||
|
||||
super.stop()
|
||||
}
|
||||
|
||||
override fun getIcon(): Icon {
|
||||
return Icons.fileFormat
|
||||
}
|
||||
|
||||
private inner class PasswordReporterDataListener(private val host: Host) : DataListener {
|
||||
override fun onChanged(key: DataKey<*>, data: Any) {
|
||||
if (key == VisualTerminal.Written && data is String) {
|
||||
|
||||
// 要求输入密码
|
||||
val line = terminal.getDocument().getScreenLine(terminal.getCursorModel().getPosition().y)
|
||||
if (line.getText().trim().trimIndent().startsWith("${host.username}@${host.host}'s password:")) {
|
||||
|
||||
// 删除密码监听
|
||||
terminal.getTerminalModel().removeDataListener(this)
|
||||
|
||||
val ptyConnector = getPtyConnector()
|
||||
|
||||
// password
|
||||
ptyConnector.write(host.authentication.password.toByteArray(ptyConnector.getCharset()))
|
||||
|
||||
// enter
|
||||
ptyConnector.write(
|
||||
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
|
||||
.toByteArray(ptyConnector.getCharset())
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.actions.TabReconnectAction
|
||||
import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor
|
||||
import app.termora.keymap.KeyShortcut
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.terminal.ControlCharacters
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.PtyConnector
|
||||
@@ -20,25 +25,34 @@ import org.apache.sshd.common.channel.ChannelListener
|
||||
import org.apache.sshd.common.session.Session
|
||||
import org.apache.sshd.common.session.SessionListener
|
||||
import org.apache.sshd.common.session.SessionListener.Event
|
||||
import org.apache.sshd.common.util.net.SshdSocketAddress
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.*
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
|
||||
class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
|
||||
class SSHTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
PtyHostTerminalTab(windowScope, host) {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
|
||||
val SSHSession = DataKey(ClientSession::class)
|
||||
|
||||
private val log = LoggerFactory.getLogger(SSHTerminalTab::class.java)
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val tab = this
|
||||
|
||||
private var sshClient: SshClient? = null
|
||||
private var sshSession: ClientSession? = null
|
||||
private var sshChannelShell: ChannelShell? = null
|
||||
private val terminalTabbedManager
|
||||
get() = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
|
||||
.getData(DataProviders.TerminalTabbedManager)
|
||||
|
||||
init {
|
||||
terminalPanel.dropFiles = false
|
||||
terminalPanel.dataProviderSupport.addData(DataProviders.TerminalTab, this)
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
@@ -75,7 +89,8 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
|
||||
terminal.write("SSH client is opening...\r\n")
|
||||
}
|
||||
|
||||
val client = SshClients.openClient(host).also { sshClient = it }
|
||||
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
|
||||
val client = SshClients.openClient(host, owner).also { sshClient = it }
|
||||
val sessionListener = MySessionListener()
|
||||
val channelListener = MyChannelListener()
|
||||
|
||||
@@ -104,12 +119,32 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
|
||||
|
||||
|
||||
channel.addChannelListener(object : ChannelListener {
|
||||
private val reconnectShortcut
|
||||
get() = KeymapManager.getInstance().getActiveKeymap()
|
||||
.getShortcut(TabReconnectAction.RECONNECT_TAB).firstOrNull()
|
||||
|
||||
override fun channelClosed(channel: Channel, reason: Throwable?) {
|
||||
coroutineScope.launch(Dispatchers.Swing) {
|
||||
terminal.write("\r\n${ControlCharacters.ESC}[31m")
|
||||
terminal.write("Channel has been disconnected.\r\n")
|
||||
terminal.write("\r\n\r\n${ControlCharacters.ESC}[31m")
|
||||
terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
|
||||
if (reconnectShortcut is KeyShortcut) {
|
||||
terminal.write(
|
||||
I18n.getString(
|
||||
"termora.terminal.channel-reconnect",
|
||||
reconnectShortcut.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
terminal.write("\r\n")
|
||||
terminal.write("${ControlCharacters.ESC}[0m")
|
||||
terminalModel.setData(DataKey.ShowCursor, false)
|
||||
if (Database.getDatabase().terminal.autoCloseTabWhenDisconnected) {
|
||||
terminalTabbedManager?.let { manager ->
|
||||
SwingUtilities.invokeLater {
|
||||
manager.closeTerminalTab(tab, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -143,35 +178,30 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
|
||||
}
|
||||
|
||||
for (tunneling in host.tunnelings) {
|
||||
if (tunneling.type == TunnelingType.Local) {
|
||||
session.startLocalPortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Remote) {
|
||||
session.startRemotePortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Dynamic) {
|
||||
session.startDynamicPortForwarding(
|
||||
SshdSocketAddress(
|
||||
tunneling.sourceHost,
|
||||
tunneling.sourcePort
|
||||
)
|
||||
)
|
||||
try {
|
||||
SshClients.openTunneling(session, host, tunneling)
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error("Start [${tunneling.name}] port forwarding failed: {}", e.message, e)
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("Start [${tunneling.name}] port forwarding failed: ${e.message}\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("SSH [{}] started {} port forwarding.", host.name, tunneling.name)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == SSHSession) {
|
||||
return sshSession as T?
|
||||
}
|
||||
return super.getData(dataKey)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (mutex.tryLock()) {
|
||||
@@ -193,6 +223,11 @@ class SSHTerminalTab(host: Host) : PtyHostTerminalTab(host) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun willBeClose(): Boolean {
|
||||
// 保存窗口状态
|
||||
terminalPanel.storeVisualWindows(host.id)
|
||||
return super.willBeClose()
|
||||
}
|
||||
|
||||
private inner class MySessionListener : SessionListener, Disposable {
|
||||
override fun sessionEvent(session: Session, event: Event) {
|
||||
|
||||
182
src/main/kotlin/app/termora/Scope.kt
Normal file
@@ -0,0 +1,182 @@
|
||||
package app.termora
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Component
|
||||
import java.awt.Window
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.swing.JPopupMenu
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
val swingCoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Swing)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
open class Scope(
|
||||
private val beans: MutableMap<KClass<*>, Any> = ConcurrentHashMap(),
|
||||
private val properties: MutableMap<String, Any> = ConcurrentHashMap()
|
||||
) : Disposable {
|
||||
|
||||
|
||||
fun <T : Any> get(clazz: KClass<T>): T {
|
||||
return beans[clazz] as T
|
||||
}
|
||||
|
||||
|
||||
fun <T : Any> getOrCreate(clazz: KClass<T>, create: () -> T): T {
|
||||
|
||||
if (beans.containsKey(clazz)) {
|
||||
return get(clazz)
|
||||
}
|
||||
|
||||
synchronized(clazz) {
|
||||
if (beans.containsKey(clazz)) {
|
||||
return get(clazz)
|
||||
}
|
||||
|
||||
val instance = create.invoke()
|
||||
beans[clazz] = instance
|
||||
|
||||
if (instance is Disposable) {
|
||||
Disposer.register(this, instance)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun putBoolean(name: String, value: Boolean) {
|
||||
properties[name] = value
|
||||
}
|
||||
|
||||
fun getBoolean(name: String, defaultValue: Boolean): Boolean {
|
||||
return properties[name]?.toString()?.toBoolean() ?: defaultValue
|
||||
}
|
||||
|
||||
fun putAny(name: String, value: Any) {
|
||||
properties[name] = value
|
||||
}
|
||||
|
||||
fun getAny(name: String, defaultValue: Any): Any {
|
||||
return properties[name]?.toString() ?: defaultValue
|
||||
}
|
||||
|
||||
fun getAnyOrNull(name: String): Any? {
|
||||
return properties[name]
|
||||
}
|
||||
|
||||
|
||||
override fun dispose() {
|
||||
beans.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ApplicationScope private constructor() : Scope() {
|
||||
|
||||
private val scopes = mutableMapOf<Any, WindowScope>()
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(ApplicationScope::class.java)
|
||||
private val instance by lazy { ApplicationScope() }
|
||||
|
||||
fun forApplicationScope(): ApplicationScope {
|
||||
return instance
|
||||
}
|
||||
|
||||
fun forWindowScope(frame: TermoraFrame): WindowScope {
|
||||
return forApplicationScope().forWindowScope(frame)
|
||||
}
|
||||
|
||||
fun forWindowScope(container: Component): WindowScope {
|
||||
val frame = getFrameForComponent(container)
|
||||
?: throw IllegalStateException("Unexpected owner in $container")
|
||||
return forWindowScope(frame)
|
||||
}
|
||||
|
||||
fun windowScopes(): List<WindowScope> {
|
||||
return forApplicationScope().windowScopes()
|
||||
}
|
||||
|
||||
private fun getFrameForComponent(component: Component): TermoraFrame? {
|
||||
if (component is TermoraFrame) {
|
||||
return component
|
||||
}
|
||||
|
||||
var owner = SwingUtilities.getWindowAncestor(component) as Component?
|
||||
if (owner is TermoraFrame) {
|
||||
return owner
|
||||
}
|
||||
|
||||
if (owner == null) {
|
||||
owner = component
|
||||
}
|
||||
|
||||
while (owner != null) {
|
||||
|
||||
if (owner is JPopupMenu) {
|
||||
owner = owner.invoker
|
||||
if (owner is TermoraFrame) {
|
||||
return owner
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
owner = owner.parent
|
||||
if (owner is TermoraFrame) {
|
||||
return owner
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun forWindowScope(frame: TermoraFrame): WindowScope {
|
||||
val windowScope = scopes.getOrPut(frame) { WindowScope(frame) }
|
||||
Disposer.register(windowScope, object : Disposable {
|
||||
override fun dispose() {
|
||||
scopes.remove(frame)
|
||||
}
|
||||
})
|
||||
|
||||
return windowScope
|
||||
}
|
||||
|
||||
fun windowScopes(): List<WindowScope> {
|
||||
if (scopes.isEmpty()) return emptyList()
|
||||
return scopes.values.toList()
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("ApplicationScope disposed")
|
||||
}
|
||||
swingCoroutineScope.cancel()
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class WindowScope(
|
||||
val window: Window,
|
||||
) : Scope() {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(WindowScope::class.java)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("WindowScope disposed")
|
||||
}
|
||||
super.dispose()
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import javax.swing.event.TreeModelEvent
|
||||
import javax.swing.event.TreeModelListener
|
||||
import javax.swing.tree.TreeModel
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class SearchableHostTreeModel(private val model: HostTreeModel) : TreeModel {
|
||||
private var text = String()
|
||||
|
||||
override fun getRoot(): Any {
|
||||
return model.root
|
||||
}
|
||||
|
||||
override fun getChild(parent: Any?, index: Int): Any {
|
||||
return getChildren(parent)[index]
|
||||
}
|
||||
|
||||
override fun getChildCount(parent: Any?): Int {
|
||||
return getChildren(parent).size
|
||||
}
|
||||
|
||||
override fun isLeaf(node: Any?): Boolean {
|
||||
return model.isLeaf(node)
|
||||
}
|
||||
|
||||
override fun valueForPathChanged(path: TreePath?, newValue: Any?) {
|
||||
return model.valueForPathChanged(path, newValue)
|
||||
}
|
||||
|
||||
override fun getIndexOfChild(parent: Any?, child: Any?): Int {
|
||||
return getChildren(parent).indexOf(child)
|
||||
}
|
||||
|
||||
override fun addTreeModelListener(l: TreeModelListener) {
|
||||
model.addTreeModelListener(l)
|
||||
}
|
||||
|
||||
override fun removeTreeModelListener(l: TreeModelListener) {
|
||||
model.removeTreeModelListener(l)
|
||||
}
|
||||
|
||||
|
||||
private fun getChildren(parent: Any?): List<Host> {
|
||||
val children = model.getChildren(parent)
|
||||
if (children.isEmpty()) return emptyList()
|
||||
return children.filter { e ->
|
||||
e.name.contains(text, true) || TreeUtils.children(model, e, true).filterIsInstance<Host>().any {
|
||||
it.name.contains(text, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun search(text: String) {
|
||||
this.text = text
|
||||
model.listeners.forEach {
|
||||
it.treeStructureChanged(
|
||||
TreeModelEvent(
|
||||
this, TreePath(root),
|
||||
null, null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
61
src/main/kotlin/app/termora/SerialPortPtyConnector.kt
Normal file
@@ -0,0 +1,61 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.terminal.PtyConnector
|
||||
import com.fazecast.jSerialComm.SerialPort
|
||||
import com.fazecast.jSerialComm.SerialPortDataListener
|
||||
import com.fazecast.jSerialComm.SerialPortEvent
|
||||
import java.nio.charset.Charset
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SerialPortPtyConnector(
|
||||
private val serialPort: SerialPort,
|
||||
private val charset: Charset = Charsets.UTF_8
|
||||
) : PtyConnector, SerialPortDataListener {
|
||||
|
||||
private val queue = LinkedBlockingQueue<Char>()
|
||||
|
||||
init {
|
||||
serialPort.addDataListener(this)
|
||||
}
|
||||
|
||||
override fun read(buffer: CharArray): Int {
|
||||
buffer[0] = queue.poll(1, TimeUnit.SECONDS) ?: return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
override fun write(buffer: ByteArray, offset: Int, len: Int) {
|
||||
serialPort.writeBytes(buffer, len, offset)
|
||||
}
|
||||
|
||||
override fun resize(rows: Int, cols: Int) {
|
||||
|
||||
}
|
||||
|
||||
override fun waitFor(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
queue.clear()
|
||||
serialPort.closePort()
|
||||
}
|
||||
|
||||
override fun getListeningEvents(): Int {
|
||||
return SerialPort.LISTENING_EVENT_DATA_RECEIVED
|
||||
}
|
||||
|
||||
override fun serialEvent(event: SerialPortEvent) {
|
||||
if (event.eventType == SerialPort.LISTENING_EVENT_DATA_RECEIVED) {
|
||||
val data = event.receivedData
|
||||
if (data.isEmpty()) return
|
||||
for (c in String(data, charset).toCharArray()) {
|
||||
queue.add(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return charset
|
||||
}
|
||||
}
|
||||
21
src/main/kotlin/app/termora/SerialTerminalTab.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.terminal.PtyConnector
|
||||
import org.apache.commons.io.Charsets
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.swing.Icon
|
||||
|
||||
class SerialTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
PtyHostTerminalTab(windowScope, host) {
|
||||
override suspend fun openPtyConnector(): PtyConnector {
|
||||
val serialPort = Serials.openPort(host)
|
||||
return SerialPortPtyConnector(
|
||||
serialPort,
|
||||
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getIcon(): Icon {
|
||||
return Icons.plugin
|
||||
}
|
||||
}
|
||||
38
src/main/kotlin/app/termora/Serials.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
package app.termora
|
||||
|
||||
import com.fazecast.jSerialComm.SerialPort
|
||||
|
||||
object Serials {
|
||||
fun openPort(host: Host): SerialPort {
|
||||
val serialComm = host.options.serialComm
|
||||
val serialPort = SerialPort.getCommPort(serialComm.port)
|
||||
serialPort.setBaudRate(serialComm.baudRate)
|
||||
serialPort.setNumDataBits(serialComm.dataBits)
|
||||
|
||||
when (serialComm.parity) {
|
||||
SerialCommParity.None -> serialPort.setParity(SerialPort.NO_PARITY)
|
||||
SerialCommParity.Mark -> serialPort.setParity(SerialPort.MARK_PARITY)
|
||||
SerialCommParity.Even -> serialPort.setParity(SerialPort.EVEN_PARITY)
|
||||
SerialCommParity.Odd -> serialPort.setParity(SerialPort.ODD_PARITY)
|
||||
SerialCommParity.Space -> serialPort.setParity(SerialPort.SPACE_PARITY)
|
||||
}
|
||||
|
||||
when (serialComm.stopBits) {
|
||||
"1" -> serialPort.setNumStopBits(SerialPort.ONE_STOP_BIT)
|
||||
"1.5" -> serialPort.setNumStopBits(SerialPort.ONE_POINT_FIVE_STOP_BITS)
|
||||
"2" -> serialPort.setNumStopBits(SerialPort.TWO_STOP_BITS)
|
||||
}
|
||||
|
||||
when (serialComm.flowControl) {
|
||||
SerialCommFlowControl.None -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED)
|
||||
SerialCommFlowControl.RTS_CTS -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_RTS_ENABLED or SerialPort.FLOW_CONTROL_CTS_ENABLED)
|
||||
SerialCommFlowControl.XON_XOFF -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_XONXOFF_IN_ENABLED or SerialPort.FLOW_CONTROL_XONXOFF_OUT_ENABLED)
|
||||
}
|
||||
|
||||
if (!serialPort.openPort()) {
|
||||
throw IllegalStateException("Open serial port [${serialComm.port}] failed")
|
||||
}
|
||||
|
||||
return serialPort
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.db.Database
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
@@ -13,7 +12,7 @@ import javax.swing.UIManager
|
||||
|
||||
class SettingsDialog(owner: Window) : DialogWrapper(owner) {
|
||||
private val optionsPane = SettingsOptionsPane()
|
||||
private val properties get() = Database.instance.properties
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
|
||||
|
||||
344
src/main/kotlin/app/termora/SimpleTree.kt
Normal file
@@ -0,0 +1,344 @@
|
||||
package app.termora
|
||||
|
||||
import org.jdesktop.swingx.JXTree
|
||||
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.Transferable
|
||||
import java.awt.datatransfer.UnsupportedFlavorException
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import javax.swing.event.CellEditorListener
|
||||
import javax.swing.event.ChangeEvent
|
||||
import javax.swing.tree.TreePath
|
||||
import kotlin.math.min
|
||||
|
||||
open class SimpleTree : JXTree() {
|
||||
|
||||
protected open val model get() = super.getModel() as SimpleTreeModel<*>
|
||||
private val editor = OutlineTextField(64)
|
||||
protected val tree get() = this
|
||||
|
||||
init {
|
||||
initViews()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
|
||||
private fun initViews() {
|
||||
|
||||
|
||||
// renderer
|
||||
setCellRenderer(object : DefaultXTreeCellRenderer() {
|
||||
override fun getTreeCellRendererComponent(
|
||||
tree: JTree,
|
||||
value: Any,
|
||||
sel: Boolean,
|
||||
expanded: Boolean,
|
||||
leaf: Boolean,
|
||||
row: Int,
|
||||
hasFocus: Boolean
|
||||
): Component {
|
||||
val node = value as SimpleTreeNode<*>
|
||||
val c = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus)
|
||||
icon = node.getIcon(sel, expanded, tree.hasFocus())
|
||||
return c
|
||||
}
|
||||
})
|
||||
|
||||
// rename
|
||||
setCellEditor(object : DefaultCellEditor(editor) {
|
||||
override fun isCellEditable(e: EventObject?): Boolean {
|
||||
if (e is MouseEvent || !tree.isCellEditable(e)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return super.isCellEditable(e).apply {
|
||||
if (this) {
|
||||
editor.preferredSize = Dimension(min(220, width - 64), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCellEditorValue(): Any? {
|
||||
return getLastSelectedPathNode()?.data
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
// 右键选中
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
if (!SwingUtilities.isRightMouseButton(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
requestFocusInWindow()
|
||||
|
||||
val selectionRows = selectionModel.selectionRows
|
||||
|
||||
val selRow = getClosestRowForLocation(e.x, e.y)
|
||||
if (selRow < 0) {
|
||||
selectionModel.clearSelection()
|
||||
return
|
||||
} else if (selectionRows != null && selectionRows.contains(selRow)) {
|
||||
return
|
||||
}
|
||||
|
||||
selectionPath = getPathForLocation(e.x, e.y)
|
||||
|
||||
setSelectionRow(selRow)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// contextmenu
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
if (!(SwingUtilities.isRightMouseButton(e))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Objects.isNull(lastSelectedPathComponent)) {
|
||||
return
|
||||
}
|
||||
|
||||
showContextmenu(e)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// rename
|
||||
getCellEditor().addCellEditorListener(object : CellEditorListener {
|
||||
override fun editingStopped(e: ChangeEvent) {
|
||||
val node = getLastSelectedPathNode() ?: return
|
||||
if (editor.text.isBlank() || editor.text == node.toString()) {
|
||||
return
|
||||
}
|
||||
onRenamed(node, editor.text)
|
||||
}
|
||||
|
||||
override fun editingCanceled(e: ChangeEvent) {
|
||||
}
|
||||
})
|
||||
|
||||
// drag
|
||||
transferHandler = object : TransferHandler() {
|
||||
|
||||
override fun createTransferable(c: JComponent): Transferable? {
|
||||
val nodes = getSelectionSimpleTreeNodes().toMutableList()
|
||||
if (nodes.isEmpty()) return null
|
||||
if (nodes.contains(model.root)) return null
|
||||
|
||||
val iterator = nodes.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val node = iterator.next()
|
||||
val parents = model.getPathToRoot(node).filter { it != node }
|
||||
if (parents.any { nodes.contains(it) }) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
return MoveNodeTransferable(nodes)
|
||||
}
|
||||
|
||||
override fun getSourceActions(c: JComponent?): Int {
|
||||
return MOVE
|
||||
}
|
||||
|
||||
override fun canImport(support: TransferSupport): Boolean {
|
||||
if (support.component != tree) return false
|
||||
val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false
|
||||
val path = dropLocation.path ?: return false
|
||||
val node = path.lastPathComponent as? SimpleTreeNode<*> ?: return false
|
||||
if (!support.isDataFlavorSupported(MoveNodeTransferable.dataFlavor)) return false
|
||||
val nodes = (support.transferable.getTransferData(MoveNodeTransferable.dataFlavor) as? List<*>)
|
||||
?.filterIsInstance<SimpleTreeNode<*>>() ?: return false
|
||||
if (nodes.isEmpty()) return false
|
||||
if (!node.isFolder) return false
|
||||
|
||||
for (e in nodes) {
|
||||
// 禁止拖拽到自己的子下面
|
||||
if (path.equals(TreePath(e.path)) || TreePath(e.path).isDescendant(path)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 文件夹只能拖拽到文件夹的下面
|
||||
if (e.isFolder) {
|
||||
if (dropLocation.childIndex > node.folderCount) {
|
||||
return false
|
||||
}
|
||||
} else if (dropLocation.childIndex != -1) {
|
||||
// 非文件夹也不能拖拽到文件夹的上面
|
||||
if (dropLocation.childIndex < node.folderCount) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val p = e.parent ?: continue
|
||||
// 如果是同级目录排序,那么判断是不是自己的上下,如果是的话也禁止
|
||||
if (p == node && dropLocation.childIndex != -1) {
|
||||
val idx = p.getIndex(e)
|
||||
if (dropLocation.childIndex in idx..idx + 1) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
support.setShowDropLocation(true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun importData(support: TransferSupport): Boolean {
|
||||
if (!support.isDrop) return false
|
||||
val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false
|
||||
val node = dropLocation.path.lastPathComponent as? SimpleTreeNode<*> ?: return false
|
||||
val nodes = (support.transferable.getTransferData(MoveNodeTransferable.dataFlavor) as? List<*>)
|
||||
?.filterIsInstance<SimpleTreeNode<*>>() ?: return false
|
||||
|
||||
// 展开的 node
|
||||
val expanded = mutableSetOf(node.id)
|
||||
for (e in nodes) {
|
||||
e.getAllChildren().filter { isExpanded(TreePath(model.getPathToRoot(it))) }
|
||||
.map { it }.forEach { expanded.add(it.id) }
|
||||
}
|
||||
|
||||
// 转移
|
||||
for (e in nodes) {
|
||||
model.removeNodeFromParent(e)
|
||||
rebase(e, node)
|
||||
|
||||
if (dropLocation.childIndex == -1) {
|
||||
if (e.isFolder) {
|
||||
model.insertNodeInto(e, node, node.folderCount)
|
||||
} else {
|
||||
model.insertNodeInto(e, node, node.childCount)
|
||||
}
|
||||
} else {
|
||||
if (e.isFolder) {
|
||||
model.insertNodeInto(e, node, min(node.folderCount, dropLocation.childIndex))
|
||||
} else {
|
||||
model.insertNodeInto(e, node, min(node.childCount, dropLocation.childIndex))
|
||||
}
|
||||
}
|
||||
|
||||
selectionPath = TreePath(model.getPathToRoot(e))
|
||||
}
|
||||
|
||||
// 先展开最顶级的
|
||||
expandPath(TreePath(model.getPathToRoot(node)))
|
||||
|
||||
for (child in node.getAllChildren()) {
|
||||
if (expanded.contains(child.id)) {
|
||||
expandPath(TreePath(model.getPathToRoot(child)))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun newFolder(newNode: SimpleTreeNode<*>): Boolean {
|
||||
val lastNode = lastSelectedPathComponent
|
||||
if (lastNode !is SimpleTreeNode<*>) return false
|
||||
return newNode(newNode, lastNode.folderCount)
|
||||
}
|
||||
|
||||
protected open fun newFile(newNode: SimpleTreeNode<*>): Boolean {
|
||||
val lastNode = lastSelectedPathComponent
|
||||
if (lastNode !is SimpleTreeNode<*>) return false
|
||||
return newNode(newNode, lastNode.childCount)
|
||||
}
|
||||
|
||||
private fun newNode(newNode: SimpleTreeNode<*>, index: Int): Boolean {
|
||||
val lastNode = lastSelectedPathComponent
|
||||
if (lastNode !is SimpleTreeNode<*>) return false
|
||||
model.insertNodeInto(newNode, lastNode, index)
|
||||
selectionPath = TreePath(model.getPathToRoot(newNode))
|
||||
startEditingAtPath(selectionPath)
|
||||
return true
|
||||
}
|
||||
|
||||
open fun getLastSelectedPathNode(): SimpleTreeNode<*>? {
|
||||
return lastSelectedPathComponent as? SimpleTreeNode<*>
|
||||
}
|
||||
|
||||
|
||||
protected open fun showContextmenu(evt: MouseEvent) {
|
||||
|
||||
}
|
||||
|
||||
protected open fun onRenamed(node: SimpleTreeNode<*>, text: String) {}
|
||||
|
||||
open fun refreshNode(node: SimpleTreeNode<*> = model.root) {
|
||||
val state = TreeUtils.saveExpansionState(tree)
|
||||
val rows = selectionRows
|
||||
|
||||
model.reload(node)
|
||||
|
||||
TreeUtils.loadExpansionState(tree, state)
|
||||
|
||||
super.setSelectionRows(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* 包含孙子
|
||||
*/
|
||||
open fun getSelectionSimpleTreeNodes(include: Boolean = false): List<SimpleTreeNode<*>> {
|
||||
val paths = selectionPaths ?: return emptyList()
|
||||
if (paths.isEmpty()) return emptyList()
|
||||
val nodes = mutableListOf<SimpleTreeNode<*>>()
|
||||
val parents = paths.mapNotNull { it.lastPathComponent }
|
||||
.filterIsInstance<SimpleTreeNode<*>>().toMutableList()
|
||||
|
||||
if (include) {
|
||||
while (parents.isNotEmpty()) {
|
||||
val node = parents.removeFirst()
|
||||
nodes.add(node)
|
||||
parents.addAll(node.children().toList().filterIsInstance<SimpleTreeNode<*>>())
|
||||
}
|
||||
}
|
||||
|
||||
return if (include) nodes else parents
|
||||
}
|
||||
|
||||
protected open fun isCellEditable(e: EventObject?): Boolean {
|
||||
return getLastSelectedPathNode() != model.root
|
||||
}
|
||||
|
||||
protected open fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>) {
|
||||
|
||||
}
|
||||
|
||||
private class MoveNodeTransferable(val nodes: List<SimpleTreeNode<*>>) : Transferable {
|
||||
companion object {
|
||||
val dataFlavor =
|
||||
DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveNodeTransferable::class.java.name}")
|
||||
}
|
||||
|
||||
|
||||
override fun getTransferDataFlavors(): Array<DataFlavor> {
|
||||
return arrayOf(dataFlavor)
|
||||
}
|
||||
|
||||
override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean {
|
||||
return dataFlavor == flavor
|
||||
}
|
||||
|
||||
override fun getTransferData(flavor: DataFlavor?): Any {
|
||||
if (flavor == dataFlavor) {
|
||||
return nodes
|
||||
}
|
||||
throw UnsupportedFlavorException(flavor)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
11
src/main/kotlin/app/termora/SimpleTreeModel.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package app.termora
|
||||
|
||||
import javax.swing.tree.DefaultTreeModel
|
||||
|
||||
abstract class SimpleTreeModel<T>(root: SimpleTreeNode<T>) : DefaultTreeModel(root) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun getRoot(): SimpleTreeNode<T> {
|
||||
return super.getRoot() as SimpleTreeNode<T>
|
||||
}
|
||||
|
||||
}
|
||||
37
src/main/kotlin/app/termora/SimpleTreeNode.kt
Normal file
@@ -0,0 +1,37 @@
|
||||
package app.termora
|
||||
|
||||
import javax.swing.Icon
|
||||
import javax.swing.tree.DefaultMutableTreeNode
|
||||
|
||||
abstract class SimpleTreeNode<T>(data: T) : DefaultMutableTreeNode(data) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
open var data: T
|
||||
get() = userObject as T
|
||||
set(value) = setUserObject(value)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun getParent(): SimpleTreeNode<T>? {
|
||||
return super.getParent() as SimpleTreeNode<T>?
|
||||
}
|
||||
|
||||
open val folderCount: Int get() = 0
|
||||
|
||||
open fun getIcon(selected: Boolean, expanded: Boolean, hasFocus: Boolean): Icon? {
|
||||
return null
|
||||
}
|
||||
|
||||
open val isFolder get() = false
|
||||
|
||||
abstract val id: String
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
open fun getAllChildren(): List<SimpleTreeNode<T>> {
|
||||
val children = mutableListOf<SimpleTreeNode<T>>()
|
||||
for (child in children()) {
|
||||
val c = child as? SimpleTreeNode<T> ?: continue
|
||||
children.add(c)
|
||||
children.addAll(c.getAllChildren())
|
||||
}
|
||||
return children
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,95 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import app.termora.keymgr.OhKeyPairKeyPairProvider
|
||||
import app.termora.terminal.TerminalSize
|
||||
import app.termora.x11.X11ChannelFactory
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.util.FontUtils
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.sshd.client.ClientBuilder
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.auth.password.UserAuthPasswordFactory
|
||||
import org.apache.sshd.client.channel.ChannelShell
|
||||
import org.apache.sshd.client.channel.ClientChannelEvent
|
||||
import org.apache.sshd.client.config.hosts.HostConfigEntry
|
||||
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
|
||||
import org.apache.sshd.client.config.hosts.KnownHostEntry
|
||||
import org.apache.sshd.client.kex.DHGClient
|
||||
import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier
|
||||
import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
|
||||
import org.apache.sshd.client.keyverifier.ServerKeyVerifier
|
||||
import org.apache.sshd.client.session.ClientProxyConnector
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.client.session.ClientSessionImpl
|
||||
import org.apache.sshd.client.session.SessionFactory
|
||||
import org.apache.sshd.common.AttributeRepository
|
||||
import org.apache.sshd.common.SshConstants
|
||||
import org.apache.sshd.common.SshException
|
||||
import org.apache.sshd.common.channel.ChannelFactory
|
||||
import org.apache.sshd.common.channel.PtyChannelConfiguration
|
||||
import org.apache.sshd.common.channel.PtyChannelConfigurationHolder
|
||||
import org.apache.sshd.common.cipher.CipherNone
|
||||
import org.apache.sshd.common.compression.BuiltinCompressions
|
||||
import org.apache.sshd.common.config.keys.KeyRandomArt
|
||||
import org.apache.sshd.common.config.keys.KeyUtils
|
||||
import org.apache.sshd.common.future.CloseFuture
|
||||
import org.apache.sshd.common.future.SshFutureListener
|
||||
import org.apache.sshd.common.global.KeepAliveHandler
|
||||
import org.apache.sshd.common.io.IoConnectFuture
|
||||
import org.apache.sshd.common.io.IoConnector
|
||||
import org.apache.sshd.common.io.IoServiceEventListener
|
||||
import org.apache.sshd.common.io.IoSession
|
||||
import org.apache.sshd.common.kex.BuiltinDHFactories
|
||||
import org.apache.sshd.common.keyprovider.KeyIdentityProvider
|
||||
import org.apache.sshd.common.session.Session
|
||||
import org.apache.sshd.common.session.SessionListener
|
||||
import org.apache.sshd.common.signature.BuiltinSignatures
|
||||
import org.apache.sshd.common.util.net.SshdSocketAddress
|
||||
import org.apache.sshd.core.CoreModuleProperties
|
||||
import org.apache.sshd.server.forward.AcceptAllForwardingFilter
|
||||
import org.apache.sshd.server.forward.RejectAllForwardingFilter
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitSshClient
|
||||
import org.eclipse.jgit.internal.transport.sshd.agent.JGitSshAgentFactory
|
||||
import org.eclipse.jgit.internal.transport.sshd.agent.connector.PageantConnector
|
||||
import org.eclipse.jgit.internal.transport.sshd.agent.connector.UnixDomainSocketConnector
|
||||
import org.eclipse.jgit.internal.transport.sshd.proxy.AbstractClientProxyConnector
|
||||
import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector
|
||||
import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector
|
||||
import org.eclipse.jgit.transport.CredentialsProvider
|
||||
import org.eclipse.jgit.transport.SshConstants.IDENTITY_AGENT
|
||||
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
|
||||
import org.eclipse.jgit.transport.sshd.ProxyData
|
||||
import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Font
|
||||
import java.awt.Window
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.SocketAddress
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.security.PublicKey
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
object SshClients {
|
||||
|
||||
val HOST_KEY = AttributeRepository.AttributeKey<Host>()
|
||||
|
||||
private val timeout = Duration.ofSeconds(30)
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
|
||||
|
||||
/**
|
||||
* 打开一个 Shell
|
||||
@@ -43,6 +111,12 @@ object SshClients {
|
||||
env.putAll(host.options.envs())
|
||||
|
||||
val channel = session.createShellChannel(configuration, env)
|
||||
if (host.options.enableX11Forwarding) {
|
||||
if (channel is app.termora.x11.ChannelShell) {
|
||||
channel.xForwarding = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel.open().verify(timeout).await()) {
|
||||
throw SshException("Failed to open Shell")
|
||||
}
|
||||
@@ -51,62 +125,580 @@ object SshClients {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一个命令
|
||||
*
|
||||
* @return first: exitCode , second: response
|
||||
*/
|
||||
fun execChannel(
|
||||
session: ClientSession,
|
||||
command: String
|
||||
): Pair<Int, String> {
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
val channel = session.createExecChannel(command)
|
||||
channel.out = baos
|
||||
|
||||
if (channel.open().verify(timeout).await(timeout)) {
|
||||
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout)
|
||||
}
|
||||
|
||||
IOUtils.closeQuietly(channel)
|
||||
|
||||
if (channel.exitStatus == null) {
|
||||
return Pair(-1, baos.toString())
|
||||
}
|
||||
|
||||
return Pair(channel.exitStatus, baos.toString())
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开一个会话
|
||||
*/
|
||||
fun openSession(host: Host, client: SshClient): ClientSession {
|
||||
val session = client.connect(host.username, host.host, host.port)
|
||||
.verify(timeout).session
|
||||
val h = hostManager.getHost(host.id) ?: host
|
||||
|
||||
// 如果没有跳板机直接连接
|
||||
if (h.options.jumpHosts.isEmpty()) {
|
||||
return doOpenSession(h, client)
|
||||
}
|
||||
|
||||
val jumpHosts = mutableListOf<Host>()
|
||||
val hosts = HostManager.getInstance().hosts().associateBy { it.id }
|
||||
for (jumpHostId in h.options.jumpHosts) {
|
||||
val e = hosts[jumpHostId]
|
||||
if (e == null) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Failed to find jump host: $jumpHostId")
|
||||
}
|
||||
continue
|
||||
}
|
||||
jumpHosts.add(e)
|
||||
}
|
||||
|
||||
// 最后一跳是目标机器
|
||||
jumpHosts.add(h)
|
||||
|
||||
val sessions = mutableListOf<ClientSession>()
|
||||
for (i in 0 until jumpHosts.size) {
|
||||
val currentHost = jumpHosts[i]
|
||||
sessions.add(doOpenSession(currentHost, client, i != 0))
|
||||
|
||||
// 如果有下一跳
|
||||
if (i < jumpHosts.size - 1) {
|
||||
val nextHost = jumpHosts[i + 1]
|
||||
// 通过 currentHost 的 Session 将远程端口映射到本地
|
||||
val address = sessions.last().startLocalPortForwarding(
|
||||
SshdSocketAddress.LOCALHOST_ADDRESS,
|
||||
SshdSocketAddress(nextHost.host, nextHost.port),
|
||||
)
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
|
||||
}
|
||||
// 映射完毕之后修改Host和端口
|
||||
jumpHosts[i + 1] =
|
||||
nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
return sessions.last()
|
||||
}
|
||||
|
||||
fun isMiddleware(session: ClientSession): Boolean {
|
||||
if (session is JGitClientSession) {
|
||||
if (session.hostConfigEntry.properties["Middleware"]?.toBoolean() == true) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param middleware 如果为 true 表示是跳板
|
||||
*/
|
||||
private fun doOpenSession(host: Host, client: SshClient, middleware: Boolean = false): ClientSession {
|
||||
val entry = HostConfigEntry()
|
||||
entry.port = host.port
|
||||
entry.username = host.username
|
||||
entry.hostName = host.host
|
||||
entry.setProperty("Middleware", middleware.toString())
|
||||
entry.setProperty("Host", host.id)
|
||||
|
||||
// 设置代理
|
||||
// configureProxy(entry, host, client)
|
||||
|
||||
// ssh-agent
|
||||
if (host.authentication.type == AuthenticationType.SSHAgent) {
|
||||
if (host.authentication.password.isNotBlank())
|
||||
entry.setProperty(IDENTITY_AGENT, host.authentication.password)
|
||||
else if (SystemInfo.isWindows)
|
||||
entry.setProperty(IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent)
|
||||
else
|
||||
entry.setProperty(IDENTITY_AGENT, UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
|
||||
}
|
||||
|
||||
val session = client.connect(entry).verify(timeout).session
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
session.addPasswordIdentity(host.authentication.password)
|
||||
} else if (host.authentication.type == AuthenticationType.PublicKey) {
|
||||
session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password)
|
||||
}
|
||||
if (!session.auth().verify(timeout).await(timeout)) {
|
||||
throw SshException("Authentication failed")
|
||||
|
||||
if (host.options.enableX11Forwarding) {
|
||||
val segments = host.options.x11Forwarding.split(":")
|
||||
if (segments.size == 2) {
|
||||
val x11Host = segments[0]
|
||||
val x11Port = segments[1].toIntOrNull()
|
||||
if (x11Port != null) {
|
||||
CoreModuleProperties.X11_BIND_HOST.set(session, x11Host)
|
||||
CoreModuleProperties.X11_BASE_PORT.set(session, 6000 + x11Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!session.auth().verify(timeout).await(timeout)) {
|
||||
throw SshException("Authentication failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e !is SshException || e.disconnectCode != SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e
|
||||
val owner = client.properties["owner"] as Window? ?: throw e
|
||||
val authentication = ask(host, owner) ?: throw e
|
||||
if (authentication.type == AuthenticationType.No) throw e
|
||||
return doOpenSession(host.copy(authentication = authentication), client)
|
||||
}
|
||||
|
||||
session.setAttribute(HOST_KEY, host)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
fun openTunneling(session: ClientSession, host: Host, tunneling: Tunneling): SshdSocketAddress {
|
||||
|
||||
val sshdSocketAddress = if (tunneling.type == TunnelingType.Local) {
|
||||
session.startLocalPortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Remote) {
|
||||
session.startRemotePortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Dynamic) {
|
||||
session.startDynamicPortForwarding(
|
||||
SshdSocketAddress(
|
||||
tunneling.sourceHost,
|
||||
tunneling.sourcePort
|
||||
)
|
||||
)
|
||||
} else {
|
||||
SshdSocketAddress.LOCALHOST_ADDRESS
|
||||
}
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info(
|
||||
"SSH [{}] started {} port forwarding. host: {} , port: {}",
|
||||
host.name,
|
||||
tunneling.name,
|
||||
sshdSocketAddress.hostName,
|
||||
sshdSocketAddress.port
|
||||
)
|
||||
}
|
||||
|
||||
return sshdSocketAddress
|
||||
}
|
||||
|
||||
fun openClient(host: Host, owner: Window): SshClient {
|
||||
val h = hostManager.getHost(host.id) ?: host
|
||||
val client = openClient(h)
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
|
||||
client.properties["owner"] = owner
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开一个客户端
|
||||
*/
|
||||
fun openClient(host: Host): SshClient {
|
||||
val builder = ClientBuilder.builder()
|
||||
builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE))
|
||||
.factory { JGitSshClient() }
|
||||
.factory { MyJGitSshClient() }
|
||||
|
||||
if (host.tunnelings.isEmpty()) {
|
||||
val keyExchangeFactories = ClientBuilder.setUpDefaultKeyExchanges(true).toMutableList()
|
||||
|
||||
// https://github.com/TermoraDev/termora/issues/123
|
||||
@Suppress("DEPRECATION")
|
||||
keyExchangeFactories.addAll(
|
||||
listOf(
|
||||
DHGClient.newFactory(BuiltinDHFactories.dhg1),
|
||||
DHGClient.newFactory(BuiltinDHFactories.dhg14),
|
||||
DHGClient.newFactory(BuiltinDHFactories.dhgex),
|
||||
)
|
||||
)
|
||||
builder.keyExchangeFactories(keyExchangeFactories)
|
||||
|
||||
val compressionFactories = ClientBuilder.setUpDefaultCompressionFactories(true).toMutableList()
|
||||
for (compression in listOf(
|
||||
BuiltinCompressions.none,
|
||||
BuiltinCompressions.zlib,
|
||||
BuiltinCompressions.delayedZlib
|
||||
)) {
|
||||
if (compressionFactories.contains(compression)) continue
|
||||
compressionFactories.add(compression)
|
||||
}
|
||||
builder.compressionFactories(compressionFactories)
|
||||
|
||||
val signatureFactories = ClientBuilder.setUpDefaultSignatureFactories(true).toMutableList()
|
||||
for (signature in BuiltinSignatures.entries) {
|
||||
if (signatureFactories.contains(signature)) continue
|
||||
signatureFactories.add(signature)
|
||||
}
|
||||
builder.signatureFactories(signatureFactories)
|
||||
|
||||
if (host.tunnelings.isEmpty() && host.options.jumpHosts.isEmpty()) {
|
||||
builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE)
|
||||
} else {
|
||||
builder.forwardingFilter(AcceptAllForwardingFilter.INSTANCE)
|
||||
}
|
||||
|
||||
val sshClient = builder.build() as JGitSshClient
|
||||
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, timeout)
|
||||
sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) }
|
||||
builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY)
|
||||
|
||||
if (host.proxy.type != ProxyType.No) {
|
||||
sshClient.setProxyDatabase {
|
||||
if (host.proxy.authenticationType == AuthenticationType.No) ProxyData(
|
||||
Proxy(
|
||||
Proxy.Type.SOCKS,
|
||||
InetSocketAddress(host.proxy.host, host.proxy.port)
|
||||
)
|
||||
)
|
||||
else
|
||||
ProxyData(
|
||||
Proxy(
|
||||
Proxy.Type.SOCKS,
|
||||
InetSocketAddress(host.proxy.host, host.proxy.port)
|
||||
),
|
||||
host.proxy.username,
|
||||
host.proxy.password.toCharArray(),
|
||||
)
|
||||
val channelFactories = mutableListOf<ChannelFactory>()
|
||||
channelFactories.addAll(ClientBuilder.DEFAULT_CHANNEL_FACTORIES)
|
||||
channelFactories.add(X11ChannelFactory.INSTANCE)
|
||||
builder.channelFactories(channelFactories)
|
||||
|
||||
val sshClient = builder.build() as JGitSshClient
|
||||
|
||||
// https://github.com/TermoraDev/termora/issues/180
|
||||
// JGit 会尝试读取本地的私钥或缓存的私钥
|
||||
sshClient.keyIdentityProvider = KeyIdentityProvider { mutableListOf() }
|
||||
|
||||
// 设置优先级
|
||||
if (host.authentication.type == AuthenticationType.PublicKey || host.authentication.type == AuthenticationType.SSHAgent) {
|
||||
if (host.authentication.type == AuthenticationType.SSHAgent) {
|
||||
// ssh-agent
|
||||
sshClient.agentFactory = JGitSshAgentFactory(ConnectorFactory.getDefault(), null)
|
||||
}
|
||||
CoreModuleProperties.PREFERRED_AUTHS.set(
|
||||
sshClient,
|
||||
listOf(
|
||||
UserAuthPasswordFactory.PUBLIC_KEY,
|
||||
UserAuthPasswordFactory.PASSWORD,
|
||||
UserAuthPasswordFactory.KB_INTERACTIVE
|
||||
).joinToString(",")
|
||||
)
|
||||
} else {
|
||||
CoreModuleProperties.PREFERRED_AUTHS.set(
|
||||
sshClient,
|
||||
listOf(
|
||||
UserAuthPasswordFactory.PASSWORD,
|
||||
UserAuthPasswordFactory.PUBLIC_KEY,
|
||||
UserAuthPasswordFactory.KB_INTERACTIVE
|
||||
).joinToString(",")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val heartbeatInterval = max(host.options.heartbeatInterval, 3)
|
||||
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong()))
|
||||
CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true)
|
||||
|
||||
sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) }
|
||||
|
||||
sshClient.start()
|
||||
return sshClient
|
||||
}
|
||||
|
||||
private fun ask(host: Host, owner: Window): Authentication? {
|
||||
val ref = AtomicReference<Authentication>(null)
|
||||
SwingUtilities.invokeAndWait {
|
||||
val dialog = RequestAuthenticationDialog(owner, host)
|
||||
dialog.setLocationRelativeTo(owner)
|
||||
val authentication = dialog.getAuthentication().apply { ref.set(this) }
|
||||
// save
|
||||
if (dialog.isRemembered()) {
|
||||
hostManager.addHost(
|
||||
host.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return ref.get()
|
||||
}
|
||||
|
||||
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
|
||||
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> ${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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.db.Database
|
||||
import app.termora.terminal.*
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import app.termora.tlog.TerminalLoggerDataListener
|
||||
import java.awt.Color
|
||||
import javax.swing.UIManager
|
||||
|
||||
class TerminalFactory {
|
||||
class TerminalFactory private constructor() : Disposable {
|
||||
private val terminals = mutableListOf<Terminal>()
|
||||
|
||||
companion object {
|
||||
val instance by lazy { TerminalFactory() }
|
||||
fun getInstance(): TerminalFactory {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(TerminalFactory::class) { TerminalFactory() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun createTerminal(): Terminal {
|
||||
val terminal = MyVisualTerminal()
|
||||
|
||||
// terminal logger listener
|
||||
terminal.getTerminalModel().addDataListener(TerminalLoggerDataListener(terminal))
|
||||
terminal.addTerminalListener(object : TerminalListener {
|
||||
override fun onClose(terminal: Terminal) {
|
||||
terminals.remove(terminal)
|
||||
}
|
||||
})
|
||||
|
||||
terminals.add(terminal)
|
||||
return terminal
|
||||
}
|
||||
@@ -23,7 +35,7 @@ class TerminalFactory {
|
||||
return terminals
|
||||
}
|
||||
|
||||
private inner class MyVisualTerminal : VisualTerminal() {
|
||||
open class MyVisualTerminal : VisualTerminal() {
|
||||
private val terminalModel by lazy { MyTerminalModel(this) }
|
||||
|
||||
override fun getTerminalModel(): TerminalModel {
|
||||
@@ -31,19 +43,24 @@ class TerminalFactory {
|
||||
}
|
||||
}
|
||||
|
||||
private inner class MyTerminalModel(terminal: Terminal) : TerminalModelImpl(terminal) {
|
||||
open class MyTerminalModel(terminal: Terminal) : TerminalModelImpl(terminal) {
|
||||
private val colorPalette by lazy { MyColorPalette(terminal) }
|
||||
private val config get() = Database.instance.terminal
|
||||
private val config get() = Database.getDatabase().terminal
|
||||
|
||||
init {
|
||||
setData(DataKey.CursorStyle, config.cursor)
|
||||
setData(TerminalPanel.Debug, config.debug)
|
||||
this.setData(DataKey.CursorStyle, config.cursor)
|
||||
this.setData(TerminalPanel.Debug, config.debug)
|
||||
}
|
||||
|
||||
override fun getColorPalette(): ColorPalette {
|
||||
return colorPalette
|
||||
}
|
||||
|
||||
override fun bell() {
|
||||
if (config.beep) {
|
||||
super.bell()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(key: DataKey<T>): T {
|
||||
@@ -90,17 +107,19 @@ class TerminalFactory {
|
||||
TerminalColor.Basic.SELECTION_FOREGROUND
|
||||
)
|
||||
|
||||
else -> DefaultColorTheme.instance.getColor(color)
|
||||
else -> DefaultColorTheme.getInstance().getColor(color)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private inner class MyColorPalette(terminal: Terminal) : ColorPaletteImpl(terminal) {
|
||||
class MyColorPalette(terminal: Terminal) : ColorPaletteImpl(terminal) {
|
||||
private val colorTheme by lazy { FlatLafColorTheme() }
|
||||
override fun getTheme(): ColorTheme {
|
||||
return colorTheme
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,37 +1,71 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.actions.MultipleAction
|
||||
import app.termora.highlight.KeywordHighlightPaintListener
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.Terminal
|
||||
import app.termora.terminal.panel.TerminalHyperlinkPaintListener
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import app.termora.terminal.panel.TerminalWriter
|
||||
import kotlinx.coroutines.*
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.awt.event.ComponentListener
|
||||
import java.nio.charset.Charset
|
||||
import java.util.*
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class TerminalPanelFactory {
|
||||
class TerminalPanelFactory : Disposable {
|
||||
private val terminalPanels = mutableListOf<TerminalPanel>()
|
||||
|
||||
companion object {
|
||||
val instance by lazy { TerminalPanelFactory() }
|
||||
|
||||
fun getInstance(): TerminalPanelFactory {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// repaint
|
||||
Painter.getInstance()
|
||||
}
|
||||
|
||||
|
||||
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
|
||||
val terminalPanel = TerminalPanel(terminal, ptyConnector)
|
||||
val writer = MyTerminalWriter(ptyConnector)
|
||||
val terminalPanel = TerminalPanel(terminal, writer)
|
||||
|
||||
// processDeviceStatusReport
|
||||
terminal.getTerminalModel().setData(DataKey.TerminalWriter, writer)
|
||||
|
||||
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
|
||||
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.instance)
|
||||
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.instance)
|
||||
terminalPanels.add(terminalPanel)
|
||||
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
|
||||
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
|
||||
|
||||
Disposer.register(terminalPanel, object : Disposable {
|
||||
override fun dispose() {
|
||||
removeTerminalPanel(terminalPanel)
|
||||
}
|
||||
})
|
||||
|
||||
addTerminalPanel(terminalPanel)
|
||||
return terminalPanel
|
||||
}
|
||||
|
||||
fun getTerminalPanels(): List<TerminalPanel> {
|
||||
return terminalPanels
|
||||
fun getTerminalPanels(): Array<TerminalPanel> {
|
||||
return terminalPanels.toTypedArray()
|
||||
}
|
||||
|
||||
fun repaintAll() {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
terminalPanels.forEach { it.repaintImmediate() }
|
||||
getTerminalPanels().forEach { it.repaintImmediate() }
|
||||
} else {
|
||||
SwingUtilities.invokeLater { repaintAll() }
|
||||
}
|
||||
@@ -45,4 +79,89 @@ class TerminalPanelFactory {
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeTerminalPanel(terminalPanel: TerminalPanel) {
|
||||
terminalPanels.remove(terminalPanel)
|
||||
}
|
||||
|
||||
private fun addTerminalPanel(terminalPanel: TerminalPanel) {
|
||||
terminalPanels.add(terminalPanel)
|
||||
}
|
||||
|
||||
private class Painter : Disposable {
|
||||
companion object {
|
||||
fun getInstance(): Painter {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(Painter::class) { Painter() }
|
||||
}
|
||||
}
|
||||
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
while (coroutineScope.isActive) {
|
||||
delay(500.milliseconds)
|
||||
SwingUtilities.invokeLater { TerminalPanelFactory.getInstance().repaintAll() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private class MyTerminalWriter(private val ptyConnector: PtyConnector) : TerminalWriter {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(MyTerminalWriter::class.java)
|
||||
}
|
||||
|
||||
private lateinit var evt: AnActionEvent
|
||||
|
||||
override fun onMounted(c: JComponent) {
|
||||
evt = AnActionEvent(c, StringUtils.EMPTY, EventObject(c))
|
||||
}
|
||||
|
||||
override fun write(request: TerminalWriter.WriteRequest) {
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("write: ${String(request.buffer, getCharset())}")
|
||||
}
|
||||
|
||||
val windowScope = evt.getData(DataProviders.WindowScope)
|
||||
if (windowScope == null) {
|
||||
ptyConnector.write(request.buffer)
|
||||
return
|
||||
}
|
||||
|
||||
val multipleAction = MultipleAction.getInstance(windowScope)
|
||||
if (!multipleAction.isSelected) {
|
||||
ptyConnector.write(request.buffer)
|
||||
return
|
||||
}
|
||||
|
||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager)
|
||||
if (terminalTabbedManager == null) {
|
||||
ptyConnector.write(request.buffer)
|
||||
return
|
||||
}
|
||||
|
||||
for (tab in terminalTabbedManager.getTerminalTabs()) {
|
||||
val writer = tab.getData(DataProviders.TerminalWriter) ?: continue
|
||||
if (writer is MyTerminalWriter) {
|
||||
writer.ptyConnector.write(request.buffer)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun resize(rows: Int, cols: Int) {
|
||||
ptyConnector.resize(rows, cols)
|
||||
}
|
||||
|
||||
override fun getCharset(): Charset {
|
||||
return ptyConnector.getCharset()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.DataProvider
|
||||
import java.beans.PropertyChangeListener
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
|
||||
interface TerminalTab : Disposable {
|
||||
interface TerminalTab : Disposable, DataProvider {
|
||||
|
||||
/**
|
||||
* 标题
|
||||
@@ -37,5 +38,20 @@ interface TerminalTab : Disposable {
|
||||
fun onLostFocus() {}
|
||||
fun onGrabFocus() {}
|
||||
|
||||
/**
|
||||
* @return 返回 false 则不可关闭
|
||||
*/
|
||||
fun canClose(): Boolean = true
|
||||
|
||||
/**
|
||||
* 返回 true 表示可以关闭
|
||||
*/
|
||||
fun willBeClose(): Boolean = true
|
||||
|
||||
/**
|
||||
* 是否可以克隆
|
||||
*/
|
||||
fun canClone(): Boolean = true
|
||||
|
||||
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.terminal.DataKey
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import javax.swing.*
|
||||
|
||||
class TerminalTabDialog(
|
||||
owner: Window,
|
||||
size: Dimension,
|
||||
private val terminalTab: TerminalTab
|
||||
) : DialogWrapper(null), Disposable {
|
||||
) : DialogWrapper(null), Disposable, DataProvider {
|
||||
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
|
||||
init {
|
||||
title = terminalTab.getTitle()
|
||||
@@ -19,11 +25,31 @@ class TerminalTabDialog(
|
||||
isAlwaysOnTop = false
|
||||
iconImages = owner.iconImages
|
||||
escapeDispose = false
|
||||
processGlobalKeymap = true
|
||||
|
||||
super.setSize(size)
|
||||
|
||||
init()
|
||||
|
||||
defaultCloseOperation = WindowConstants.DO_NOTHING_ON_CLOSE
|
||||
addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosing(e: WindowEvent) {
|
||||
if (terminalTab.canClose()) {
|
||||
SwingUtilities.invokeLater { doCancelAction() }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
|
||||
if (owner is DataProvider) {
|
||||
owner.getData(DataProviders.WindowScope)?.let {
|
||||
dataProviderSupport.addData(DataProviders.WindowScope, it)
|
||||
}
|
||||
}
|
||||
|
||||
dataProviderSupport.addData(DataProviders.TerminalTab, terminalTab)
|
||||
}
|
||||
|
||||
override fun createSouthPanel(): JComponent? {
|
||||
@@ -42,4 +68,8 @@ class TerminalTabDialog(
|
||||
super<DialogWrapper>.dispose()
|
||||
}
|
||||
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
return dataProviderSupport.getData(dataKey)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,34 +1,37 @@
|
||||
package app.termora
|
||||
|
||||
|
||||
import app.termora.actions.*
|
||||
import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhere
|
||||
import app.termora.findeverywhere.FindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhereResult
|
||||
import app.termora.terminal.DataKey
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.action.ActionContainerFactory
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.*
|
||||
import java.awt.event.AWTEventListener
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.beans.PropertyChangeEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
|
||||
import kotlin.math.min
|
||||
|
||||
class TerminalTabbed(
|
||||
private val toolbar: JToolBar,
|
||||
private val windowScope: WindowScope,
|
||||
private val termoraToolBar: TermoraToolBar,
|
||||
private val tabbedPane: FlatTabbedPane,
|
||||
) : JPanel(BorderLayout()), Disposable, TerminalTabbedManager {
|
||||
) : JPanel(BorderLayout()), Disposable, TerminalTabbedManager, DataProvider {
|
||||
private val tabs = mutableListOf<TerminalTab>()
|
||||
|
||||
private val customizeToolBarAWTEventListener = CustomizeToolBarAWTEventListener()
|
||||
private val toolbar = termoraToolBar.getJToolBar()
|
||||
private val actionManager = ActionManager.getInstance()
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val titleProperty = UUID.randomUUID().toSimpleString()
|
||||
private val iconListener = PropertyChangeListener { e ->
|
||||
val source = e.source
|
||||
if (e.propertyName == "icon" && source is TerminalTab) {
|
||||
@@ -50,40 +53,14 @@ class TerminalTabbed(
|
||||
tabbedPane.isTabsClosable = true
|
||||
tabbedPane.tabType = FlatTabbedPane.TabType.card
|
||||
|
||||
tabbedPane.styleMap = mapOf(
|
||||
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground")
|
||||
)
|
||||
|
||||
val actionManager = ActionManager.getInstance()
|
||||
val actionContainerFactory = ActionContainerFactory(actionManager)
|
||||
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))
|
||||
updateBtn.isVisible = updateBtn.isEnabled
|
||||
updateBtn.addChangeListener { updateBtn.isVisible = updateBtn.isEnabled }
|
||||
|
||||
toolbar.add(actionContainerFactory.createButton(object : AnAction(StringUtils.EMPTY, Icons.add) {
|
||||
override fun actionPerformed(e: ActionEvent?) {
|
||||
actionManager.getAction(Actions.FIND_EVERYWHERE)?.actionPerformed(e)
|
||||
}
|
||||
|
||||
override fun isEnabled(): Boolean {
|
||||
return actionManager.getAction(Actions.FIND_EVERYWHERE)?.isEnabled ?: false
|
||||
}
|
||||
}))
|
||||
toolbar.add(Box.createHorizontalStrut(UIManager.getInt("TabbedPane.tabHeight")))
|
||||
toolbar.add(Box.createHorizontalGlue())
|
||||
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.MACRO)))
|
||||
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEYWORD_HIGHLIGHT_EVERYWHERE)))
|
||||
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEY_MANAGER)))
|
||||
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.MULTIPLE)))
|
||||
toolbar.add(updateBtn)
|
||||
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.FIND_EVERYWHERE)))
|
||||
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.SETTING)))
|
||||
|
||||
|
||||
tabbedPane.trailingComponent = toolbar
|
||||
|
||||
add(tabbedPane, BorderLayout.CENTER)
|
||||
|
||||
windowScope.getOrCreate(TerminalTabbedManager::class) { this }
|
||||
|
||||
dataProviderSupport.addData(DataProviders.TerminalTabbed, this)
|
||||
dataProviderSupport.addData(DataProviders.TerminalTabbedManager, this)
|
||||
}
|
||||
|
||||
|
||||
@@ -92,56 +69,23 @@ class TerminalTabbed(
|
||||
tabbedPane.setTabCloseCallback { _, i -> removeTabAt(i, true) }
|
||||
|
||||
// 选中变动
|
||||
tabbedPane.addPropertyChangeListener("selectedIndex", object : PropertyChangeListener {
|
||||
override fun propertyChange(evt: PropertyChangeEvent) {
|
||||
val oldIndex = evt.oldValue as Int
|
||||
val newIndex = evt.newValue as Int
|
||||
if (oldIndex >= 0 && tabs.size > newIndex) {
|
||||
tabs[oldIndex].onLostFocus()
|
||||
}
|
||||
if (newIndex >= 0 && tabs.size > newIndex) {
|
||||
tabs[newIndex].onGrabFocus()
|
||||
}
|
||||
}
|
||||
})
|
||||
tabbedPane.addPropertyChangeListener("selectedIndex") { evt ->
|
||||
val oldIndex = evt.oldValue as Int
|
||||
val newIndex = evt.newValue as Int
|
||||
|
||||
// 选择变动
|
||||
tabbedPane.addChangeListener {
|
||||
if (tabbedPane.selectedIndex >= 0) {
|
||||
val c = tabbedPane.getComponentAt(tabbedPane.selectedIndex)
|
||||
c.requestFocusInWindow()
|
||||
if (oldIndex >= 0 && tabs.size > newIndex) {
|
||||
tabs[oldIndex].onLostFocus()
|
||||
}
|
||||
|
||||
tabbedPane.getComponentAt(newIndex).requestFocusInWindow()
|
||||
|
||||
if (newIndex >= 0 && tabs.size > newIndex) {
|
||||
tabs[newIndex].onGrabFocus()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// 快捷键
|
||||
val inputMap = getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
|
||||
for (i in KeyEvent.VK_1..KeyEvent.VK_9) {
|
||||
val tabIndex = i - KeyEvent.VK_1 + 1
|
||||
val actionKey = "select_$tabIndex"
|
||||
actionMap.put(actionKey, object : AnAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
tabbedPane.selectedIndex = if (i == KeyEvent.VK_9 || tabIndex > tabbedPane.tabCount) {
|
||||
tabbedPane.tabCount - 1
|
||||
} else {
|
||||
tabIndex - 1
|
||||
}
|
||||
}
|
||||
})
|
||||
inputMap.put(KeyStroke.getKeyStroke(i, toolkit.menuShortcutKeyMaskEx), actionKey)
|
||||
}
|
||||
|
||||
// 关闭 tab
|
||||
actionMap.put("closeTab", object : AnAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (tabbedPane.selectedIndex >= 0) {
|
||||
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabbedPane.selectedIndex)
|
||||
}
|
||||
}
|
||||
})
|
||||
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_W, toolkit.menuShortcutKeyMaskEx), "closeTab")
|
||||
|
||||
|
||||
// 右键菜单
|
||||
tabbedPane.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
@@ -162,7 +106,7 @@ class TerminalTabbed(
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e)) {
|
||||
val index = tabbedPane.indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
if (index > 0) {
|
||||
tabbedPane.getComponentAt(index).requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
@@ -170,49 +114,51 @@ class TerminalTabbed(
|
||||
})
|
||||
|
||||
// 注册全局搜索
|
||||
FindEverywhere.registerProvider(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider {
|
||||
override fun find(pattern: String): List<FindEverywhereResult> {
|
||||
val results = mutableListOf<FindEverywhereResult>()
|
||||
for (i in 0 until tabbedPane.tabCount) {
|
||||
if (tabbedPane.getComponentAt(i) is WelcomePanel) {
|
||||
continue
|
||||
}
|
||||
results.add(
|
||||
SwitchFindEverywhereResult(
|
||||
tabbedPane.getTitleAt(i),
|
||||
tabbedPane.getIconAt(i),
|
||||
tabbedPane.getComponentAt(i)
|
||||
FindEverywhereProvider.getFindEverywhereProviders(windowScope)
|
||||
.add(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider {
|
||||
override fun find(pattern: String): List<FindEverywhereResult> {
|
||||
val results = mutableListOf<FindEverywhereResult>()
|
||||
for (i in 0 until tabbedPane.tabCount) {
|
||||
val c = tabbedPane.getComponentAt(i)
|
||||
if (c is JComponent && c.getClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE) != null) {
|
||||
continue
|
||||
}
|
||||
results.add(
|
||||
SwitchFindEverywhereResult(
|
||||
tabbedPane.getTitleAt(i),
|
||||
tabbedPane.getIconAt(i),
|
||||
tabbedPane.getComponentAt(i)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
return results
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
override fun group(): String {
|
||||
return I18n.getString("termora.find-everywhere.groups.opened-hosts")
|
||||
}
|
||||
|
||||
override fun order(): Int {
|
||||
return Integer.MIN_VALUE + 1
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
// 打开 Host
|
||||
ActionManager.getInstance().addAction(Actions.OPEN_HOST, object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (e !is OpenHostActionEvent) {
|
||||
return
|
||||
override fun group(): String {
|
||||
return I18n.getString("termora.find-everywhere.groups.opened-hosts")
|
||||
}
|
||||
openHost(e.host)
|
||||
}
|
||||
})
|
||||
|
||||
override fun order(): Int {
|
||||
return Integer.MIN_VALUE + 1
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
// 监听全局事件
|
||||
toolkit.addAWTEventListener(customizeToolBarAWTEventListener, AWTEvent.MOUSE_EVENT_MASK)
|
||||
|
||||
}
|
||||
|
||||
private fun removeTabAt(index: Int, disposable: Boolean = true) {
|
||||
if (tabbedPane.isTabClosable(index)) {
|
||||
val tab = tabs[index]
|
||||
|
||||
if (disposable) {
|
||||
if (!tab.willBeClose()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tab.onLostFocus()
|
||||
tab.removePropertyChangeListener(iconListener)
|
||||
|
||||
@@ -225,80 +171,104 @@ class TerminalTabbed(
|
||||
// 新的获取到焦点
|
||||
tabs[tabbedPane.selectedIndex].onGrabFocus()
|
||||
|
||||
// 新的真正获取焦点
|
||||
tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow()
|
||||
|
||||
if (disposable) {
|
||||
Disposer.dispose(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun openHost(host: Host) {
|
||||
val tab = if (host.protocol == Protocol.SSH) SSHTerminalTab(host) else LocalTerminalTab(host)
|
||||
addTab(tab)
|
||||
tab.start()
|
||||
}
|
||||
|
||||
|
||||
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
|
||||
val c = tabbedPane.getComponentAt(tabIndex) as JComponent
|
||||
val tab = tabs[tabIndex]
|
||||
|
||||
val popupMenu = FlatPopupMenu()
|
||||
|
||||
// 修改名称
|
||||
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
|
||||
rename.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
if (index > 0) {
|
||||
val dialog = InputDialog(
|
||||
if (tabIndex > 0) {
|
||||
val text = OptionPane.showInputDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
title = rename.text,
|
||||
text = tabbedPane.getTitleAt(index),
|
||||
value = tabbedPane.getTitleAt(tabIndex)
|
||||
)
|
||||
val text = dialog.getText()
|
||||
if (!text.isNullOrBlank()) {
|
||||
tabbedPane.setTitleAt(index, text)
|
||||
tabbedPane.setTitleAt(tabIndex, text)
|
||||
c.putClientProperty(titleProperty, text)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 克隆
|
||||
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
|
||||
clone.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
if (index > 0) {
|
||||
val tab = tabs[index]
|
||||
if (tab is HostTerminalTab) {
|
||||
ActionManager.getInstance()
|
||||
.getAction(Actions.OPEN_HOST)
|
||||
.actionPerformed(OpenHostActionEvent(this, tab.host))
|
||||
}
|
||||
clone.addActionListener { evt ->
|
||||
if (tab is HostTerminalTab) {
|
||||
actionManager
|
||||
.getAction(OpenHostAction.OPEN_HOST)
|
||||
.actionPerformed(OpenHostActionEvent(this, tab.host, evt))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 编辑
|
||||
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
|
||||
edit.addActionListener(object : AnAction() {
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
if (tab is HostTerminalTab) {
|
||||
val host = hostManager.getHost(tab.host.id) ?: return
|
||||
val dialog = HostDialog(evt.window, host)
|
||||
dialog.setLocationRelativeTo(evt.window)
|
||||
dialog.isVisible = true
|
||||
hostManager.addHost(dialog.host ?: return)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 在新窗口中打开
|
||||
val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window"))
|
||||
openInNewWindow.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
if (index > 0) {
|
||||
val tab = tabs[index]
|
||||
removeTabAt(index, false)
|
||||
val dialog = TerminalTabDialog(
|
||||
owner = SwingUtilities.getWindowAncestor(this),
|
||||
terminalTab = tab,
|
||||
size = Dimension(min(size.width, 1280), min(size.height, 800))
|
||||
)
|
||||
Disposer.register(dialog, tab)
|
||||
Disposer.register(this, dialog)
|
||||
dialog.isVisible = true
|
||||
openInNewWindow.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val owner = evt.getData(DataProviders.TermoraFrame) ?: return
|
||||
if (tabIndex > 0) {
|
||||
val title = tabbedPane.getTitleAt(tabIndex)
|
||||
removeTabAt(tabIndex, false)
|
||||
val dialog = TerminalTabDialog(
|
||||
owner = owner,
|
||||
terminalTab = tab,
|
||||
size = Dimension(min(size.width, 1280), min(size.height, 800))
|
||||
)
|
||||
dialog.title = title
|
||||
Disposer.register(dialog, tab)
|
||||
Disposer.register(this@TerminalTabbed, dialog)
|
||||
dialog.isVisible = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (tab is HostTerminalTab) {
|
||||
val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST)
|
||||
if (openHostAction != null) {
|
||||
if (tab.host.protocol == Protocol.SSH || tab.host.protocol == Protocol.SFTPPty) {
|
||||
popupMenu.addSeparator()
|
||||
val sftpCommand = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
|
||||
sftpCommand.addActionListener { openSFTPPtyTab(tab, openHostAction, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 关闭
|
||||
val close = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close"))
|
||||
close.addActionListener {
|
||||
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabIndex)
|
||||
}
|
||||
|
||||
// 关闭其他标签页
|
||||
popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close-other-tabs")).addActionListener {
|
||||
for (i in tabbedPane.tabCount - 1 downTo tabIndex + 1) {
|
||||
tabbedPane.tabCloseCallback?.accept(tabbedPane, i)
|
||||
@@ -308,6 +278,7 @@ class TerminalTabbed(
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭所有标签页
|
||||
popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close-all-tabs")).addActionListener {
|
||||
for (i in 0 until tabbedPane.tabCount) {
|
||||
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabbedPane.tabCount - 1)
|
||||
@@ -315,19 +286,23 @@ class TerminalTabbed(
|
||||
}
|
||||
|
||||
|
||||
close.isEnabled = c !is WelcomePanel
|
||||
close.isEnabled = tab.canClose()
|
||||
rename.isEnabled = close.isEnabled
|
||||
clone.isEnabled = close.isEnabled
|
||||
edit.isEnabled = tab is HostTerminalTab && tab.host.id != "local"
|
||||
openInNewWindow.isEnabled = close.isEnabled
|
||||
|
||||
// 如果不允许克隆
|
||||
if (clone.isEnabled && !tab.canClone()) {
|
||||
clone.isEnabled = false
|
||||
}
|
||||
|
||||
if (close.isEnabled) {
|
||||
popupMenu.addSeparator()
|
||||
val reconnect = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.reconnect"))
|
||||
reconnect.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
if (index > 0) {
|
||||
tabs[index].reconnect()
|
||||
if (tabIndex > 0) {
|
||||
tabs[tabIndex].reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,21 +313,129 @@ class TerminalTabbed(
|
||||
}
|
||||
|
||||
|
||||
fun addTab(tab: TerminalTab) {
|
||||
tabbedPane.addTab(
|
||||
tab.getTitle(),
|
||||
private fun addTab(index: Int, tab: TerminalTab, selected: Boolean) {
|
||||
val c = tab.getJComponent()
|
||||
val title = (c.getClientProperty(titleProperty) ?: tab.getTitle()).toString()
|
||||
|
||||
tabbedPane.insertTab(
|
||||
title,
|
||||
tab.getIcon(),
|
||||
tab.getJComponent()
|
||||
c,
|
||||
StringUtils.EMPTY,
|
||||
index
|
||||
)
|
||||
|
||||
// 设置标题
|
||||
c.putClientProperty(titleProperty, title)
|
||||
// 监听 icons 变化
|
||||
tab.addPropertyChangeListener(iconListener)
|
||||
|
||||
tabs.add(tab)
|
||||
tabbedPane.selectedIndex = tabbedPane.tabCount - 1
|
||||
tabs.add(index, tab)
|
||||
|
||||
if (selected) {
|
||||
tabbedPane.selectedIndex = index
|
||||
}
|
||||
|
||||
tabbedPane.setTabClosable(index, tab.canClose())
|
||||
|
||||
Disposer.register(this, tab)
|
||||
}
|
||||
|
||||
override fun refreshTerminalTabs() {
|
||||
for (i in 0 until tabbedPane.tabCount) {
|
||||
tabbedPane.setTabClosable(i, tabs[i].canClose())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
|
||||
if (!SFTPPtyTerminalTab.canSupports) {
|
||||
OptionPane.showMessageDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.tabbed.contextmenu.sftp-not-install"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var host = tab.host
|
||||
|
||||
if (host.protocol == Protocol.SSH) {
|
||||
val envs = tab.host.options.envs().toMutableMap()
|
||||
val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel()
|
||||
?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY
|
||||
|
||||
if (currentDir.isNotBlank()) {
|
||||
envs["CurrentDir"] = currentDir
|
||||
}
|
||||
|
||||
host = host.copy(
|
||||
protocol = Protocol.SFTPPty, updateDate = System.currentTimeMillis(),
|
||||
options = host.options.copy(env = envs.toPropertiesString())
|
||||
)
|
||||
}
|
||||
|
||||
openHostAction.actionPerformed(OpenHostActionEvent(this, host, evt))
|
||||
}
|
||||
|
||||
/**
|
||||
* 对着 ToolBar 右键
|
||||
*/
|
||||
private inner class CustomizeToolBarAWTEventListener : AWTEventListener, Disposable {
|
||||
init {
|
||||
Disposer.register(this@TerminalTabbed, this)
|
||||
}
|
||||
|
||||
override fun eventDispatched(event: AWTEvent) {
|
||||
if (event !is MouseEvent || event.id != MouseEvent.MOUSE_CLICKED || !SwingUtilities.isRightMouseButton(event)) return
|
||||
// 如果 ToolBar 没有显示
|
||||
if (!toolbar.isShowing) return
|
||||
// 如果不是作用于在 ToolBar 上面
|
||||
if (!Rectangle(toolbar.locationOnScreen, toolbar.size).contains(event.locationOnScreen)) return
|
||||
|
||||
// 显示右键菜单
|
||||
showContextMenu(event)
|
||||
}
|
||||
|
||||
private fun showContextMenu(event: MouseEvent) {
|
||||
val popupMenu = FlatPopupMenu()
|
||||
popupMenu.add(I18n.getString("termora.toolbar.customize-toolbar")).addActionListener {
|
||||
val dialog = CustomizeToolBarDialog(
|
||||
SwingUtilities.getWindowAncestor(this@TerminalTabbed),
|
||||
termoraToolBar
|
||||
)
|
||||
if (dialog.open()) {
|
||||
termoraToolBar.rebuild()
|
||||
}
|
||||
}
|
||||
popupMenu.show(event.component, event.x, event.y)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
toolkit.removeAWTEventListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
/*private inner class CustomizeToolBarDialog(owner: Window) : DialogWrapper(owner) {
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
|
||||
isModal = true
|
||||
title = I18n.getString("termora.setting")
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val model = DefaultListModel<String>()
|
||||
val checkBoxList = CheckBoxList(model)
|
||||
checkBoxList.fixedCellHeight = UIManager.getInt("Tree.rowHeight")
|
||||
model.addElement("Test")
|
||||
return checkBoxList
|
||||
}
|
||||
|
||||
}*/
|
||||
|
||||
private inner class SwitchFindEverywhereResult(
|
||||
private val title: String,
|
||||
private val icon: Icon?,
|
||||
@@ -383,8 +466,12 @@ class TerminalTabbed(
|
||||
override fun dispose() {
|
||||
}
|
||||
|
||||
override fun addTerminalTab(tab: TerminalTab) {
|
||||
addTab(tab)
|
||||
override fun addTerminalTab(tab: TerminalTab, selected: Boolean) {
|
||||
addTab(tabs.size, tab, selected)
|
||||
}
|
||||
|
||||
override fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean) {
|
||||
addTab(index, tab, selected)
|
||||
}
|
||||
|
||||
override fun getSelectedTerminalTab(): TerminalTab? {
|
||||
@@ -400,5 +487,33 @@ class TerminalTabbed(
|
||||
return tabs
|
||||
}
|
||||
|
||||
override fun setSelectedTerminalTab(tab: TerminalTab) {
|
||||
for (index in tabs.indices) {
|
||||
if (tabs[index] == tab) {
|
||||
tabbedPane.selectedIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun closeTerminalTab(tab: TerminalTab, disposable: Boolean) {
|
||||
for (i in 0 until tabs.size) {
|
||||
if (tabs[i] == tab) {
|
||||
removeTabAt(i, disposable)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == DataProviders.TerminalTab) {
|
||||
dataProviderSupport.removeData(dataKey)
|
||||
if (tabbedPane.selectedIndex >= 0 && tabs.size > tabbedPane.selectedIndex) {
|
||||
dataProviderSupport.addData(dataKey, tabs[tabbedPane.selectedIndex])
|
||||
}
|
||||
}
|
||||
return dataProviderSupport.getData(dataKey)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package app.termora
|
||||
|
||||
interface TerminalTabbedManager {
|
||||
fun addTerminalTab(tab: TerminalTab)
|
||||
fun addTerminalTab(tab: TerminalTab, selected: Boolean = true)
|
||||
fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean = true)
|
||||
fun getSelectedTerminalTab(): TerminalTab?
|
||||
fun getTerminalTabs(): List<TerminalTab>
|
||||
fun setSelectedTerminalTab(tab: TerminalTab)
|
||||
fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true)
|
||||
fun refreshTerminalTabs()
|
||||
}
|
||||
@@ -1,279 +1,195 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.findeverywhere.FindEverywhere
|
||||
import app.termora.highlight.KeywordHighlightDialog
|
||||
import app.termora.keymgr.KeyManagerDialog
|
||||
import app.termora.macro.MacroAction
|
||||
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.sftp.SFTPTab
|
||||
import app.termora.terminal.DataKey
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.FlatDesktop
|
||||
import com.formdev.flatlaf.ui.FlatRootPaneUI
|
||||
import com.formdev.flatlaf.ui.FlatTitlePane
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jetbrains.JBR
|
||||
import io.github.g00fy2.versioncompare.Version
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.JXEditorPane
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Dimension
|
||||
import java.awt.Insets
|
||||
import java.awt.KeyEventDispatcher
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.event.*
|
||||
import java.net.URI
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.event.MouseListener
|
||||
import java.awt.event.MouseMotionListener
|
||||
import java.util.*
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.*
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JFrame
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.SwingUtilities.isEventDispatchThread
|
||||
import javax.swing.event.HyperlinkEvent
|
||||
import kotlin.concurrent.fixedRateTimer
|
||||
import kotlin.math.max
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import javax.swing.UIManager
|
||||
|
||||
|
||||
fun assertEventDispatchThread() {
|
||||
if (!isEventDispatchThread()) throw WrongThreadException("AWT EventQueue")
|
||||
}
|
||||
|
||||
|
||||
class TermoraFrame : JFrame() {
|
||||
class TermoraFrame : JFrame(), DataProvider {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TermoraFrame::class.java)
|
||||
}
|
||||
|
||||
private val toolbar = JToolBar()
|
||||
private val id = UUID.randomUUID().toString()
|
||||
private val windowScope = ApplicationScope.forWindowScope(this)
|
||||
private val tabbedPane = MyTabbedPane()
|
||||
private lateinit var terminalTabbed: TerminalTabbed
|
||||
private val disposable = Disposer.newDisposable()
|
||||
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
|
||||
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
|
||||
private val updaterManager get() = UpdaterManager.instance
|
||||
private val toolbar = TermoraToolBar(windowScope, this, tabbedPane)
|
||||
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val welcomePanel = WelcomePanel(windowScope)
|
||||
private val sftp get() = Database.getDatabase().sftp
|
||||
private var notifyListeners = emptyArray<NotifyListener>()
|
||||
|
||||
private val preferencesHandler = object : Runnable {
|
||||
override fun run() {
|
||||
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow ?: this@TermoraFrame
|
||||
if (owner != this@TermoraFrame) {
|
||||
return
|
||||
}
|
||||
|
||||
val that = this
|
||||
FlatDesktop.setPreferencesHandler {}
|
||||
val dialog = SettingsDialog(owner)
|
||||
dialog.addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
FlatDesktop.setPreferencesHandler(that)
|
||||
}
|
||||
})
|
||||
dialog.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
initActions()
|
||||
initView()
|
||||
initEvents()
|
||||
initDesktopHandler()
|
||||
scheduleUpdate()
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
if (SystemInfo.isLinux) {
|
||||
val mouseAdapter = object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
getMouseHandler()?.mouseClicked(e)
|
||||
}
|
||||
|
||||
// 监听窗口大小变动,然后修改边距避开控制按钮
|
||||
addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
if (SystemInfo.isMacOS) {
|
||||
val left = titleBar.leftInset.toInt()
|
||||
if (tabbedPane.tabAreaInsets.left != left) {
|
||||
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0)
|
||||
}
|
||||
} else if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
getMouseHandler()?.mousePressed(e)
|
||||
}
|
||||
|
||||
val right = titleBar.rightInset.toInt()
|
||||
override fun mouseDragged(e: MouseEvent) {
|
||||
getMouseMotionListener()?.mouseDragged(
|
||||
MouseEvent(
|
||||
e.component,
|
||||
e.id,
|
||||
e.`when`,
|
||||
e.modifiersEx,
|
||||
e.x,
|
||||
e.y,
|
||||
e.clickCount,
|
||||
e.isPopupTrigger,
|
||||
e.button
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
for (i in 0 until toolbar.componentCount) {
|
||||
val c = toolbar.getComponent(i)
|
||||
if (c.name == "spacing") {
|
||||
if (c.width == right) {
|
||||
private fun getMouseHandler(): MouseListener? {
|
||||
return getHandler() as? MouseListener
|
||||
}
|
||||
|
||||
private fun getMouseMotionListener(): MouseMotionListener? {
|
||||
return getHandler() as? MouseMotionListener
|
||||
}
|
||||
|
||||
private fun getHandler(): Any? {
|
||||
val titlePane = getTitlePane() ?: return null
|
||||
val handlerField = titlePane.javaClass.getDeclaredField("handler") ?: return null
|
||||
handlerField.isAccessible = true
|
||||
return handlerField.get(titlePane)
|
||||
}
|
||||
|
||||
private fun getTitlePane(): FlatTitlePane? {
|
||||
val ui = rootPane.ui as? FlatRootPaneUI ?: return null
|
||||
val titlePaneField = ui.javaClass.getDeclaredField("titlePane")
|
||||
titlePaneField.isAccessible = true
|
||||
return titlePaneField.get(ui) as? FlatTitlePane
|
||||
}
|
||||
}
|
||||
toolbar.getJToolBar().addMouseListener(mouseAdapter)
|
||||
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
|
||||
}
|
||||
|
||||
/// force hit
|
||||
if (SystemInfo.isMacOS) {
|
||||
if (JBR.isWindowDecorationsSupported()) {
|
||||
val height = UIManager.getInt("TabbedPane.tabHeight") + tabbedPane.tabAreaInsets.top
|
||||
val customTitleBar = JBR.getWindowDecorations().createCustomTitleBar()
|
||||
customTitleBar.height = height.toFloat()
|
||||
|
||||
val mouseAdapter = object : MouseAdapter() {
|
||||
|
||||
private fun hit(e: MouseEvent) {
|
||||
if (e.source == tabbedPane) {
|
||||
val index = tabbedPane.indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
return
|
||||
}
|
||||
toolbar.remove(i)
|
||||
break
|
||||
}
|
||||
customTitleBar.forceHitTest(false)
|
||||
}
|
||||
|
||||
if (right > 0) {
|
||||
val spacing = Box.createHorizontalStrut(right)
|
||||
spacing.name = "spacing"
|
||||
toolbar.add(spacing)
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
hit(e)
|
||||
}
|
||||
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
hit(e)
|
||||
}
|
||||
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
hit(e)
|
||||
}
|
||||
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
hit(e)
|
||||
}
|
||||
|
||||
override fun mouseDragged(e: MouseEvent) {
|
||||
hit(e)
|
||||
}
|
||||
|
||||
override fun mouseMoved(e: MouseEvent) {
|
||||
hit(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
forceHitTest()
|
||||
terminalTabbed.addMouseListener(mouseAdapter)
|
||||
terminalTabbed.addMouseMotionListener(mouseAdapter)
|
||||
|
||||
// macos 需要判断是否全部删除
|
||||
// 当 Tab 为 0 的时候,需要加一个边距,避开控制栏
|
||||
if (SystemInfo.isMacOS && isWindowDecorationsSupported) {
|
||||
tabbedPane.addChangeListener {
|
||||
tabbedPane.leadingComponent = if (tabbedPane.tabCount == 0) {
|
||||
Box.createHorizontalStrut(titleBar.leftInset.toInt())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
tabbedPane.addMouseListener(mouseAdapter)
|
||||
tabbedPane.addMouseMotionListener(mouseAdapter)
|
||||
|
||||
toolbar.getJToolBar().addMouseListener(mouseAdapter)
|
||||
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
|
||||
|
||||
JBR.getWindowDecorations().setCustomTitleBar(this, customTitleBar)
|
||||
}
|
||||
}
|
||||
|
||||
// global shortcuts
|
||||
rootPane.actionMap.put(Actions.FIND_EVERYWHERE, ActionManager.getInstance().getAction(Actions.FIND_EVERYWHERE))
|
||||
rootPane.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
|
||||
.put(KeyStroke.getKeyStroke(KeyEvent.VK_T, toolkit.menuShortcutKeyMaskEx), Actions.FIND_EVERYWHERE)
|
||||
|
||||
// double shift
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(object : KeyEventDispatcher {
|
||||
private var lastTime = -1L
|
||||
|
||||
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
|
||||
if (e.keyCode == KeyEvent.VK_SHIFT && e.id == KeyEvent.KEY_PRESSED) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - 250 < lastTime) {
|
||||
ActionManager.getInstance().getAction(Actions.FIND_EVERYWHERE)
|
||||
.actionPerformed(ActionEvent(rootPane, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))
|
||||
}
|
||||
lastTime = now
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// 监听主题变化 需要动态修改控制栏颜色
|
||||
if (SystemInfo.isWindows && isWindowDecorationsSupported) {
|
||||
ThemeManager.instance.addThemeChangeListener(object : ThemeChangeListener {
|
||||
override fun onChanged() {
|
||||
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// dispose
|
||||
addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
|
||||
Disposer.dispose(disposable)
|
||||
Disposer.dispose(ApplicationDisposable.instance)
|
||||
|
||||
try {
|
||||
Disposer.getTree().assertIsEmpty(true)
|
||||
} catch (e: Exception) {
|
||||
log.error(e.message)
|
||||
}
|
||||
exitProcess(0)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun initActions() {
|
||||
// SETTING
|
||||
ActionManager.getInstance().addAction(Actions.SETTING, object : AnAction(
|
||||
I18n.getString("termora.setting"),
|
||||
Icons.settings
|
||||
) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
preferencesHandler.run()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// MULTIPLE
|
||||
ActionManager.getInstance().addAction(Actions.MULTIPLE, object : AnAction(
|
||||
I18n.getString("termora.tools.multiple"),
|
||||
Icons.vcs
|
||||
) {
|
||||
init {
|
||||
setStateAction()
|
||||
}
|
||||
|
||||
override fun actionPerformed(evt: ActionEvent) {
|
||||
TerminalPanelFactory.instance.repaintAll()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Keyword Highlight
|
||||
ActionManager.getInstance().addAction(Actions.KEYWORD_HIGHLIGHT_EVERYWHERE, object : AnAction(
|
||||
I18n.getString("termora.highlight"),
|
||||
Icons.edit
|
||||
) {
|
||||
override fun actionPerformed(evt: ActionEvent) {
|
||||
KeywordHighlightDialog(this@TermoraFrame).isVisible = true
|
||||
}
|
||||
})
|
||||
|
||||
// app update
|
||||
ActionManager.getInstance().addAction(Actions.APP_UPDATE, object :
|
||||
AnAction(
|
||||
StringUtils.EMPTY,
|
||||
Icons.ideUpdate
|
||||
) {
|
||||
init {
|
||||
isEnabled = false
|
||||
}
|
||||
|
||||
override fun actionPerformed(evt: ActionEvent) {
|
||||
showUpdateDialog()
|
||||
}
|
||||
})
|
||||
|
||||
// macro
|
||||
ActionManager.getInstance().addAction(Actions.MACRO, MacroAction())
|
||||
|
||||
// FIND_EVERYWHERE
|
||||
ActionManager.getInstance().addAction(Actions.FIND_EVERYWHERE, object : AnAction(
|
||||
I18n.getString("termora.find-everywhere"),
|
||||
Icons.find
|
||||
) {
|
||||
override fun actionPerformed(evt: ActionEvent) {
|
||||
if (this.isEnabled) {
|
||||
val focusWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusedWindow
|
||||
val frame = this@TermoraFrame
|
||||
if (focusWindow == frame) {
|
||||
FindEverywhere(frame).isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Key manager
|
||||
ActionManager.getInstance().addAction(Actions.KEY_MANAGER, object : AnAction(
|
||||
I18n.getString("termora.keymgr.title"),
|
||||
Icons.greyKey
|
||||
) {
|
||||
override fun actionPerformed(evt: ActionEvent) {
|
||||
if (this.isEnabled) {
|
||||
KeyManagerDialog(this@TermoraFrame).isVisible = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
if (isWindowDecorationsSupported) {
|
||||
titleBar.height = UIManager.getInt("TabbedPane.tabHeight").toFloat()
|
||||
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
|
||||
JBR.getWindowDecorations().setCustomTitleBar(this, titleBar)
|
||||
|
||||
// macOS 要避开左边的控制栏
|
||||
if (SystemInfo.isMacOS) {
|
||||
tabbedPane.tabAreaInsets = Insets(0, 76, 0, 0)
|
||||
} else if (SystemInfo.isWindows) {
|
||||
// Windows 10 会有1像素误差
|
||||
tabbedPane.tabAreaInsets = Insets(if (SystemInfo.isWindows_11_orLater) 1 else 2, 2, 0, 0)
|
||||
} else if (SystemInfo.isLinux) {
|
||||
tabbedPane.tabAreaInsets = Insets(1, 2, 0, 0)
|
||||
}
|
||||
|
||||
if (SystemInfo.isLinux) {
|
||||
val height = UIManager.getInt("TabbedPane.tabHeight") + tabbedPane.tabAreaInsets.top
|
||||
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
rootPane.putClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT, true)
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, UIManager.getInt("TabbedPane.tabHeight"))
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICON, false)
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, false)
|
||||
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, height)
|
||||
} else if (SystemInfo.isMacOS) {
|
||||
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
|
||||
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
|
||||
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
|
||||
rootPane.putClientProperty(
|
||||
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING,
|
||||
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING_MEDIUM
|
||||
)
|
||||
}
|
||||
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
@@ -286,170 +202,74 @@ class TermoraFrame : JFrame() {
|
||||
}
|
||||
|
||||
minimumSize = Dimension(640, 400)
|
||||
terminalTabbed = TerminalTabbed(toolbar, tabbedPane).apply {
|
||||
Application.registerService(TerminalTabbedManager::class, this)
|
||||
}
|
||||
terminalTabbed.addTab(WelcomePanel())
|
||||
terminalTabbed.addTerminalTab(welcomePanel)
|
||||
|
||||
// macOS 要避开左边的控制栏
|
||||
if (SystemInfo.isMacOS) {
|
||||
val left = max(titleBar.leftInset.toInt(), 76)
|
||||
if (tabbedPane.tabCount == 0) {
|
||||
tabbedPane.leadingComponent = Box.createHorizontalStrut(left)
|
||||
} else {
|
||||
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0)
|
||||
// 下一次事件循环检测是否固定 SFTP
|
||||
if (sftp.pinTab) {
|
||||
SwingUtilities.invokeLater {
|
||||
terminalTabbed.addTerminalTab(SFTPTab(), false)
|
||||
}
|
||||
}
|
||||
|
||||
Disposer.register(disposable, terminalTabbed)
|
||||
add(terminalTabbed)
|
||||
val glassPane = GlassPane()
|
||||
rootPane.glassPane = glassPane
|
||||
glassPane.isOpaque = false
|
||||
glassPane.isVisible = true
|
||||
|
||||
|
||||
Disposer.register(windowScope, terminalTabbed)
|
||||
add(terminalTabbed, BorderLayout.CENTER)
|
||||
|
||||
dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane)
|
||||
dataProviderSupport.addData(DataProviders.TermoraFrame, this)
|
||||
dataProviderSupport.addData(DataProviders.WindowScope, windowScope)
|
||||
}
|
||||
|
||||
private fun showUpdateDialog() {
|
||||
val lastVersion = updaterManager.lastVersion
|
||||
val editorPane = JXEditorPane()
|
||||
editorPane.contentType = "text/html"
|
||||
editorPane.text = lastVersion.htmlBody
|
||||
editorPane.isEditable = false
|
||||
editorPane.addHyperlinkListener {
|
||||
if (it.eventType == HyperlinkEvent.EventType.ACTIVATED) {
|
||||
Application.browse(it.url.toURI())
|
||||
}
|
||||
}
|
||||
editorPane.background = DynamicColor("window")
|
||||
val scrollPane = JScrollPane(editorPane)
|
||||
scrollPane.border = BorderFactory.createEmptyBorder()
|
||||
scrollPane.preferredSize = Dimension(
|
||||
UIManager.getInt("Dialog.width") - 100,
|
||||
UIManager.getInt("Dialog.height") - 100
|
||||
)
|
||||
|
||||
val option = OptionPane.showConfirmDialog(
|
||||
this,
|
||||
scrollPane,
|
||||
title = I18n.getString("termora.update.title"),
|
||||
messageType = JOptionPane.PLAIN_MESSAGE,
|
||||
optionType = JOptionPane.YES_NO_CANCEL_OPTION,
|
||||
options = arrayOf(
|
||||
I18n.getString("termora.update.update"),
|
||||
I18n.getString("termora.update.ignore"),
|
||||
I18n.getString("termora.cancel")
|
||||
),
|
||||
initialValue = I18n.getString("termora.update.update")
|
||||
)
|
||||
if (option == JOptionPane.CANCEL_OPTION) {
|
||||
return
|
||||
} else if (option == JOptionPane.NO_OPTION) {
|
||||
ActionManager.getInstance().setEnabled(Actions.APP_UPDATE, false)
|
||||
updaterManager.ignore(updaterManager.lastVersion.version)
|
||||
} else if (option == JOptionPane.YES_OPTION) {
|
||||
ActionManager.getInstance()
|
||||
.setEnabled(Actions.APP_UPDATE, false)
|
||||
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${lastVersion.version}"))
|
||||
}
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
return dataProviderSupport.getData(dataKey)
|
||||
?: terminalTabbed.getData(dataKey)
|
||||
?: welcomePanel.getData(dataKey)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun scheduleUpdate() {
|
||||
fixedRateTimer(
|
||||
name = "check-update-timer",
|
||||
initialDelay = 3.minutes.inWholeMilliseconds,
|
||||
period = 5.hours.inWholeMilliseconds, daemon = true
|
||||
) {
|
||||
GlobalScope.launch(Dispatchers.IO) { supervisorScope { launch { checkUpdate() } } }
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as TermoraFrame
|
||||
|
||||
return id == other.id
|
||||
}
|
||||
|
||||
private suspend fun checkUpdate() {
|
||||
|
||||
val latestVersion = updaterManager.fetchLatestVersion()
|
||||
if (latestVersion.isSelf) {
|
||||
return
|
||||
}
|
||||
|
||||
val newVersion = Version(latestVersion.version)
|
||||
val version = Version(Application.getVersion())
|
||||
if (newVersion <= version) {
|
||||
return
|
||||
}
|
||||
|
||||
if (updaterManager.isIgnored(latestVersion.version)) {
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
ActionManager.getInstance()
|
||||
.setEnabled(Actions.APP_UPDATE, true)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return id.hashCode()
|
||||
}
|
||||
|
||||
private fun forceHitTest() {
|
||||
val mouseAdapter = object : MouseAdapter() {
|
||||
|
||||
private fun hit(e: MouseEvent) {
|
||||
if (e.source == tabbedPane) {
|
||||
val index = tabbedPane.indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
if (e.id == MouseEvent.MOUSE_CLICKED) {
|
||||
tabbedPane.getComponentAt(index)?.requestFocusInWindow()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
titleBar.forceHitTest(false)
|
||||
}
|
||||
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
hit(e)
|
||||
}
|
||||
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
if (e.source == toolbar) {
|
||||
if (!isWindowDecorationsSupported && SwingUtilities.isLeftMouseButton(e)) {
|
||||
if (JBR.isWindowMoveSupported()) {
|
||||
JBR.getWindowMove().startMovingTogetherWithMouse(this@TermoraFrame, e.button)
|
||||
}
|
||||
}
|
||||
}
|
||||
hit(e)
|
||||
}
|
||||
|
||||
override fun mouseReleased(e: MouseEvent) {
|
||||
hit(e)
|
||||
}
|
||||
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
hit(e)
|
||||
}
|
||||
|
||||
override fun mouseDragged(e: MouseEvent) {
|
||||
|
||||
hit(e)
|
||||
}
|
||||
|
||||
override fun mouseMoved(e: MouseEvent) {
|
||||
hit(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
terminalTabbed.addMouseListener(mouseAdapter)
|
||||
terminalTabbed.addMouseMotionListener(mouseAdapter)
|
||||
|
||||
tabbedPane.addMouseListener(mouseAdapter)
|
||||
tabbedPane.addMouseMotionListener(mouseAdapter)
|
||||
|
||||
toolbar.addMouseListener(mouseAdapter)
|
||||
toolbar.addMouseMotionListener(mouseAdapter)
|
||||
fun addNotifyListener(listener: NotifyListener) {
|
||||
notifyListeners += listener
|
||||
}
|
||||
|
||||
private fun initDesktopHandler() {
|
||||
if (SystemInfo.isMacOS) {
|
||||
FlatDesktop.setPreferencesHandler {
|
||||
preferencesHandler.run()
|
||||
}
|
||||
fun removeNotifyListener(listener: NotifyListener) {
|
||||
notifyListeners = ArrayUtils.removeElements(notifyListeners, listener)
|
||||
}
|
||||
|
||||
override fun addNotify() {
|
||||
super.addNotify()
|
||||
notifyListeners.forEach { it.addNotify() }
|
||||
}
|
||||
|
||||
|
||||
private class GlassPane : JComponent() {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
229
src/main/kotlin/app/termora/TermoraFrameManager.kt
Normal file
@@ -0,0 +1,229 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.native.osx.NativeMacLibrary
|
||||
import com.formdev.flatlaf.ui.FlatNativeWindowsLibrary
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.platform.win32.User32
|
||||
import com.sun.jna.platform.win32.WinDef
|
||||
import com.sun.jna.platform.win32.WinUser.*
|
||||
import de.jangassen.jfa.ThreadUtils
|
||||
import de.jangassen.jfa.foundation.Foundation
|
||||
import de.jangassen.jfa.foundation.ID
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Frame
|
||||
import java.awt.Window
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.JFrame
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.UIManager
|
||||
import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
|
||||
import kotlin.math.max
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
|
||||
class TermoraFrameManager : Disposable {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TermoraFrameManager::class.java)
|
||||
|
||||
fun getInstance(): TermoraFrameManager {
|
||||
return ApplicationScope.forApplicationScope()
|
||||
.getOrCreate(TermoraFrameManager::class) { TermoraFrameManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val frames = mutableListOf<TermoraFrame>()
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val isDisposed = AtomicBoolean(false)
|
||||
private val isBackgroundRunning get() = Database.getDatabase().appearance.backgroundRunning
|
||||
|
||||
fun createWindow(): TermoraFrame {
|
||||
val frame = TermoraFrame().apply { registerCloseCallback(this) }
|
||||
frame.title = if (SystemInfo.isLinux) null else Application.getName()
|
||||
frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
|
||||
|
||||
val rectangle = getFrameRectangle() ?: FrameRectangle(-1, -1, 1280, 800, 0)
|
||||
if (rectangle.isMaximized) {
|
||||
frame.setSize(1280, 800)
|
||||
frame.setLocationRelativeTo(null)
|
||||
frame.extendedState = rectangle.s
|
||||
} else {
|
||||
// 控制最小
|
||||
frame.setSize(
|
||||
max(rectangle.w, UIManager.getInt("Dialog.width") - 150),
|
||||
max(rectangle.h, UIManager.getInt("Dialog.height") - 100)
|
||||
)
|
||||
if (rectangle.x == -1 && rectangle.y == -1) {
|
||||
frame.setLocationRelativeTo(null)
|
||||
} else {
|
||||
frame.setLocation(max(rectangle.x, 0), max(rectangle.y, 0))
|
||||
}
|
||||
}
|
||||
|
||||
frame.addNotifyListener(object : NotifyListener {
|
||||
private val opacity get() = Database.getDatabase().appearance.opacity
|
||||
override fun addNotify() {
|
||||
val opacity = this.opacity
|
||||
if (opacity >= 1.0) return
|
||||
setOpacity(frame, opacity)
|
||||
}
|
||||
})
|
||||
|
||||
return frame.apply { frames.add(this) }
|
||||
}
|
||||
|
||||
fun getWindows(): Array<TermoraFrame> {
|
||||
return frames.toTypedArray()
|
||||
}
|
||||
|
||||
|
||||
private fun registerCloseCallback(window: TermoraFrame) {
|
||||
val manager = this
|
||||
window.addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
|
||||
// 存储位置信息
|
||||
saveFrameRectangle(window)
|
||||
|
||||
// 删除
|
||||
frames.remove(window)
|
||||
|
||||
// dispose windowScope
|
||||
val windowScope = ApplicationScope.forWindowScope(e.window)
|
||||
Disposer.disposeChildren(windowScope, null)
|
||||
Disposer.dispose(windowScope)
|
||||
|
||||
val windowScopes = ApplicationScope.windowScopes()
|
||||
if (windowScopes.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经没有 Window 域了,那么就可以退出程序了
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
Disposer.dispose(manager)
|
||||
} else if (SystemInfo.isMacOS) {
|
||||
// 如果 macOS 开启了后台运行,那么尽管所有窗口都没了,也不会退出
|
||||
if (isBackgroundRunning) {
|
||||
return
|
||||
}
|
||||
Disposer.dispose(manager)
|
||||
}
|
||||
}
|
||||
|
||||
override fun windowClosing(e: WindowEvent) {
|
||||
if (ApplicationScope.windowScopes().size != 1) {
|
||||
window.dispose()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果 Windows 开启了后台运行,那么最小化
|
||||
if (SystemInfo.isWindows && isBackgroundRunning) {
|
||||
// 最小化
|
||||
window.extendedState = window.extendedState or JFrame.ICONIFIED
|
||||
// 隐藏
|
||||
window.isVisible = false
|
||||
return
|
||||
}
|
||||
|
||||
// 如果 macOS 已经开启了后台运行,那么直接销毁,因为会有一个进程驻守
|
||||
if (SystemInfo.isMacOS && isBackgroundRunning) {
|
||||
window.dispose()
|
||||
return
|
||||
}
|
||||
|
||||
val option = OptionPane.showConfirmDialog(
|
||||
window,
|
||||
I18n.getString("termora.quit-confirm", Application.getName()),
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
)
|
||||
if (option == JOptionPane.YES_OPTION) {
|
||||
window.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun tick() {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
val windows = getWindows()
|
||||
if (windows.isEmpty()) return
|
||||
for (window in windows) {
|
||||
if (window.extendedState and JFrame.ICONIFIED == JFrame.ICONIFIED) {
|
||||
window.extendedState = window.extendedState and JFrame.ICONIFIED.inv()
|
||||
}
|
||||
window.isVisible = true
|
||||
}
|
||||
windows.last().toFront()
|
||||
} else {
|
||||
SwingUtilities.invokeLater { tick() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (isDisposed.compareAndSet(false, true)) {
|
||||
Disposer.dispose(ApplicationScope.forApplicationScope())
|
||||
|
||||
try {
|
||||
Disposer.getTree().assertIsEmpty(true)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
private fun saveFrameRectangle(frame: TermoraFrame) {
|
||||
properties.putString("TermoraFrame.x", frame.x.toString())
|
||||
properties.putString("TermoraFrame.y", frame.y.toString())
|
||||
properties.putString("TermoraFrame.width", frame.width.toString())
|
||||
properties.putString("TermoraFrame.height", frame.height.toString())
|
||||
properties.putString("TermoraFrame.extendedState", frame.extendedState.toString())
|
||||
}
|
||||
|
||||
private fun getFrameRectangle(): FrameRectangle? {
|
||||
val x = properties.getString("TermoraFrame.x")?.toIntOrNull() ?: return null
|
||||
val y = properties.getString("TermoraFrame.y")?.toIntOrNull() ?: return null
|
||||
val w = properties.getString("TermoraFrame.width")?.toIntOrNull() ?: return null
|
||||
val h = properties.getString("TermoraFrame.height")?.toIntOrNull() ?: return null
|
||||
val s = properties.getString("TermoraFrame.extendedState")?.toIntOrNull() ?: return null
|
||||
return FrameRectangle(x, y, w, h, s)
|
||||
}
|
||||
|
||||
fun setOpacity(opacity: Double) {
|
||||
if (opacity < 0 || opacity > 1 || SystemInfo.isLinux) return
|
||||
for (window in getWindows()) {
|
||||
setOpacity(window, opacity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setOpacity(window: Window, opacity: Double) {
|
||||
if (SystemInfo.isMacOS) {
|
||||
val nsWindow = ID(NativeMacLibrary.getNSWindow(window) ?: return)
|
||||
ThreadUtils.dispatch_async {
|
||||
Foundation.invoke(nsWindow, "setOpaque:", false)
|
||||
Foundation.invoke(nsWindow, "setAlphaValue:", opacity)
|
||||
}
|
||||
} else if (SystemInfo.isWindows) {
|
||||
val alpha = ((opacity * 255).toInt() and 0xFF).toByte()
|
||||
val hwnd = WinDef.HWND(Pointer.createConstant(FlatNativeWindowsLibrary.getHWND(window)))
|
||||
val exStyle = User32.INSTANCE.GetWindowLong(hwnd, User32.GWL_EXSTYLE)
|
||||
if (exStyle and WS_EX_LAYERED == 0) {
|
||||
User32.INSTANCE.SetWindowLong(hwnd, GWL_EXSTYLE, exStyle or WS_EX_LAYERED)
|
||||
}
|
||||
User32.INSTANCE.SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA)
|
||||
}
|
||||
}
|
||||
|
||||
private data class FrameRectangle(
|
||||
val x: Int, val y: Int, val w: Int, val h: Int, val s: Int
|
||||
) {
|
||||
val isMaximized get() = (s and Frame.MAXIMIZED_BOTH) == Frame.MAXIMIZED_BOTH
|
||||
}
|
||||
}
|
||||
155
src/main/kotlin/app/termora/TermoraRestarter.kt
Normal file
@@ -0,0 +1,155 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.github.hstyi.restart4j.Restarter
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Component
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class TermoraRestarter {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TermoraRestarter::class.java)
|
||||
|
||||
fun getInstance(): TermoraRestarter {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(TermoraRestarter::class) { TermoraRestarter() }
|
||||
}
|
||||
|
||||
init {
|
||||
Restarter.setProcessHandler { ProcessHandle.current().pid().toInt() }
|
||||
Restarter.setExecCommandsHandler { commands ->
|
||||
val pb = ProcessBuilder(commands)
|
||||
if (SystemInfo.isLinux) {
|
||||
// 去掉链接库变量
|
||||
pb.environment().remove("LD_LIBRARY_PATH")
|
||||
}
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||
pb.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
pb.directory(Paths.get(System.getProperty("user.home")).toFile())
|
||||
pb.start()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val restarting = AtomicBoolean(false)
|
||||
private val isSupported get() = !restarting.get() && checkIsSupported()
|
||||
private val isLinuxAppImage by lazy { System.getenv("LinuxAppImage")?.toBoolean() == true }
|
||||
private val startupCommand by lazy { ProcessHandle.current().info().command().getOrNull() }
|
||||
private val macOSApplicationPath by lazy {
|
||||
StringUtils.removeEndIgnoreCase(
|
||||
Application.getAppPath(),
|
||||
"/Contents/MacOS/Termora"
|
||||
)
|
||||
}
|
||||
|
||||
private fun restart(commands: List<String>) {
|
||||
if (!isSupported) return
|
||||
if (!restarting.compareAndSet(false, true)) return
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
try {
|
||||
doRestart(commands)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划重启,如果当前进程支持重启,那么会询问用户是否重启。如果不支持重启,那么弹窗提示需要手动重启。
|
||||
*/
|
||||
fun scheduleRestart(owner: Component?, commands: List<String> = emptyList()) {
|
||||
|
||||
if (isSupported) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.restart.message"),
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
messageType = JOptionPane.QUESTION_MESSAGE,
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
options = arrayOf(
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
I18n.getString("termora.cancel")
|
||||
),
|
||||
initialValue = I18n.getString("termora.settings.restart.title")
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
restart(commands)
|
||||
}
|
||||
} else {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.restart.message"),
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
messageType = JOptionPane.INFORMATION_MESSAGE,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun doRestart(commands: List<String>) {
|
||||
|
||||
if (commands.isEmpty()) {
|
||||
if (SystemInfo.isMacOS) {
|
||||
Restarter.restart(arrayOf("open", "-n", macOSApplicationPath))
|
||||
} else if (SystemInfo.isWindows && startupCommand != null) {
|
||||
Restarter.restart(arrayOf(startupCommand))
|
||||
} else if (SystemInfo.isLinux) {
|
||||
if (isLinuxAppImage) {
|
||||
Restarter.restart(arrayOf(System.getenv("APPIMAGE")))
|
||||
} else if (startupCommand != null) {
|
||||
Restarter.restart(arrayOf(startupCommand))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Restarter.restart(commands.toTypedArray())
|
||||
}
|
||||
|
||||
for (window in TermoraFrameManager.getInstance().getWindows()) {
|
||||
window.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun checkIsSupported(): Boolean {
|
||||
val appPath = Application.getAppPath()
|
||||
if (appPath.isBlank() || Application.isUnknownVersion()) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Restart not supported")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (SystemInfo.isWindows && startupCommand == null) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Restart not supported , ProcessHandle#info#command is null.")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (SystemInfo.isLinux) {
|
||||
if (isLinuxAppImage) {
|
||||
val appImage = System.getenv("APPIMAGE") ?: StringUtils.EMPTY
|
||||
return appImage.isNotBlank() && FileUtils.getFile(appImage).exists()
|
||||
}
|
||||
return startupCommand != null
|
||||
}
|
||||
|
||||
if (SystemInfo.isMacOS) {
|
||||
return Application.getAppPath().isNotBlank()
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
183
src/main/kotlin/app/termora/TermoraToolBar.kt
Normal file
@@ -0,0 +1,183 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.actions.*
|
||||
import app.termora.findeverywhere.FindEverywhereAction
|
||||
import app.termora.snippet.SnippetAction
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.action.ActionContainerFactory
|
||||
import java.awt.Rectangle
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import javax.swing.Box
|
||||
import javax.swing.JToolBar
|
||||
|
||||
|
||||
@Serializable
|
||||
data class ToolBarAction(
|
||||
val id: String,
|
||||
val visible: Boolean,
|
||||
)
|
||||
|
||||
class TermoraToolBar(
|
||||
private val windowScope: WindowScope,
|
||||
private val frame: TermoraFrame,
|
||||
private val tabbedPane: FlatTabbedPane
|
||||
) {
|
||||
private val properties by lazy { Database.getDatabase().properties }
|
||||
private val toolbar by lazy { MyToolBar().apply { rebuild(this) } }
|
||||
|
||||
|
||||
fun getJToolBar(): JToolBar {
|
||||
return toolbar
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取到所有的 Action
|
||||
*/
|
||||
fun getAllActions(): List<ToolBarAction> {
|
||||
return listOf(
|
||||
ToolBarAction(SnippetAction.SNIPPET, true),
|
||||
ToolBarAction(Actions.SFTP, true),
|
||||
ToolBarAction(Actions.TERMINAL_LOGGER, true),
|
||||
ToolBarAction(Actions.MACRO, true),
|
||||
ToolBarAction(Actions.KEYWORD_HIGHLIGHT, true),
|
||||
ToolBarAction(Actions.KEY_MANAGER, true),
|
||||
ToolBarAction(MultipleAction.MULTIPLE, true),
|
||||
ToolBarAction(FindEverywhereAction.FIND_EVERYWHERE, true),
|
||||
ToolBarAction(SettingsAction.SETTING, true),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取到所有 Action,会根据用户个性化排序/显示
|
||||
*/
|
||||
fun getActions(): List<ToolBarAction> {
|
||||
val text = properties.getString(
|
||||
"Termora.ToolBar.Actions",
|
||||
StringUtils.EMPTY
|
||||
)
|
||||
|
||||
val actions = getAllActions()
|
||||
|
||||
if (text.isBlank()) {
|
||||
return actions
|
||||
}
|
||||
|
||||
// 存储的 action
|
||||
val storageActions = (ohMyJson.runCatching {
|
||||
ohMyJson.decodeFromString<List<ToolBarAction>>(text)
|
||||
}.getOrNull() ?: return actions).toMutableList()
|
||||
|
||||
for (action in actions) {
|
||||
// 如果存储的 action 不包含这个,那么这个可能是新增的,新增的默认显示出来
|
||||
if (storageActions.none { it.id == action.id }) {
|
||||
storageActions.addFirst(ToolBarAction(action.id, true))
|
||||
}
|
||||
}
|
||||
|
||||
// 如果存储的 Action 在所有 Action 里没有,那么移除
|
||||
storageActions.removeIf { e -> actions.none { e.id == it.id } }
|
||||
|
||||
return storageActions
|
||||
}
|
||||
|
||||
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() {
|
||||
init {
|
||||
// 监听窗口大小变动,然后修改边距避开控制按钮
|
||||
addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
adjust()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun adjust() {
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
val rectangle =
|
||||
frame.rootPane.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
|
||||
as? Rectangle ?: return
|
||||
val right = rectangle.width
|
||||
val toolbar = this@MyToolBar
|
||||
for (i in 0 until toolbar.componentCount) {
|
||||
val c = toolbar.getComponent(i)
|
||||
if (c.name == "spacing") {
|
||||
if (c.width == right) {
|
||||
return
|
||||
}
|
||||
toolbar.remove(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (right > 0) {
|
||||
val spacing = Box.createHorizontalStrut(right)
|
||||
spacing.name = "spacing"
|
||||
toolbar.add(spacing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.*
|
||||
import com.formdev.flatlaf.ui.FlatTextBorder
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.Component
|
||||
import java.awt.event.FocusAdapter
|
||||
import java.awt.event.FocusEvent
|
||||
import java.awt.event.KeyAdapter
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.*
|
||||
import java.text.ParseException
|
||||
import javax.swing.DefaultListCellRenderer
|
||||
import javax.swing.JComboBox
|
||||
@@ -53,6 +51,15 @@ class OutlineTextArea : FlatTextArea() {
|
||||
}
|
||||
}
|
||||
|
||||
class OutlineComboBox<T> : FlatComboBox<T>() {
|
||||
init {
|
||||
addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
putClientProperty(FlatClientProperties.OUTLINE, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FixedLengthTextArea(var maxLength: Int = Int.MAX_VALUE) : FlatTextArea() {
|
||||
init {
|
||||
@@ -92,6 +99,8 @@ class OutlinePasswordField(
|
||||
styleMap = mapOf(
|
||||
"showRevealButton" to true
|
||||
)
|
||||
|
||||
putClientProperty("JPasswordField.cutCopyAllowed", true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +146,7 @@ open class EmailFormattedTextField(var maxLength: Int = Int.MAX_VALUE) : Outline
|
||||
}
|
||||
|
||||
|
||||
abstract class NumberSpinner(
|
||||
open class NumberSpinner(
|
||||
value: Int,
|
||||
minimum: Int,
|
||||
maximum: Int,
|
||||
|
||||