Compare commits

...

122 Commits

Author SHA1 Message Date
hstyi
422e9aac84 release: 1.0.10 2025-03-05 11:11:41 +08:00
hstyi
9915c373b7 chore: remind me next time 2025-02-28 12:43:12 +08:00
hstyi
eba85e6348 fix: emacs shift key 2025-02-27 20:43:51 +08:00
hstyi
483a7772f4 feat: support snippet (#321) 2025-02-27 16:48:25 +08:00
hstyi
dcc96358f6 chore: remind me next time 2025-02-26 16:05:19 +08:00
hstyi
b5c30d505b feat: improve FlatTabbedPaneUI (#314) 2025-02-25 15:45:48 +08:00
hstyi
1f3ef5f3f0 chore: upgrade jdk 21.0.6b895.91 2025-02-25 13:27:41 +08:00
hstyi
d388bcfc92 chore: improve floating toolbar 2025-02-24 18:38:33 +08:00
hstyi
562c1f98fe feat: support to open host by enter 2025-02-24 17:11:12 +08:00
hstyi
f3c5009a45 feat: supports remembering window positions 2025-02-24 16:27:53 +08:00
hstyi
09a1d9f51e chore: osx GitHub actions 2025-02-24 14:31:09 +08:00
hstyi
84b48278ad feat: support sftp status (#307) 2025-02-24 14:14:44 +08:00
hstyi
ef9caf2578 release: 1.0.9 2025-02-24 13:23:13 +08:00
hstyi
b85bdf840e feat: support automatic download of update packages (#305) 2025-02-24 12:38:30 +08:00
hstyi
a2d7f3b5bb chore: Inno Setup 2025-02-24 00:51:53 +08:00
hstyi
02a96d73c8 fix: linux won't restart 2025-02-23 22:33:15 +08:00
hstyi
9fb12c7a71 feat: SFTP command add key shortcut 2025-02-23 21:32:25 +08:00
hstyi
145d8fc802 chore: automatically notarise macOS releases when released 2025-02-23 15:00:42 +08:00
hstyi
72c9dba806 feat: support restart (#299) 2025-02-23 11:32:44 +08:00
hstyi
de20bd654c feat: supports importing hosts from PuTTY (#297) 2025-02-22 16:47:50 +08:00
hstyi
35b3a10746 feat: supports importing hosts from electerm (#296) 2025-02-22 15:59:43 +08:00
hstyi
05fe6a0eb1 feat: supports importing hosts from FinalShell (#295) 2025-02-22 15:32:48 +08:00
hstyi
0552917c26 feat: supports importing hosts from SecureCRT (#294) 2025-02-22 14:51:32 +08:00
hstyi
51c355c113 feat: supports importing hosts from MobaXterm (#293) 2025-02-22 14:04:31 +08:00
hstyi
034ee3791d feat: supports importing hosts from Xshell (#292) 2025-02-22 13:15:45 +08:00
hstyi
adabaf8f2d feat: supports importing hosts from CSV (#291) 2025-02-22 12:23:31 +08:00
hstyi
1f392c52a1 chore: win 7z 2025-02-21 22:31:40 +08:00
hstyi
28fe4c725f feat: supports importing hosts from WindTerm (#289) 2025-02-21 21:44:51 +08:00
hstyi
18fe92cb11 chore: upgrade dependency versions 2025-02-21 19:42:08 +08:00
hstyi
c49acf7b51 feat: support fixed SFTP tab (#286) 2025-02-21 17:04:50 +08:00
hstyi
7df317a1b9 feat: refactoring HostTree & support sorting (#285) 2025-02-21 16:24:45 +08:00
hstyi
219e5420f5 fix: memory parsing error (#284) 2025-02-20 21:24:38 +08:00
hstyi
aefb7c3014 chore: exclude sshd-osgi 2025-02-20 20:53:12 +08:00
hstyi
f0c7f06ff5 chore: optimize package size 2025-02-20 20:41:34 +08:00
hstyi
604e07b43a fix: memory leaks 2025-02-20 17:17:23 +08:00
hstyi
0000e4610a feat: nvidia smi (#280) 2025-02-20 16:45:53 +08:00
hstyi
510324d7c4 fix: tunnels causes connection failure (#279) 2025-02-20 12:13:27 +08:00
hstyi
33a359fcbf feat: system information (#278) 2025-02-20 12:05:45 +08:00
hstyi
0b84d3271c feat: FindEverywhere show more info 2025-02-19 15:24:28 +08:00
hstyi
57547c95cb feat: blink (#273) 2025-02-19 13:17:59 +08:00
hstyi
503cfa9a4e fix: terminal cursor error (#272) 2025-02-19 10:29:31 +08:00
hstyi
af1f979e31 feat: ⌘ + Q to exit prompt 2025-02-18 23:29:04 +08:00
hstyi
3cd9f92ea9 feat: support setting sftp path (#267) 2025-02-18 18:09:33 +08:00
hstyi
b332bada95 release: 1.0.8 2025-02-18 11:42:19 +08:00
hstyi
63a12c2ec8 docs: README 2025-02-18 11:42:01 +08:00
hstyi
743f242805 feat: support system fonts (#260) 2025-02-18 08:31:33 +08:00
hstyi
5bead0b27d fix: high CPU 2025-02-17 09:41:35 +08:00
hstyi
73e3c7016b feat: SFTP command icon 2025-02-17 08:24:59 +08:00
hstyi
3829dcd0f9 feat: sftp HostKeyAlgorithms (#255) 2025-02-16 19:18:00 +08:00
hstyi
b2047044fe chore: apple.awt.application.name 2025-02-16 11:22:30 +08:00
hstyi
47d1a13189 chore: improve contextmenu (#251) 2025-02-16 11:14:37 +08:00
hstyi
309909cbd7 fix: key shortcut also triggers when Popup is available (#250) 2025-02-15 19:52:57 +08:00
hstyi
b5cebb4cea chore: remove double Shift key shortcut (#249) 2025-02-15 19:27:44 +08:00
hstyi
b6dd2693cd fix: hostConfigEntry NPE 2025-02-15 19:22:20 +08:00
hstyi
5fdfe98f26 feat: OSC 1337 (#244) 2025-02-15 17:38:06 +08:00
hstyi
0c768aa1ca chore: osx github actions 2025-02-15 16:20:22 +08:00
hstyi
d493e6dc9e chore: description 2025-02-15 15:09:47 +08:00
hstyi
7e0c7d8891 fix: sftp1 to sftp 2025-02-15 14:43:46 +08:00
hstyi
3510c6600d feat: detecting SFTP program (#241) 2025-02-15 14:38:51 +08:00
hstyi
32d91150bd fix: dialog edge detection (#240) 2025-02-15 14:15:17 +08:00
hstyi
bbf2d50e3f feat: clear terminal screen shortcut (#239) 2025-02-15 14:14:49 +08:00
hstyi
39725f9828 chore: linux-aarch64.yml 2025-02-15 13:39:23 +08:00
hstyi
1e8c617a85 feat: SFTP command support for Jump Hosts and Proxy (#236) 2025-02-15 13:15:02 +08:00
hstyi
7f8573ec4c fix: frequent fingerprint saving on the jump server 2025-02-15 12:42:18 +08:00
hstyi
d8e629917e feat: SFTP command (#234) 2025-02-15 11:23:06 +08:00
hstyi
bdc0a15439 fix: HostDialog title 2025-02-14 20:54:51 +08:00
hstyi
a25b97614f feat: Floating Toolbar (#231) 2025-02-14 20:38:46 +08:00
hstyi
4e12c32566 chore: stop listening if the file does not exist (#230) 2025-02-14 15:36:26 +08:00
hstyi
ea9c0f1225 chore: optimising SFTP for Linux edit (#229) 2025-02-14 15:00:19 +08:00
hstyi
ff865f13a2 fix: AppImage not working 2025-02-14 14:41:52 +08:00
hstyi
9875200912 chore: toolbar strut (#227) 2025-02-14 13:58:11 +08:00
hstyi
9f218d004e fix: tab drag (#226) 2025-02-14 13:54:39 +08:00
hstyi
ab727f66f4 fix: windows action cache 2025-02-14 12:46:37 +08:00
hstyi
efbc0302e4 chore: wget quiet 2025-02-14 12:34:27 +08:00
hstyi
ab2367d670 chore: linux AppImage and actions/cache (#222) 2025-02-14 12:27:14 +08:00
hstyi
045e4f81d6 feat: export configuration file support encryption (#221) 2025-02-14 12:18:37 +08:00
hstyi
160cfee947 chore: linux logo 2025-02-13 20:00:44 +08:00
hstyi
0e40b5ecce feat: open with SFTP (#217) 2025-02-13 17:04:14 +08:00
hstyi
fcaddcee80 feat: open SFTP directly to the current SSH server (#216) 2025-02-13 16:46:52 +08:00
hstyi
8d6295fd3b fix: auto wrap mode (#215) 2025-02-13 15:50:50 +08:00
hstyi
d0d51b3e6f fix: authentication dialog 2025-02-12 17:32:31 +08:00
hstyi
b8d612f1d5 feat: supports one-time authorised connection (#211) 2025-02-12 17:13:30 +08:00
hstyi
f7c49cde0c feat: supports custom editing of SFTP command (#210) 2025-02-12 16:33:37 +08:00
hstyi
189f8fb3ba feat: SFTP file editing support (#209) 2025-02-12 15:55:51 +08:00
hstyi
2a64bd28a8 chore: HostTree.showMoreInfo 2025-02-12 14:30:01 +08:00
hstyi
8a733379a3 feat: known_hosts (#206) 2025-02-12 11:45:55 +08:00
hstyi
e5f854dfcd feat: HostTree shows more information (#203) 2025-02-12 11:45:39 +08:00
hstyi
4e690bafed fix: macOS sign 2025-02-12 09:03:41 +08:00
hstyi
28b511e179 release: 1.0.7 2025-02-12 08:47:00 +08:00
hstyi
f010a13abd fix: center the MFA Code dialog (#199) 2025-02-11 16:38:09 +08:00
hstyi
4d80ffafdd fix: SSH password authentication reading local private key (#185) 2025-02-10 14:18:14 +08:00
hstyi
9aecd4d54b chore: browse 2025-02-10 14:01:59 +08:00
hstyi
65091823eb chore: copy-ssh-id i18n 2025-02-10 09:29:06 +08:00
hstyi
d17218bfbd chore: disable jpackage verbose 2025-02-09 17:07:08 +08:00
hstyi
724c5d2632 feat: copy public key display name (#186) 2025-02-09 16:56:10 +08:00
hstyi
6806c26028 feat: deprecate double-click Shift shortcut (#184) 2025-02-09 15:26:36 +08:00
hstyi
dcd89174c9 chore: new version dialog (#182) 2025-02-09 11:10:06 +08:00
hstyi
9a8707b8cb fix: encoding error 2025-02-09 10:25:43 +08:00
hstyi
28f1d05f06 feat: support ssh-copy-id (#177) 2025-02-08 12:32:18 +08:00
hstyi
54b044584e fix: line breaks 2025-02-08 11:01:59 +08:00
hstyi
ed39449a20 feat: GitHub actions macOS sign (#175) 2025-02-08 10:42:41 +08:00
hstyi
2ff3f3a352 chore: improve code 2025-02-08 09:18:14 +08:00
Mystery0 M
91e2e964a5 chore: move terminal disconnection messages to i18n (#168) 2025-02-08 09:15:21 +08:00
Mystery0 M
ca6cc68fed feat: support auto close terminal tab when ssh disconnected normally (#169) 2025-02-08 09:14:57 +08:00
hstyi
0962de7735 feat: winget releaser 2025-02-08 08:52:56 +08:00
hstyi
062b957fdb docs: README 2025-02-07 15:40:27 +08:00
hstyi
4efe4e5663 chore: opengl 2025-02-07 14:43:03 +08:00
hstyi
25eb6966c4 feat: external release to create a new window (#162) 2025-02-07 14:11:07 +08:00
hstyi
7843460020 feat: confirmation required to exit program 2025-02-07 13:50:34 +08:00
hstyi
1cbc6ba4a9 fix: color mismatch issue 2025-02-07 11:15:21 +08:00
hstyi
a43407bee8 feat: support drag and drop transfer (#157) 2025-02-07 11:15:01 +08:00
hstyi
05c4ec9af2 feat: support for turning off beep (#155) 2025-02-07 09:22:01 +08:00
hstyi
9236064293 docs: README 2025-02-06 16:03:52 +08:00
hstyi
e1955a371e feat: support for WebDAV (#150) 2025-02-06 16:03:25 +08:00
hstyi
58b56c4221 fix: drag and drop cancel 2025-02-06 11:30:09 +08:00
hstyi
1e461e529f release: 1.0.6 2025-02-06 10:52:15 +08:00
hstyi
38ada1207c chore: PasswordField allows copying and cutting 2025-02-06 10:05:18 +08:00
hstyi
8bd1b34f46 feat: support drag and drop to other windows (#145) 2025-02-06 09:51:45 +08:00
hstyi
4a513360e6 chore: text cursor 2025-02-05 14:19:02 +08:00
hstyi
22da5c1c37 chore: jbrsdk-21.0.6 2025-01-28 12:01:46 +08:00
hstyi
483582a8d1 feat: serial comm (#141) 2025-01-28 10:23:05 +08:00
hstyi
f037cbfac0 docs: README 2025-01-26 21:04:54 +08:00
173 changed files with 9654 additions and 2033 deletions

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

@@ -0,0 +1,47 @@
name: Linux aarch64
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-aarch64-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

View File

@@ -4,14 +4,17 @@ on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-linux-x64-b509.30.tar.gz
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b895.91.tar.gz
# appimagetool
- run: sudo apt install libfuse2
# install jdk
- name: Installing Java
@@ -19,9 +22,18 @@ jobs:
with:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.5'
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
@@ -30,4 +42,6 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: termora-linux-x86-64
path: build/distributions/*.tar.gz
path: |
build/distributions/*.tar.gz
build/distributions/*.AppImage

View File

@@ -10,9 +10,41 @@ jobs:
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 -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-osx-aarch64-b509.30.tar.gz
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b895.91.tar.gz
# install jdk
- name: Installing Java
@@ -20,16 +52,33 @@ jobs:
with:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.5'
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: |
- 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/*.dmg
path: |
build/distributions/*.zip
build/distributions/*.dmg

View File

@@ -10,8 +10,41 @@ jobs:
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 -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.5-osx-x64-b509.30.tar.gz
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b895.91.tar.gz
# install jdk
- name: Installing Java
@@ -19,16 +52,35 @@ jobs:
with:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.5'
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: |
- 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/*.dmg
path: |
build/distributions/*.zip
build/distributions/*.dmg

View File

@@ -10,15 +10,34 @@ jobs:
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
uses: actions/setup-java@v4
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:
distribution: 'jetbrains'
java-version: '21'
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
@@ -27,3 +46,4 @@ jobs:
path: |
build/distributions/*.zip
build/distributions/*.msi
build/distributions/*.exe

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

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

View File

@@ -15,11 +15,13 @@
## Features
- SSH and local terminal support
- [SFTP](./docs/sftp.png?raw=1) file transfer 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
- Configuration synchronization via [Gist](https://gist.github.com)
- SSH port forwarding & Jump hosts
- Terminal log
- Configuration synchronization via [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- Macro support (record and replay scripts)
- Keyword highlighting
- Key management
@@ -32,6 +34,7 @@
- [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`
## Development

View File

@@ -11,11 +11,13 @@
## 功能特性
- 支持 SSH 和本地终端
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) 文件传输
- 支持串口协议
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) & [命令行](./docs/sftp-command.png?raw=1) 文件传输
- 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议
- 支持 SSH 端口转发
- 支持配置同步到 [Gist](https://gist.github.com)
- 支持 SSH 端口转发和跳板机
- 终端日志记录
- 支持配置同步到 [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- 支持宏(录制脚本并回放)
- 支持关键词高亮
- 支持密钥管理器
@@ -28,6 +30,7 @@
- [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`
## 开发

View File

@@ -14,7 +14,7 @@ commonmark 0.24.0
BSD 2-Clause "Simplified" License
https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt
commons-codec 1.17.1
commons-codec 1.18.0
Apache License 2.0
https://github.com/apache/commons-codec/blob/master/LICENSE.txt
@@ -34,10 +34,18 @@ commons-net 3.11.1
Apache License 2.0
https://github.com/apache/commons-net/blob/master/LICENSE.txt
commons-text 1.12.0
commons-text 1.13.0
Apache License 2.0
https://github.com/apache/commons-text/blob/master/LICENSE.txt
commons-csv 1.13.0
Apache License 2.0
https://github.com/apache/commons-csv/blob/master/LICENSE.txt
ini4j 0.5.5-2
Apache License 2.0
http://www.apache.org/licenses/LICENSE-2.0.txt
eddsa 0.3.0
Creative Commons Zero v1.0 Universal
https://github.com/str4d/ed25519-java/blob/master/LICENSE.txt
@@ -110,7 +118,7 @@ kotlin-logging 1.7.9
Apache License 2.0
https://github.com/oshai/kotlin-logging/blob/master/LICENSE
kotlin-stdlib 2.1.0
kotlin-stdlib 2.1.10
Apache License 2.0
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
@@ -126,6 +134,10 @@ kotlin-stdlib-jdk8 1.9.10
Apache License 2.0
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
restart4j 0.0.1
Apache License 2.0
https://github.com/hstyi/restart4j/blob/main/LICENSE
kotlinx-coroutines-core-jvm 1.10.1
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
@@ -134,11 +146,11 @@ kotlinx-coroutines-swing 1.10.1
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0
kotlinx-serialization-core-jvm 1.7.3
kotlinx-serialization-core-jvm 1.8.0
Apache License 2.0
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
kotlinx-serialization-json-jvm 1.7.3
kotlinx-serialization-json-jvm 1.8.0
Apache License 2.0
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
@@ -241,3 +253,7 @@ https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
json-20231013
Public Domain.
https://github.com/stleary/JSON-java/blob/master/LICENSE
jSerialComm 2.11.0
Apache License 2.0
https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0

View File

@@ -1,12 +1,18 @@
import org.gradle.internal.jvm.Jvm
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils
import org.jetbrains.kotlin.org.apache.commons.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
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlinx.serialization)
@@ -14,10 +20,10 @@ plugins {
group = "app.termora"
version = "1.0.5"
version = "1.0.10"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: Architecture = DefaultNativePlatform.getCurrentArchitecture()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
// macOS 签名信息
val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY
@@ -37,7 +43,7 @@ repositories {
dependencies {
// 由于签名和公证macOS 不携带 natives
val useNoNativesFlatLaf = os.isMacOsX && macOSNotary && System.getenv("ENABLE_BUILD").toBoolean()
val useNoNativesFlatLaf = os.isMacOsX && System.getenv("ENABLE_BUILD").toBoolean()
testImplementation(kotlin("test"))
testImplementation(libs.hutool)
@@ -57,6 +63,7 @@ dependencies {
implementation(libs.commons.codec)
implementation(libs.commons.io)
implementation(libs.commons.lang3)
implementation(libs.commons.csv)
implementation(libs.commons.net)
implementation(libs.commons.text)
implementation(libs.commons.compress)
@@ -96,7 +103,7 @@ dependencies {
implementation(libs.sshd.core)
implementation(libs.commonmark)
implementation(libs.jgit)
implementation(libs.jgit.sshd)
implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.jnafilechooser)
implementation(libs.xodus.vfs)
implementation(libs.xodus.openAPI)
@@ -104,6 +111,9 @@ dependencies {
implementation(libs.bip39)
implementation(libs.colorpicker)
implementation(libs.mixpanel)
implementation(libs.jSerialComm)
implementation(libs.ini4j)
implementation(libs.restart4j)
}
application {
@@ -114,7 +124,6 @@ application {
"-XX:+ZUncommit",
"-XX:+ZGenerational",
"-XX:ZUncommitDelay=60",
"-XX:SoftMaxHeapSize=64m"
)
if (os.isMacOsX) {
@@ -140,14 +149,17 @@ tasks.test {
tasks.register<Copy>("copy-dependencies") {
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 jna = libs.jna.asProvider().get()
val archName = if (arch.isArm) "aarch64" else "x86_64"
val dylib = dir.get().dir("dylib").asFile
val pty4j = libs.pty4j.get()
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
val targetDir = File(dylib, jna.name)
@@ -172,6 +184,38 @@ tasks.register<Copy>("copy-dependencies") {
// @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)
}
}
}
@@ -184,6 +228,73 @@ tasks.register<Copy>("copy-dependencies") {
}
}
}
} 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/*") }
}
}
}
}
}
}
}
@@ -225,22 +336,24 @@ tasks.register<Exec>("jpackage") {
"-XX:+ZUncommit",
"-XX:+ZGenerational",
"-XX:ZUncommitDelay=60",
"-XX:SoftMaxHeapSize=64m",
"-XX:+HeapDumpOnOutOfMemoryError",
"-Dlogger.console.level=off",
"-Dkotlinx.coroutines.debug=off",
"-Dapp-version=${project.version}",
)
if (os.isMacOsX) {
options.add("-Dsun.java2d.metal=true")
if (os.isMacOsX) {
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}"))
@@ -252,7 +365,17 @@ tasks.register<Exec>("jpackage") {
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) {
@@ -270,6 +393,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) {
@@ -300,11 +427,8 @@ tasks.register("dist") {
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
val osName = if (os.isMacOsX) "osx" else if (os.isWindows) "windows" else "linux"
val finalFilenameWithoutExtension = "${project.name}-${project.version}-${osName}-${arch.name}"
val macOSFinalFilePath = distributionDir.file("${finalFilenameWithoutExtension}.dmg").asFile.absolutePath
// 清空目录
exec { commandLine(gradlew, "clean") }
@@ -324,75 +448,8 @@ tasks.register("dist") {
// 打包
exec { commandLine(gradlew, "jpackage") }
// pack
if (os.isWindows) { // zip and msi
// zip
exec {
commandLine(
"tar", "-vacf",
distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile.absolutePath,
project.name.uppercaseFirstChar()
)
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
}
// msi
exec {
commandLine(
"cmd", "/c", "move",
"${project.name.uppercaseFirstChar()}-${project.version}.msi",
"${finalFilenameWithoutExtension}.msi"
)
workingDir = distributionDir.asFile
}
} else if (os.isLinux) { // tar.gz
exec {
commandLine(
"tar", "-czvf",
distributionDir.file("${finalFilenameWithoutExtension}.tar.gz").asFile.absolutePath,
project.name.uppercaseFirstChar()
)
workingDir = distributionDir.asFile
}
} else if (os.isMacOsX) { // rename
exec {
commandLine(
"mv",
distributionDir.file("${project.name.uppercaseFirstChar()}-${project.version}.dmg").asFile.absolutePath,
macOSFinalFilePath,
)
}
} else {
throw GradleException("${os.name} is not supported")
}
// sign dmg
if (os.isMacOsX && macOSSign) {
// sign
signMacOSLocalFile(File(macOSFinalFilePath))
// notary
if (macOSNotary) {
exec {
commandLine(
"/usr/bin/xcrun", "notarytool",
"submit", macOSFinalFilePath,
"--keychain-profile", macOSNotaryKeychainProfile,
"--wait",
)
}
// 绑定公证信息
exec {
commandLine(
"/usr/bin/xcrun",
"stapler", "staple", macOSFinalFilePath,
)
}
}
}
// 根据不同的系统构建不同的二进制包
pack()
}
}
@@ -430,6 +487,198 @@ tasks.register("check-license") {
}
}
/**
* 构建包
*/
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 对本地文件进行签名
*/
@@ -449,6 +698,40 @@ fun signMacOSLocalFile(file: File) {
}
}
/**
* 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 {
@@ -457,3 +740,10 @@ kotlin {
vendor = JvmVendorSpec.JETBRAINS
}
}
idea {
module {
isDownloadJavadoc = true
isDownloadSources = true
}
}

BIN
docs/sftp-command.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,16 +1,17 @@
[versions]
kotlin = "2.1.0"
kotlin = "2.1.10"
slf4j = "2.0.16"
pty4j = "0.13.2"
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-serialization-json = "1.8.0"
commons-codec = "1.18.0"
commons-lang3 = "3.17.0"
commons-csv = "1.13.0"
commons-net = "3.11.1"
commons-text = "1.12.0"
commons-text = "1.13.0"
commons-compress = "1.27.1"
koin-bom = "4.0.0"
swingx = "1.6.5-1"
@@ -41,6 +42,9 @@ rhino = "1.7.15"
delight-rhino-sandbox = "0.0.17"
testcontainers = "1.20.4"
mixpanel = "1.5.3"
jSerialComm = "2.11.0"
ini4j = "0.5.5-2"
restart4j = "0.0.1"
[libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
@@ -52,9 +56,11 @@ 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" }
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" }
@@ -71,6 +77,7 @@ 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" }
@@ -97,6 +104,7 @@ rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View File

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

View File

@@ -15,6 +15,8 @@ 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 kotlin.math.ln
import kotlin.math.pow
@@ -60,6 +62,16 @@ object Application {
return "/bin/bash"
}
fun getTemporaryDir(): File {
val temporaryDir = File(getBaseDataDir(), "temporary")
FileUtils.forceMkdir(temporaryDir)
return temporaryDir
}
fun createSubTemporaryDir(prefix: String = getName()): Path {
return Files.createTempDirectory(getTemporaryDir().toPath(), prefix)
}
fun getBaseDataDir(): File {
if (::baseDataDir.isInitialized) {
return baseDataDir
@@ -111,11 +123,18 @@ object Application {
return "Termora"
}
@Suppress("OPT_IN_USAGE")
fun browse(uri: URI, async: Boolean = true) {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
// https://github.com/TermoraDev/termora/issues/178
if (SystemInfo.isWindows && uri.scheme == "file") {
if (async) {
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
} else {
tryBrowse(uri)
}
} else if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(uri)
} else if (async) {
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
} else {
tryBrowse(uri)

View File

@@ -4,6 +4,8 @@ import app.termora.actions.ActionManager
import app.termora.keymap.KeymapManager
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatDesktop
import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse
import com.formdev.flatlaf.extras.FlatInspector
import com.formdev.flatlaf.util.SystemInfo
import com.jthemedetecor.OsThemeDetector
@@ -20,12 +22,14 @@ import org.apache.commons.lang3.SystemUtils
import org.json.JSONObject
import org.slf4j.LoggerFactory
import org.tinylog.configuration.Configuration
import java.awt.KeyboardFocusManager
import java.io.File
import java.nio.channels.FileChannel
import java.nio.channels.FileLock
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.util.*
import java.util.function.Consumer
import javax.swing.*
import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis
@@ -73,6 +77,9 @@ class ApplicationRunner {
// 解密数据
val openDoor = measureTimeMillis { openDoor() }
// clear temporary
clearTemporary()
// 启动主窗口
val startMainFrame = measureTimeMillis { startMainFrame() }
@@ -94,6 +101,14 @@ class ApplicationRunner {
}
}
@Suppress("OPT_IN_USAGE")
private fun clearTemporary() {
GlobalScope.launch(Dispatchers.IO) {
// 启动时清除
FileUtils.cleanDirectory(Application.getTemporaryDir())
}
}
private fun openDoor() {
if (Doorman.getInstance().isWorking()) {
@@ -104,7 +119,37 @@ class ApplicationRunner {
}
private fun startMainFrame() {
TermoraFrameManager.getInstance().createWindow().isVisible = true
if (SystemUtils.IS_OS_MAC_OSX) {
SwingUtilities.invokeLater {
FlatDesktop.setQuitHandler(object : Consumer<QuitResponse> {
override fun accept(response: QuitResponse) {
quitHandler(response)
}
})
}
}
}
private fun quitHandler(response: QuitResponse) {
val keyboardFocusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
if (OptionPane.showConfirmDialog(
keyboardFocusManager.focusedWindow,
I18n.getString("termora.quit-confirm", Application.getName()),
optionType = JOptionPane.YES_NO_OPTION,
) != JOptionPane.YES_OPTION
) {
response.cancelQuit()
return
}
for (frame in TermoraFrameManager.getInstance().getWindows()) {
frame.dispose()
}
}
private fun loadSettings() {
@@ -142,7 +187,7 @@ class ApplicationRunner {
themeManager.change(theme, true)
if (Application.isUnknownVersion())
FlatInspector.install("ctrl shift alt X");
FlatInspector.install("ctrl shift alt X")
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)

View File

@@ -22,10 +22,6 @@ class ChannelShellPtyConnector(
output.flush()
}
override fun write(buffer: String) {
write(buffer.toByteArray(charset))
}
override fun resize(rows: Int, cols: Int) {
channel.sendWindowChange(cols, rows)
}
@@ -38,4 +34,8 @@ class ChannelShellPtyConnector(
override fun close() {
channel.close(true)
}
override fun getCharset(): Charset {
return charset
}
}

View File

@@ -5,6 +5,7 @@ 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.SyncType
import app.termora.terminal.CursorStyle
import jetbrains.exodus.bindings.StringBinding
@@ -12,14 +13,11 @@ 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
@@ -28,6 +26,7 @@ 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"
@@ -55,6 +54,7 @@ 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.getInstance()
@@ -106,17 +106,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)
@@ -153,12 +142,29 @@ class Database private constructor(private val env: Environment) : Disposable {
}
}
fun removeHost(id: String) {
env.executeInTransaction {
delete(it, HOST_STORE, id)
if (log.isDebugEnabled) {
log.debug("Removed Host: $id")
fun 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 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
}
}
@@ -401,10 +407,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()
}
}
}
@@ -454,6 +460,16 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var debug by BooleanPropertyDelegate(false)
/**
* 蜂鸣声
*/
var beep by BooleanPropertyDelegate(true)
/**
* 光标闪烁
*/
var cursorBlink by BooleanPropertyDelegate(false)
/**
* 选中复制
*/
@@ -463,6 +479,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)
}
/**
@@ -563,6 +589,31 @@ class Database private constructor(private val env: Environment) : Disposable {
}
/**
* SFTP
*/
inner class SFTP : Property("Setting.SFTP") {
/**
* 编辑命令
*/
var editCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* sftp command
*/
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* 是否固定在标签栏
*/
var pinTab by BooleanPropertyDelegate(false)
}
/**
* 同步配置
*/
@@ -577,6 +628,7 @@ 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)

View File

@@ -54,7 +54,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
protected fun init() {
defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
defaultCloseOperation = DISPOSE_ON_CLOSE
initTitleBar()
initEvents()
@@ -158,7 +158,8 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
openPopup = true
}
val window = SwingUtilities.windowForComponent(c)
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")) {
@@ -166,6 +167,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
w.dispose()
}
}
}
if (openPopup) {
return

View File

@@ -37,6 +37,16 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
}
jumpHostsOption.filter = { it.id != host.id }
val serialComm = host.options.serialComm
if (serialComm.port.isNotBlank()) {
serialCommOption.serialPortComboBox.selectedItem = serialComm.port
}
serialCommOption.baudRateComboBox.selectedItem = serialComm.baudRate
serialCommOption.dataBitsComboBox.selectedItem = serialComm.dataBits
serialCommOption.parityComboBox.selectedItem = serialComm.parity
serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits
serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl
}
override fun getHost(): Host {

View File

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

View File

@@ -5,6 +5,17 @@ import org.apache.commons.lang3.StringUtils
import java.util.*
fun Map<*, *>.toPropertiesString(): String {
val env = StringBuilder()
for ((i, e) in entries.withIndex()) {
env.append(e.key).append('=').append(e.value)
if (i != size - 1) {
env.appendLine()
}
}
return env.toString()
}
fun UUID.toSimpleString(): String {
return toString().replace("-", StringUtils.EMPTY)
}
@@ -13,6 +24,13 @@ enum class Protocol {
Folder,
SSH,
Local,
Serial,
/**
* 交互式的 SFTP此协议只在系统内部交互不应该暴露给用户也不应该持久化
*/
@Transient
SFTPPty
}
@@ -39,6 +57,53 @@ data class Authentication(
}
}
enum class SerialCommParity {
None,
Even,
Odd,
Mark,
Space
}
enum class SerialCommFlowControl {
None,
RTS_CTS,
XON_XOFF,
}
@Serializable
data class SerialComm(
/**
* 串口
*/
val port: String = StringUtils.EMPTY,
/**
* 波特率
*/
val baudRate: Int = 9600,
/**
* 数据位5、6、7、8
*/
val dataBits: Int = 8,
/**
* 停止位: 1、1.5、2
*/
val stopBits: String = "1",
/**
* 校验位
*/
val parity: SerialCommParity = SerialCommParity.None,
/**
* 流控
*/
val flowControl: SerialCommFlowControl = SerialCommFlowControl.None,
)
@Serializable
data class Options(
@@ -61,7 +126,12 @@ data class Options(
/**
* SSH 心跳间隔
*/
val heartbeatInterval: Int = 30
val heartbeatInterval: Int = 30,
/**
* 串口配置
*/
val serialComm: SerialComm = SerialComm(),
) {
companion object {
val Default = Options()
@@ -190,7 +260,7 @@ data class Host(
val tunnelings: List<Tunneling> = emptyList(),
/**
* 排序
* 排序,越小越靠前
*/
val sort: Long = 0,
/**
@@ -237,4 +307,8 @@ data class Host(
result = 31 * result + ownerId.hashCode()
return result
}
override fun toString(): String {
return name
}
}

View File

@@ -67,37 +67,53 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
private suspend fun testConnection(host: Host) {
val owner = this
if (host.protocol != Protocol.SSH) {
if (host.protocol == Protocol.Local) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(owner, I18n.getString("termora.new-host.test-connection-successful"))
}
return
}
var client: SshClient? = null
var session: ClientSession? = null
try {
client = SshClients.openClient(host)
client.userInteraction = TerminalUserInteraction(owner)
session = SshClients.openSession(host, client)
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")
)
}
} catch (e: Exception) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
private fun testSSH(host: Host) {
var client: SshClient? = null
var session: ClientSession? = null
try {
client = SshClients.openClient(host)
client.userInteraction = TerminalUserInteraction(owner)
session = SshClients.openSession(host, client)
} finally {
session?.close()
client?.close()
}
}
private fun testSerial(host: Host) {
Serials.openPort(host).closePort()
}
override fun doOKAction() {

View File

@@ -1,13 +1,5 @@
package app.termora
import java.util.*
interface HostListener : EventListener {
fun hostAdded(host: Host) {}
fun hostRemoved(id: String) {}
fun hostsChanged() {}
}
class HostManager private constructor() {
companion object {
@@ -17,39 +9,38 @@ class HostManager private constructor() {
}
private val database get() = Database.getDatabase()
private val listeners = mutableListOf<HostListener>()
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) }
}
fun removeHost(id: String) {
assertEventDispatchThread()
database.removeHost(id)
listeners.forEach { it.hostRemoved(id) }
if (host.deleted) {
hosts.entries.removeIf { it.value.id == host.id || it.value.parentId == host.id }
} else {
hosts[host.id] = host
}
}
/**
* 第一次调用从数据库中获取,后续从缓存中获取
*/
fun hosts(): List<Host> {
return database.getHosts()
if (hosts.isEmpty()) {
database.getHosts().filter { !it.deleted }
.forEach { hosts[it.id] = it }
}
return hosts.values.filter { !it.deleted }
.sortedWith(compareBy<Host> { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort })
}
fun removeAll() {
assertEventDispatchThread()
database.removeAllHost()
listeners.forEach { it.hostsChanged() }
/**
* 从缓存中获取
*/
fun getHost(id: String): Host? {
return hosts[id]
}
fun addHostListener(listener: HostListener) {
listeners.add(listener)
}
fun removeHostListener(listener: HostListener) {
listeners.remove(listener)
}
}

View File

@@ -2,11 +2,17 @@ package app.termora
import app.termora.keymgr.KeyManager
import app.termora.keymgr.KeyManagerDialog
import com.fazecast.jSerialComm.SerialPort
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.ui.FlatTextBorder
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
import java.awt.*
import java.awt.event.*
@@ -22,6 +28,7 @@ open class HostOptionsPane : OptionsPane() {
protected val proxyOption = ProxyOption()
protected val terminalOption = TerminalOption()
protected val jumpHostsOption = JumpHostsOption()
protected val serialCommOption = SerialCommOption()
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init {
@@ -30,6 +37,7 @@ open class HostOptionsPane : OptionsPane() {
addOption(tunnelingOption)
addOption(jumpHostsOption)
addOption(terminalOption)
addOption(serialCommOption)
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
}
@@ -43,6 +51,7 @@ open class HostOptionsPane : OptionsPane() {
var authentication = Authentication.No
var proxy = Proxy.No
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
authentication = authentication.copy(
type = AuthenticationType.Password,
@@ -66,12 +75,23 @@ open class HostOptionsPane : OptionsPane() {
)
}
val serialComm = SerialComm(
port = serialCommOption.serialPortComboBox.selectedItem?.toString() ?: StringUtils.EMPTY,
baudRate = serialCommOption.baudRateComboBox.selectedItem?.toString()?.toIntOrNull() ?: 9600,
dataBits = serialCommOption.dataBitsComboBox.selectedItem as Int? ?: 8,
stopBits = serialCommOption.stopBitsComboBox.selectedItem as String? ?: "1",
parity = serialCommOption.parityComboBox.selectedItem as SerialCommParity,
flowControl = serialCommOption.flowControlComboBox.selectedItem as SerialCommFlowControl
)
val options = Options.Default.copy(
encoding = terminalOption.charsetComboBox.selectedItem as String,
env = terminalOption.environmentTextArea.text,
startupCommand = terminalOption.startupCommandTextField.text,
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
jumpHosts = jumpHostsOption.jumpHosts.map { it.id }
jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
serialComm = serialComm
)
return Host(
@@ -103,6 +123,12 @@ open class HostOptionsPane : OptionsPane() {
if (validateField(generalOption.usernameTextField)) {
return false
}
} else if (host.protocol == Protocol.Serial) {
if (validateField(serialCommOption.serialPortComboBox)
|| validateField(serialCommOption.baudRateComboBox)
) {
return false
}
}
if (host.authentication.type == AuthenticationType.Password) {
@@ -152,7 +178,8 @@ open class HostOptionsPane : OptionsPane() {
* 返回 true 表示有错误
*/
private fun validateField(comboBox: JComboBox<*>): Boolean {
if (comboBox.isEnabled && comboBox.selectedItem == null) {
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()
@@ -259,6 +286,7 @@ open class HostOptionsPane : OptionsPane() {
protocolTypeComboBox.addItem(Protocol.SSH)
protocolTypeComboBox.addItem(Protocol.Local)
protocolTypeComboBox.addItem(Protocol.Serial)
authenticationTypeComboBox.addItem(AuthenticationType.No)
authenticationTypeComboBox.addItem(AuthenticationType.Password)
@@ -328,7 +356,9 @@ open class HostOptionsPane : OptionsPane() {
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
@@ -901,6 +931,127 @@ open class HostOptionsPane : OptionsPane() {
}
}
protected inner class SerialCommOption : JPanel(BorderLayout()), Option {
val serialPortComboBox = OutlineComboBox<String>()
val baudRateComboBox = OutlineComboBox<Int>()
val dataBitsComboBox = OutlineComboBox<Int>()
val parityComboBox = OutlineComboBox<SerialCommParity>()
val stopBitsComboBox = OutlineComboBox<String>()
val flowControlComboBox = OutlineComboBox<SerialCommFlowControl>()
init {
initView()
initEvents()
}
private fun initView() {
serialPortComboBox.isEditable = true
baudRateComboBox.isEditable = true
baudRateComboBox.addItem(9600)
baudRateComboBox.addItem(19200)
baudRateComboBox.addItem(38400)
baudRateComboBox.addItem(57600)
baudRateComboBox.addItem(115200)
dataBitsComboBox.addItem(5)
dataBitsComboBox.addItem(6)
dataBitsComboBox.addItem(7)
dataBitsComboBox.addItem(8)
dataBitsComboBox.selectedItem = 8
parityComboBox.addItem(SerialCommParity.None)
parityComboBox.addItem(SerialCommParity.Even)
parityComboBox.addItem(SerialCommParity.Odd)
parityComboBox.addItem(SerialCommParity.Mark)
parityComboBox.addItem(SerialCommParity.Space)
stopBitsComboBox.addItem("1")
stopBitsComboBox.addItem("1.5")
stopBitsComboBox.addItem("2")
stopBitsComboBox.selectedItem = "1"
flowControlComboBox.addItem(SerialCommFlowControl.None)
flowControlComboBox.addItem(SerialCommFlowControl.RTS_CTS)
flowControlComboBox.addItem(SerialCommFlowControl.XON_XOFF)
flowControlComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
val text = value?.toString() ?: StringUtils.EMPTY
return super.getListCellRendererComponent(
list,
text.replace('_', '/'),
index,
isSelected,
cellHasFocus
)
}
}
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
addComponentListener(object : ComponentAdapter() {
override fun componentShown(e: ComponentEvent) {
removeComponentListener(this)
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) {
for (commPort in SerialPort.getCommPorts()) {
withContext(Dispatchers.Swing) {
serialPortComboBox.addItem(commPort.systemPortName)
}
}
}
}
})
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.plugin
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.serial")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.new-host.serial.port")}:").xy(1, rows)
.add(serialPortComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.baud-rate")}:").xy(1, rows)
.add(baudRateComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.data-bits")}:").xy(1, rows)
.add(dataBitsComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.parity")}:").xy(1, rows)
.add(parityComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.stop-bits")}:").xy(1, rows)
.add(stopBitsComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.flow-control")}:").xy(1, rows)
.add(flowControlComboBox).xy(3, rows).apply { rows += step }
.build()
return panel
}
}
protected inner class JumpHostsOption : JPanel(BorderLayout()), Option {
val jumpHosts = mutableListOf<Host>()
@@ -983,16 +1134,16 @@ open class HostOptionsPane : OptionsPane() {
private fun initEvents() {
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent?) {
val dialog = HostTreeDialog(owner) { host ->
jumpHosts.none { it.id == host.id } && filter.invoke(host)
}
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)

View File

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

View File

@@ -1,583 +0,0 @@
package app.termora
import app.termora.actions.NewHostAction
import app.termora.actions.OpenHostAction
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.getInstance()
private val editor = OutlineTextField(64)
var contextmenu = true
/**
* 双击是否打开连接
*/
var doubleClickConnection = true
val model = HostTreeModel()
val searchableModel = SearchableHostTreeModel(model)
init {
initView()
initEvents()
}
private fun initView() {
setModel(model)
isEditable = true
dropMode = DropMode.ON_OR_INSERT
dragEnabled = true
selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
editor.preferredSize = Dimension(220, 0)
setCellRenderer(object : DefaultXTreeCellRenderer() {
override fun getTreeCellRendererComponent(
tree: JTree,
value: Any,
sel: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean
): Component {
val host = value as Host
val c = super.getTreeCellRendererComponent(tree, host, sel, expanded, leaf, row, hasFocus)
if (host.protocol == Protocol.Folder) {
icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
} else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) {
icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal
}
return c
}
})
setCellEditor(object : DefaultCellEditor(editor) {
override fun isCellEditable(e: EventObject?): Boolean {
if (e is MouseEvent) {
return false
}
return super.isCellEditable(e)
}
})
val state = Database.getDatabase().properties.getString("HostTreeExpansionState")
if (state != null) {
TreeUtils.loadExpansionState(this@HostTree, state)
}
}
override fun convertValueToText(
value: Any?,
selected: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean
): String {
if (value is Host) {
return value.name
}
return super.convertValueToText(value, selected, expanded, leaf, row, hasFocus)
}
private fun initEvents() {
// 右键选中
addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
if (!SwingUtilities.isRightMouseButton(e)) {
return
}
requestFocusInWindow()
val selectionRows = selectionModel.selectionRows
val selRow = getClosestRowForLocation(e.x, e.y)
if (selRow < 0) {
selectionModel.clearSelection()
return
} else if (selectionRows != null && selectionRows.contains(selRow)) {
return
}
selectionPath = getPathForLocation(e.x, e.y)
setSelectionRow(selRow)
}
override fun mouseClicked(e: MouseEvent) {
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val host = lastSelectedPathComponent
if (host is Host && host.protocol != Protocol.Folder) {
ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(e.source, host, e))
}
}
}
})
// contextmenu
addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
if (!(SwingUtilities.isRightMouseButton(e))) {
return
}
if (Objects.isNull(lastSelectedPathComponent)) {
return
}
SwingUtilities.invokeLater { showContextMenu(e) }
}
})
// rename
getCellEditor().addCellEditorListener(object : CellEditorListener {
override fun editingStopped(e: ChangeEvent) {
val lastHost = lastSelectedPathComponent
if (lastHost !is Host || editor.text.isBlank() || editor.text == lastHost.name) {
return
}
runCatchingHost(lastHost.copy(name = editor.text))
}
override fun editingCanceled(e: ChangeEvent) {
}
})
// drag
transferHandler = object : TransferHandler() {
override fun createTransferable(c: JComponent): Transferable {
val nodes = selectionModel.selectionPaths
.map { it.lastPathComponent }
.filterIsInstance<Host>()
.toMutableList()
val iterator = nodes.iterator()
while (iterator.hasNext()) {
val node = iterator.next()
val parents = model.getPathToRoot(node).filter { it != node }
if (parents.any { nodes.contains(it) }) {
iterator.remove()
}
}
return MoveHostTransferable(nodes)
}
override fun getSourceActions(c: JComponent?): Int {
return MOVE
}
override fun canImport(support: TransferSupport): Boolean {
if (!support.isDrop) {
return false
}
val dropLocation = support.dropLocation
if (dropLocation !is JTree.DropLocation || support.component != this@HostTree
|| dropLocation.childIndex != -1
) {
return false
}
val lastNode = dropLocation.path.lastPathComponent
if (lastNode !is Host || lastNode.protocol != Protocol.Folder) {
return false
}
if (support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) {
val nodes = support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>
if (nodes.any { it == lastNode }) {
return false
}
for (parent in model.getPathToRoot(lastNode).filter { it != lastNode }) {
if (nodes.any { it == parent }) {
return false
}
}
}
support.setShowDropLocation(true)
return support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)
}
override fun importData(support: TransferSupport): Boolean {
if (!support.isDrop) {
return false
}
val dropLocation = support.dropLocation
if (dropLocation !is JTree.DropLocation) {
return false
}
val lastNode = dropLocation.path.lastPathComponent
if (lastNode !is Host || lastNode.protocol != Protocol.Folder) {
return false
}
if (!support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) {
return false
}
val hosts = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>)
.filterIsInstance<Host>().toMutableList()
if (hosts.isEmpty()) {
return false
}
// 记录展开的节点
val expandedHosts = mutableListOf<String>()
for (host in hosts) {
model.visit(host) {
if (it.protocol == Protocol.Folder) {
if (isExpanded(TreePath(model.getPathToRoot(it)))) {
expandedHosts.addFirst(it.id)
}
}
}
}
var now = System.currentTimeMillis()
for (host in hosts) {
model.removeNodeFromParent(host)
val newHost = host.copy(
parentId = lastNode.id,
sort = ++now,
updateDate = now
)
runCatchingHost(newHost)
}
expandNode(lastNode)
// 展开
for (id in expandedHosts) {
model.getHost(id)?.let { expandNode(it) }
}
return true
}
}
}
override fun isPathEditable(path: TreePath?): Boolean {
if (path == null) return false
if (path.lastPathComponent == model.root) return false
return super.isPathEditable(path)
}
override fun getLastSelectedPathComponent(): Any? {
val last = super.getLastSelectedPathComponent() ?: return null
if (last is Host) {
return model.getHost(last.id) ?: last
}
return last
}
private fun showContextMenu(event: MouseEvent) {
if (!contextmenu) return
val lastHost = lastSelectedPathComponent
if (lastHost !is Host) {
return
}
val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new"))
val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder"))
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host"))
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open"))
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 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 { openHosts(it, false) }
openInNewWindow.addActionListener { openHosts(it, true) }
rename.addActionListener {
startEditingAtPath(TreePath(model.getPathToRoot(lastHost)))
}
expandAll.addActionListener {
getSelectionNodes().forEach { expandNode(it, true) }
}
colspanAll.addActionListener {
selectionModel.selectionPaths.map { it.lastPathComponent }
.filterIsInstance<Host>()
.filter { it.protocol == Protocol.Folder }
.forEach { collapseNode(it) }
}
copy.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val parent = model.getParent(lastHost) ?: return
val node = copyNode(parent, lastHost)
selectionPath = TreePath(model.getPathToRoot(node))
}
})
remove.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
I18n.getString("termora.remove"),
JOptionPane.YES_NO_OPTION,
JOptionPane.QUESTION_MESSAGE
) == JOptionPane.YES_OPTION
) {
var lastParent: Host? = null
while (!selectionModel.isSelectionEmpty) {
val host = lastSelectedPathComponent ?: break
if (host !is Host) {
break
} else {
lastParent = model.getParent(host)
}
model.visit(host) { hostManager.removeHost(it.id) }
}
if (lastParent != null) {
selectionPath = TreePath(model.getPathToRoot(lastParent))
}
}
}
newFolder.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (lastHost.protocol != Protocol.Folder) {
return
}
val host = Host(
id = UUID.randomUUID().toSimpleString(),
protocol = Protocol.Folder,
name = I18n.getString("termora.welcome.contextmenu.new.folder.name"),
sort = System.currentTimeMillis(),
parentId = lastHost.id
)
runCatchingHost(host)
expandNode(lastHost)
selectionPath = TreePath(model.getPathToRoot(host))
startEditingAtPath(selectionPath)
}
})
newHost.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
ActionManager.getInstance().getAction(NewHostAction.NEW_HOST)
?.actionPerformed(e)
}
})
property.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val dialog = HostDialog(SwingUtilities.getWindowAncestor(this@HostTree), lastHost)
dialog.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)
}
private fun openHosts(evt: EventObject, openInNewWindow: Boolean) {
assertEventDispatchThread()
val nodes = getSelectionNodes().filter { it.protocol != Protocol.Folder }
if (nodes.isEmpty()) return
val openHostAction = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return
val source = if (openInNewWindow)
TermoraFrameManager.getInstance().createWindow().apply { isVisible = true }
else evt.source
nodes.forEach { openHostAction.actionPerformed(OpenHostActionEvent(source, it, evt)) }
}
fun expandNode(node: Host, including: Boolean = false) {
expandPath(TreePath(model.getPathToRoot(node)))
if (including) {
model.getChildren(node).forEach { expandNode(it, true) }
}
}
private fun copyNode(
parent: Host,
host: Host,
idGenerator: () -> String = { UUID.randomUUID().toSimpleString() }
): Host {
val now = System.currentTimeMillis()
val newHost = host.copy(
name = "${host.name} ${I18n.getString("termora.welcome.contextmenu.copy")}",
id = idGenerator.invoke(),
parentId = parent.id,
updateDate = now,
createDate = now,
sort = now
)
runCatchingHost(newHost)
if (host.protocol == Protocol.Folder) {
for (child in model.getChildren(host)) {
copyNode(newHost, child, idGenerator)
}
if (isExpanded(TreePath(model.getPathToRoot(host)))) {
expandNode(newHost)
}
}
return newHost
}
private fun runCatchingHost(host: Host) {
hostManager.addHost(host)
}
private fun collapseNode(node: Host) {
model.getChildren(node).forEach { collapseNode(it) }
collapsePath(TreePath(model.getPathToRoot(node)))
}
fun getSelectionNodes(): List<Host> {
val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent }
.filterIsInstance<Host>()
if (selectionNodes.isEmpty()) {
return emptyList()
}
val nodes = mutableListOf<Host>()
val parents = mutableListOf<Host>()
for (node in selectionNodes) {
if (node.protocol == Protocol.Folder) {
parents.add(node)
}
nodes.add(node)
}
while (parents.isNotEmpty()) {
val p = parents.removeFirst()
for (i in 0 until getModel().getChildCount(p)) {
val child = getModel().getChild(p, i) as Host
nodes.add(child)
parents.add(child)
}
}
// 确保是最新的
for (i in 0 until nodes.size) {
nodes[i] = model.getHost(nodes[i].id) ?: continue
}
return nodes
}
override fun dispose() {
Database.getDatabase().properties.putString(
"HostTreeExpansionState",
TreeUtils.saveExpansionState(this)
)
}
private abstract class HostTreeNodeTransferable(val hosts: List<Host>) :
Transferable {
override fun getTransferDataFlavors(): Array<DataFlavor> {
return arrayOf(getDataFlavor())
}
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean {
return getDataFlavor() == flavor
}
override fun getTransferData(flavor: DataFlavor): Any {
return hosts
}
abstract fun getDataFlavor(): DataFlavor
}
private class MoveHostTransferable(hosts: List<Host>) : HostTreeNodeTransferable(hosts) {
companion object {
val dataFlavor =
DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveHostTransferable::class.java.name}")
}
override fun getDataFlavor(): DataFlavor {
return dataFlavor
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,110 @@
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
else -> if (selected && hasFocus) Icons.terminal.dark else Icons.terminal
}
}
fun childrenNode(): List<HostTreeNode> {
return children?.map { it as HostTreeNode } ?: emptyList()
}
/**
* 深度克隆
* @param scopes 克隆的范围
*/
fun clone(scopes: Set<Protocol> = emptySet()): HostTreeNode {
val newNode = clone() as HostTreeNode
deepClone(newNode, this, scopes)
return newNode
}
private fun deepClone(newNode: HostTreeNode, oldNode: HostTreeNode, scopes: Set<Protocol> = emptySet()) {
for (child in oldNode.childrenNode()) {
if (scopes.isNotEmpty() && !scopes.contains(child.data.protocol)) continue
val newChildNode = child.clone() as HostTreeNode
deepClone(newChildNode, child, scopes)
newNode.add(newChildNode)
}
}
override fun clone(): Any {
val newNode = HostTreeNode(data)
newNode.children = null
newNode.parent = null
return newNode
}
override fun isNodeChild(aNode: TreeNode?): Boolean {
if (aNode is HostTreeNode) {
for (node in childrenNode()) {
if (node.data == aNode.data) {
return true
}
}
}
return super.isNodeChild(aNode)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HostTreeNode
return data == other.data
}
override fun hashCode(): Int {
return data.hashCode()
}
}

View File

@@ -3,10 +3,15 @@ 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 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 eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
@@ -23,6 +28,9 @@ object Icons {
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 networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
@@ -47,6 +55,7 @@ object Icons {
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") }
val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_dark.svg") }
val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_dark.svg") }
val azure by lazy { DynamicIcon("icons/azure.svg", "icons/azure_dark.svg") }
val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") }
val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") }
@@ -67,6 +76,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") }
@@ -84,6 +94,9 @@ object Icons {
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 {
@@ -109,5 +122,6 @@ object Icons {
val listKey by lazy { DynamicIcon("icons/listKey.svg", "icons/listKey_dark.svg") }
val forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") }
val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_dark.svg") }
val nvidia by lazy { DynamicIcon("icons/nvidia.svg", "icons/nvidia_dark.svg") }
}

View File

@@ -4,7 +4,8 @@ import app.termora.terminal.PtyConnector
import org.apache.commons.io.Charsets
import java.nio.charset.StandardCharsets
class LocalTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
class LocalTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
override suspend fun openPtyConnector(): PtyConnector {
val winSize = terminalPanel.winSize()

View File

@@ -12,6 +12,10 @@ fun main() {
setupNativeLibraries()
}
if (SystemUtils.IS_OS_MAC_OSX) {
System.setProperty("apple.awt.application.name", Application.getName())
}
ApplicationRunner().run()
}
@@ -41,4 +45,14 @@ private fun setupNativeLibraries() {
if (pty4j.exists()) {
System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath)
}
val jSerialComm = FileUtils.getFile(dylib, "jSerialComm")
if (jSerialComm.exists()) {
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
}
val restart4j = FileUtils.getFile(dylib, "restart4j", "restarter")
if (restart4j.exists()) {
System.setProperty("restarter.path", restart4j.absolutePath)
}
}

View File

@@ -5,6 +5,7 @@ import app.termora.actions.ActionManager
import app.termora.terminal.Terminal
import app.termora.terminal.TerminalColor
import app.termora.terminal.TextStyle
import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalDisplay
import app.termora.terminal.panel.TerminalPaintListener
import app.termora.terminal.panel.TerminalPanel
@@ -32,13 +33,25 @@ class MultipleTerminalListener : TerminalPaintListener {
// 正在搜索那么需要下移
val finding = terminal.getTerminalModel().getData(TerminalPanel.Finding, false)
// 如果悬浮窗正在显示,那么需要下移
val floatingToolBar = terminalPanel.getData(FloatingToolbarPanel.FloatingToolbar)?.isVisible == true
var y = g.fontMetrics.ascent
if (finding) {
y += g.fontMetrics.height + g.fontMetrics.ascent / 2
}
if (floatingToolBar) {
y += g.fontMetrics.height + g.fontMetrics.ascent / 2
}
g.font = font
g.color = Color(colorPalette.getColor(TerminalColor.Normal.RED))
g.drawString(
text,
terminalDisplay.width - width - terminalPanel.getAverageCharWidth() / 2,
g.fontMetrics.ascent + if (finding)
g.fontMetrics.height + g.fontMetrics.ascent / 2 else 0
y
)
g.font = oldFont
}

View File

@@ -1,6 +1,7 @@
package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
@@ -9,20 +10,30 @@ import java.awt.event.*
import java.awt.image.BufferedImage
import java.util.*
import javax.swing.*
import javax.swing.plaf.TabbedPaneUI
import kotlin.math.abs
class MyTabbedPane : FlatTabbedPane() {
private val owner: Window get() = SwingUtilities.getWindowAncestor(this)
private val dragMouseAdaptor = DragMouseAdaptor()
private val terminalTabbedManager
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
.getData(DataProviders.TerminalTabbedManager)
private val owner
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
.getData(DataProviders.TermoraFrame) as TermoraFrame
private val myUI = MyFlatTabbedPaneUI()
init {
isFocusable = false
super.setUI(myUI)
initEvents()
}
override fun setUI(ui: TabbedPaneUI?) {
super.setUI(myUI)
}
override fun updateUI() {
styleMap = mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
@@ -75,10 +86,13 @@ class MyTabbedPane : FlatTabbedPane() {
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
@@ -136,19 +150,26 @@ class MyTabbedPane : FlatTabbedPane() {
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) {
// 如果是手动取消
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)
moveTab(
terminalTabbedManager,
tab,
lastVisitTabIndex
)
}
}
@@ -161,6 +182,7 @@ class MyTabbedPane : FlatTabbedPane() {
}
override fun mouseReleased(e: MouseEvent) {
releasedPoint = e.point
stopDrag()
}
@@ -184,6 +206,80 @@ class MyTabbedPane : FlatTabbedPane() {
}
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 terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return
val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return
val windowScope = frame.getData(DataProviders.WindowScope) ?: return
val oldWindowScope = oldFrame.getData(DataProviders.WindowScope) ?: return
val location = Point(MouseInfo.getPointerInfo().location)
SwingUtilities.convertPointFromScreen(location, tabbedPane)
val index = tabbedPane.indexAtLocation(location.x, location.y)
moveTab(
tabbedManager,
tab,
index
)
TerminalPanelFactory.getInstance(oldWindowScope).removeTerminalPanel(terminalPanel)
TerminalPanelFactory.getInstance(windowScope).addTerminalPanel(terminalPanel)
if (frame.hasFocus()) {
return
}
SwingUtilities.invokeLater {
frame.requestFocus()
tabbedPane.selectedComponent?.requestFocusInWindow()
}
}
private fun moveTab(terminalTabbedManager: TerminalTabbedManager, tab: TerminalTab, lastVisitTabIndex: Int) {
// 如果是手动取消
if (cancelled) {
terminalTabbedManager.addTerminalTab(tabIndex, tab)
} else if (lastVisitTabIndex > 0) {
terminalTabbedManager.addTerminalTab(lastVisitTabIndex, tab)
} else if (lastVisitTabIndex == 0) {
terminalTabbedManager.addTerminalTab(1, tab)
} else {
terminalTabbedManager.addTerminalTab(tab)
}
}
}

View File

@@ -0,0 +1,871 @@
package app.termora
import app.termora.Application.ohMyJson
import app.termora.actions.AnActionEvent
import app.termora.actions.OpenHostAction
import app.termora.transport.SFTPAction
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.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.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
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 var isShowMoreInfo
get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
set(value) = properties.putString("HostTree.showMoreInfo", value.toString())
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()) {
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>"""
}
if (host.protocol == Protocol.SSH) {
text =
"<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;${fontTag.apply("${host.username}@${host.host}")}</html>"
} else if (host.protocol == Protocol.Serial) {
text =
"<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;${fontTag.apply(host.options.serialComm.port)}</html>"
} else if (host.protocol == Protocol.Folder) {
text = "<html>${host.name}${fontTag.apply(" (${node.childCount})")}</html>"
}
}
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
icon = node.getIcon(sel, expanded, hasFocus)
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) {
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 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) }
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.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
val sftpAction = ActionManager.getInstance().getAction(app.termora.Actions.SFTP) as SFTPAction? ?: return
val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return
for (node in nodes) {
sftpAction.connectHost(node, tab)
}
}
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.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
}
}
// 选择文件
val code = chooser.showOpenDialog(owner)
if (code != JFileChooser.APPROVE_OPTION) {
return
}
val file = chooser.selectedFile
properties.putString(
"NewHostTree.ImportHosts.defaultDir",
(if (FileUtils.isDirectory(file)) file else file.parentFile).absolutePath
)
val nodes = when (type) {
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)
}
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 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,
FinalShell,
electerm,
}
}

View File

@@ -0,0 +1,87 @@
package app.termora
import org.apache.commons.lang3.StringUtils
import java.awt.Dimension
import java.awt.Window
import java.util.function.Function
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.JScrollPane
import javax.swing.UIManager
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") - 200, 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
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))
}
})
}
}

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,20 @@ class PtyConnectorFactory : Disposable {
rows: Int = 24, cols: Int = 80,
env: Map<String, String> = emptyMap(),
charset: Charset = StandardCharsets.UTF_8
): PtyConnector {
val command = database.terminal.localShell
val commands = mutableListOf(command)
if (SystemUtils.IS_OS_UNIX) {
commands.add("-l")
}
return createPtyConnector(commands.toTypedArray(), rows, cols, env, charset)
}
fun createPtyConnector(
commands: Array<String>,
rows: Int = 24, cols: Int = 80,
env: Map<String, String> = emptyMap(),
charset: Charset = StandardCharsets.UTF_8
): PtyConnector {
val envs = mutableMapOf<String, String>()
envs.putAll(System.getenv())
@@ -44,17 +58,11 @@ class PtyConnectorFactory : Disposable {
}
}
val command = database.terminal.localShell
val commands = mutableListOf(command)
if (SystemUtils.IS_OS_UNIX) {
commands.add("-l")
}
if (log.isDebugEnabled) {
log.debug("command: {} , envs: {}", commands.joinToString(" "), envs)
}
val ptyProcess = PtyProcessBuilder(commands.toTypedArray())
val ptyProcess = PtyProcessBuilder(commands)
.setEnvironment(envs)
.setInitialRows(rows)
.setInitialColumns(cols)

View File

@@ -1,5 +1,6 @@
package app.termora
import app.termora.actions.DataProviders
import app.termora.terminal.*
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
@@ -23,8 +24,9 @@ abstract class PtyHostTerminalTab(
private var readerJob: Job? = null
private val ptyConnectorDelegate = PtyConnectorDelegate()
protected val terminalPanel =
TerminalPanelFactory.getInstance(windowScope).createTerminalPanel(terminal, ptyConnectorDelegate)
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
protected val terminalPanel = terminalPanelFactory.createTerminalPanel(terminal, ptyConnectorDelegate)
.apply { Disposer.register(this@PtyHostTerminalTab, this) }
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance(windowScope)
init {
@@ -49,12 +51,16 @@ abstract class PtyHostTerminalTab(
startPtyConnectorReader()
// 启动命令
if (host.options.startupCommand.isNotBlank()) {
if (host.options.startupCommand.isNotBlank() && host.protocol != Protocol.SFTPPty) {
coroutineScope.launch(Dispatchers.IO) {
delay(250.milliseconds)
withContext(Dispatchers.Swing) {
ptyConnector.write(host.options.startupCommand)
ptyConnector.write(terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER)))
val charset = ptyConnector.getCharset()
ptyConnector.write(host.options.startupCommand.toByteArray(charset))
ptyConnector.write(
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
.toByteArray(charset)
)
}
}
}
@@ -116,6 +122,7 @@ abstract class PtyHostTerminalTab(
override fun dispose() {
stop()
terminalPanel
super.dispose()
@@ -129,4 +136,12 @@ abstract class PtyHostTerminalTab(
}
abstract suspend fun openPtyConnector(): PtyConnector
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == DataProviders.TerminalPanel) {
return terminalPanel as T?
}
return super.getData(dataKey)
}
}

View File

@@ -0,0 +1,161 @@
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("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)
setLocationRelativeTo(null)
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
return super.getListCellRendererComponent(
list,
if (value is OhKeyPair) value.name else value,
index,
isSelected,
cellHasFocus
)
}
}
for (keyPair in keyManager.getOhKeyPairs()) {
publicKeyComboBox.addItem(keyPair)
}
authenticationTypeComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
switchPasswordComponent()
}
}
usernameTextField.text = host.username
}
override fun createCenterPanel(): JComponent {
authenticationTypeComboBox.addItem(AuthenticationType.Password)
authenticationTypeComboBox.addItem(AuthenticationType.PublicKey)
val formMargin = "7dlu"
val layout = FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref"
)
switchPasswordComponent()
return FormBuilder.create().padding("$formMargin, $formMargin, $formMargin, $formMargin")
.layout(layout)
.add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, 1)
.add(authenticationTypeComboBox).xy(3, 1)
.add("${I18n.getString("termora.new-host.general.username")}:").xy(1, 3)
.add(usernameTextField).xy(3, 3)
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 5)
.add(passwordPanel).xy(3, 5)
.build()
}
private fun switchPasswordComponent() {
passwordPanel.removeAll()
if (authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
passwordPanel.add(passwordPasswordField, BorderLayout.CENTER)
} else if (authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
passwordPanel.add(publicKeyComboBox, BorderLayout.CENTER)
}
passwordPanel.revalidate()
passwordPanel.repaint()
}
override fun createSouthPanel(): JComponent? {
val box = super.createSouthPanel() ?: return null
rememberCheckBox.isFocusable = false
box.add(rememberCheckBox, 0)
return box
}
override fun doCancelAction() {
authentication = Authentication.No
super.doCancelAction()
}
override fun doOKAction() {
val type = authenticationTypeComboBox.selectedItem as AuthenticationType
if (type == AuthenticationType.Password) {
if (passwordPasswordField.password.isEmpty()) {
passwordPasswordField.requestFocusInWindow()
return
}
} else if (type == AuthenticationType.PublicKey) {
if (publicKeyComboBox.selectedItem == null) {
publicKeyComboBox.requestFocusInWindow()
return
}
}
authentication = authentication.copy(
type = type,
password = if (type == AuthenticationType.Password) String(passwordPasswordField.password)
else (publicKeyComboBox.selectedItem as OhKeyPair).id
)
super.doOKAction()
}
fun getAuthentication(): Authentication {
isModal = true
SwingUtilities.invokeLater {
if (usernameTextField.text.isBlank()) {
usernameTextField.requestFocusInWindow()
} else {
passwordPasswordField.requestFocusInWindow()
}
}
isVisible = true
return authentication
}
fun isRemembered(): Boolean {
return rememberCheckBox.isSelected
}
fun getUsername(): String {
return usernameTextField.text
}
}

View File

@@ -0,0 +1,201 @@
package app.termora
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.*
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.Charsets
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter
import org.apache.sshd.common.util.net.SshdSocketAddress
import java.awt.event.KeyEvent
import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import javax.swing.Icon
import javax.swing.SwingUtilities
class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
private val keyManager by lazy { KeyManager.getInstance() }
private val tempFiles = mutableListOf<Path>()
private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand
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).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 {
commands.add("${host.username}@${host.host}")
}
val winSize = terminalPanel.winSize()
val ptyConnector = ptyConnectorFactory.createPtyConnector(
commands.toTypedArray(),
winSize.rows, winSize.cols,
host.options.envs(),
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
)
return ptyConnector
}
private fun setAuthentication(commands: MutableList<String>, host: Host) {
// 如果通过公钥连接
if (host.authentication.type == AuthenticationType.PublicKey) {
val ohKeyPair = keyManager.getOhKeyPair(host.authentication.password)
if (ohKeyPair != null) {
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(ohKeyPair)
val privateKeyPath = Application.createSubTemporaryDir()
val privateKeyFile = Files.createTempFile(privateKeyPath, Application.getName(), StringUtils.EMPTY)
Files.newOutputStream(privateKeyFile)
.use { OpenSSHKeyPairResourceWriter.INSTANCE.writePrivateKey(keyPair, null, null, it) }
commands.add("-i")
commands.add(privateKeyFile.toFile().absolutePath)
tempFiles.add(privateKeyPath)
}
} else if (host.authentication.type == AuthenticationType.Password) {
terminal.getTerminalModel().addDataListener(PasswordReporterDataListener(host).apply {
lastPasswordReporterDataListener = this
})
}
}
override fun stop() {
// 删除密码监听
lastPasswordReporterDataListener?.let { listener ->
SwingUtilities.invokeLater { terminal.getTerminalModel().removeDataListener(listener) }
}
IOUtils.closeQuietly(sshSession)
IOUtils.closeQuietly(sshClient)
tempFiles.removeIf {
FileUtils.deleteQuietly(it.toFile())
true
}
super.stop()
}
override fun getIcon(): Icon {
return Icons.fileFormat
}
private inner class PasswordReporterDataListener(private val host: Host) : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
if (key == VisualTerminal.Written && data is String) {
// 要求输入密码
val line = terminal.getDocument().getScreenLine(terminal.getCursorModel().getPosition().y)
if (line.getText().trim().trimIndent().startsWith("${host.username}@${host.host}'s password:")) {
// 删除密码监听
terminal.getTerminalModel().removeDataListener(this)
val ptyConnector = getPtyConnector()
// password
ptyConnector.write(host.authentication.password.toByteArray(ptyConnector.getCharset()))
// enter
ptyConnector.write(
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
.toByteArray(ptyConnector.getCharset())
)
}
}
}
}
}

View File

@@ -1,5 +1,7 @@
package app.termora
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import app.termora.transport.TransportDataProviders
import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener
@@ -8,12 +10,13 @@ import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
class SFTPTerminalTab : Disposable, TerminalTab {
class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
private val transportPanel by lazy {
TransportPanel().apply {
Disposer.register(this@SFTPTerminalTab, this)
}
private val sftp get() = Database.getDatabase().sftp
private val transportPanel = TransportPanel()
init {
Disposer.register(this, transportPanel)
}
override fun getTitle(): String {
@@ -41,6 +44,11 @@ class SFTPTerminalTab : Disposable, TerminalTab {
override fun canClose(): Boolean {
assertEventDispatchThread()
if (sftp.pinTab) {
return false
}
val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true
if (transportManager.getTransports().isEmpty()) {
return true
@@ -54,4 +62,12 @@ class SFTPTerminalTab : Disposable, TerminalTab {
) == JOptionPane.OK_OPTION
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == TransportDataProviders.TransportPanel) {
return transportPanel as T
}
return null
}
}

View File

@@ -1,5 +1,7 @@
package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.actions.TabReconnectAction
import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor
import app.termora.keyboardinteractive.TerminalUserInteraction
@@ -24,23 +26,30 @@ 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(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, 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
@@ -80,9 +89,34 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
terminal.write("SSH client is opening...\r\n")
}
var host =
this.host.copy(authentication = this.host.authentication.copy(), updateDate = System.currentTimeMillis())
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
val client = SshClients.openClient(host).also { sshClient = it }
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// keyboard interactive
client.userInteraction = TerminalUserInteraction(SwingUtilities.getWindowAncestor(terminalPanel))
client.userInteraction = TerminalUserInteraction(owner)
if (host.authentication.type == AuthenticationType.No) {
withContext(Dispatchers.Swing) {
val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication()
host = host.copy(
authentication = authentication,
username = dialog.getUsername(),
updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(
tab.host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
}
}
val sessionListener = MySessionListener()
val channelListener = MyChannelListener()
@@ -119,13 +153,25 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
override fun channelClosed(channel: Channel, reason: Throwable?) {
coroutineScope.launch(Dispatchers.Swing) {
terminal.write("\r\n\r\n${ControlCharacters.ESC}[31m")
terminal.write("Channel has been disconnected.")
terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
if (reconnectShortcut is KeyShortcut) {
terminal.write(" Type $reconnectShortcut to reconnect.")
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)
}
}
}
}
}
})
@@ -159,35 +205,30 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
}
for (tunneling in host.tunnelings) {
if (tunneling.type == TunnelingType.Local) {
session.startLocalPortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
)
} else if (tunneling.type == TunnelingType.Remote) {
session.startRemotePortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
)
} else if (tunneling.type == TunnelingType.Dynamic) {
session.startDynamicPortForwarding(
SshdSocketAddress(
tunneling.sourceHost,
tunneling.sourcePort
)
)
}
if (log.isInfoEnabled) {
log.info("SSH [{}] started {} port forwarding.", host.name, tunneling.name)
}
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")
}
}
}
}
@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()) {

View File

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

View File

@@ -0,0 +1,61 @@
package app.termora
import app.termora.terminal.PtyConnector
import com.fazecast.jSerialComm.SerialPort
import com.fazecast.jSerialComm.SerialPortDataListener
import com.fazecast.jSerialComm.SerialPortEvent
import java.nio.charset.Charset
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
class SerialPortPtyConnector(
private val serialPort: SerialPort,
private val charset: Charset = Charsets.UTF_8
) : PtyConnector, SerialPortDataListener {
private val queue = LinkedBlockingQueue<Char>()
init {
serialPort.addDataListener(this)
}
override fun read(buffer: CharArray): Int {
buffer[0] = queue.poll(1, TimeUnit.SECONDS) ?: return 0
return 1
}
override fun write(buffer: ByteArray, offset: Int, len: Int) {
serialPort.writeBytes(buffer, len, offset)
}
override fun resize(rows: Int, cols: Int) {
}
override fun waitFor(): Int {
return 0
}
override fun close() {
queue.clear()
serialPort.closePort()
}
override fun getListeningEvents(): Int {
return SerialPort.LISTENING_EVENT_DATA_RECEIVED
}
override fun serialEvent(event: SerialPortEvent) {
if (event.eventType == SerialPort.LISTENING_EVENT_DATA_RECEIVED) {
val data = event.receivedData
if (data.isEmpty()) return
for (c in String(data, charset).toCharArray()) {
queue.add(c)
}
}
}
override fun getCharset(): Charset {
return charset
}
}

View File

@@ -0,0 +1,21 @@
package app.termora
import app.termora.terminal.PtyConnector
import org.apache.commons.io.Charsets
import java.nio.charset.StandardCharsets
import javax.swing.Icon
class SerialTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
override suspend fun openPtyConnector(): PtyConnector {
val serialPort = Serials.openPort(host)
return SerialPortPtyConnector(
serialPort,
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8)
)
}
override fun getIcon(): Icon {
return Icons.plugin
}
}

View File

@@ -0,0 +1,38 @@
package app.termora
import com.fazecast.jSerialComm.SerialPort
object Serials {
fun openPort(host: Host): SerialPort {
val serialComm = host.options.serialComm
val serialPort = SerialPort.getCommPort(serialComm.port)
serialPort.setBaudRate(serialComm.baudRate)
serialPort.setNumDataBits(serialComm.dataBits)
when (serialComm.parity) {
SerialCommParity.None -> serialPort.setParity(SerialPort.NO_PARITY)
SerialCommParity.Mark -> serialPort.setParity(SerialPort.MARK_PARITY)
SerialCommParity.Even -> serialPort.setParity(SerialPort.EVEN_PARITY)
SerialCommParity.Odd -> serialPort.setParity(SerialPort.ODD_PARITY)
SerialCommParity.Space -> serialPort.setParity(SerialPort.SPACE_PARITY)
}
when (serialComm.stopBits) {
"1" -> serialPort.setNumStopBits(SerialPort.ONE_STOP_BIT)
"1.5" -> serialPort.setNumStopBits(SerialPort.ONE_POINT_FIVE_STOP_BITS)
"2" -> serialPort.setNumStopBits(SerialPort.TWO_STOP_BITS)
}
when (serialComm.flowControl) {
SerialCommFlowControl.None -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED)
SerialCommFlowControl.RTS_CTS -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_RTS_ENABLED or SerialPort.FLOW_CONTROL_CTS_ENABLED)
SerialCommFlowControl.XON_XOFF -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_XONXOFF_IN_ENABLED or SerialPort.FLOW_CONTROL_XONXOFF_OUT_ENABLED)
}
if (!serialPort.openPort()) {
throw IllegalStateException("Open serial port [${serialComm.port}] failed")
}
return serialPort
}
}

View File

@@ -4,6 +4,7 @@ import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
@@ -14,14 +15,19 @@ import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager
import app.termora.native.FileChooser
import app.termora.snippet.Snippet
import app.termora.snippet.SnippetManager
import app.termora.sync.SyncConfig
import app.termora.sync.SyncRange
import app.termora.sync.SyncType
import app.termora.sync.SyncerProvider
import app.termora.terminal.CursorStyle
import app.termora.terminal.DataKey
import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalPanel
import app.termora.transport.SFTPAction
import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.*
import com.formdev.flatlaf.util.FontUtils
@@ -32,17 +38,20 @@ import com.jthemedetecor.OsThemeDetector
import com.sun.jna.LastErrorException
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.*
import org.apache.commons.codec.binary.Base64
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.jdesktop.swingx.JXEditorPane
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.awt.event.ItemEvent
import java.io.File
@@ -60,8 +69,10 @@ class SettingsOptionsPane : OptionsPane() {
private val owner get() = SwingUtilities.getWindowAncestor(this@SettingsOptionsPane)
private val database get() = Database.getDatabase()
private val hostManager get() = HostManager.getInstance()
private val snippetManager get() = SnippetManager.getInstance()
private val keymapManager get() = KeymapManager.getInstance()
private val macroManager get() = MacroManager.getInstance()
private val actionManager get() = ActionManager.getInstance()
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
private val keyManager get() = KeyManager.getInstance()
@@ -107,6 +118,7 @@ class SettingsOptionsPane : OptionsPane() {
addOption(AppearanceOption())
addOption(TerminalOption())
addOption(KeyShortcutsOption())
addOption(SFTPOption())
addOption(CloudSyncOption())
addOption(DoormanOption())
addOption(AboutOption())
@@ -189,12 +201,7 @@ class SettingsOptionsPane : OptionsPane() {
if (it.stateChange == ItemEvent.SELECTED) {
appearance.language = languageComboBox.selectedItem as String
SwingUtilities.invokeLater {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.settings.restart.message"),
I18n.getString("termora.settings.restart.title"),
messageType = JOptionPane.INFORMATION_MESSAGE,
)
TermoraRestarter.getInstance().scheduleRestart(owner)
}
}
}
@@ -298,12 +305,16 @@ class SettingsOptionsPane : OptionsPane() {
private inner class TerminalOption : JPanel(BorderLayout()), Option {
private val cursorStyleComboBox = FlatComboBox<CursorStyle>()
private val debugComboBox = YesOrNoComboBox()
private val beepComboBox = YesOrNoComboBox()
private val cursorBlinkComboBox = YesOrNoComboBox()
private val fontComboBox = FlatComboBox<String>()
private val shellComboBox = FlatComboBox<String>()
private val maxRowsTextField = IntSpinner(0, 0)
private val fontSizeTextField = IntSpinner(0, 9, 99)
private val terminalSetting get() = Database.getDatabase().terminal
private val selectCopyComboBox = YesOrNoComboBox()
private val autoCloseTabComboBox = YesOrNoComboBox()
private val floatingToolbarComboBox = YesOrNoComboBox()
init {
initView()
@@ -319,6 +330,26 @@ class SettingsOptionsPane : OptionsPane() {
}
}
autoCloseTabComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.autoCloseTabWhenDisconnected = autoCloseTabComboBox.selectedItem as Boolean
}
}
autoCloseTabComboBox.toolTipText = I18n.getString("termora.settings.terminal.auto-close-tab-description")
floatingToolbarComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.floatingToolbar = floatingToolbarComboBox.selectedItem as Boolean
TerminalPanelFactory.getAllTerminalPanel().forEach { tp ->
if (terminalSetting.floatingToolbar && FloatingToolbarPanel.isPined) {
tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerShow()
} else {
tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerHide()
}
}
}
}
selectCopyComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean
@@ -355,6 +386,19 @@ class SettingsOptionsPane : OptionsPane() {
}
beepComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.beep = beepComboBox.selectedItem as Boolean
}
}
cursorBlinkComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.cursorBlink = cursorBlinkComboBox.selectedItem as Boolean
}
}
shellComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
terminalSetting.localShell = shellComboBox.selectedItem as String
@@ -390,6 +434,11 @@ class SettingsOptionsPane : OptionsPane() {
}
fontComboBox.renderer = object : DefaultListCellRenderer() {
init {
preferredSize = Dimension(preferredSize.width, fontComboBox.preferredSize.height - 2)
maximumSize = Dimension(preferredSize.width, preferredSize.height)
}
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
@@ -423,28 +472,11 @@ class SettingsOptionsPane : OptionsPane() {
shellComboBox.selectedItem = terminalSetting.localShell
val fonts = linkedSetOf(
"JetBrains Mono",
"Source Code Pro",
"Monospaced",
"Andale Mono",
"Ayuthaya",
"Courier New",
"Droid Sans Mono",
"Fira Code",
"PCMyungjo",
"Menlo",
"Monaco",
"Osaka",
"PT Mono",
"SimSong",
)
for (font in FontUtils.getAllFonts()) {
if (fonts.contains(font.family)) {
continue
val fonts = linkedSetOf<String>("JetBrains Mono", "Source Code Pro", "Monospaced")
FontUtils.getAllFonts().forEach {
if (!fonts.contains(it.family)) {
fonts.addLast(it.family)
}
fonts.remove(font.family)
}
for (font in fonts) {
@@ -453,8 +485,12 @@ class SettingsOptionsPane : OptionsPane() {
fontComboBox.selectedItem = terminalSetting.font
debugComboBox.selectedItem = terminalSetting.debug
beepComboBox.selectedItem = terminalSetting.beep
cursorBlinkComboBox.selectedItem = terminalSetting.cursorBlink
cursorStyleComboBox.selectedItem = terminalSetting.cursor
selectCopyComboBox.selectedItem = terminalSetting.selectCopy
autoCloseTabComboBox.selectedItem = terminalSetting.autoCloseTabWhenDisconnected
floatingToolbarComboBox.selectedItem = terminalSetting.floatingToolbar
}
override fun getIcon(isSelected: Boolean): Icon {
@@ -472,9 +508,14 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val beepBtn = JButton(Icons.run)
beepBtn.isFocusable = false
beepBtn.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON)
beepBtn.addActionListener { Toolkit.getDefaultToolkit().beep() }
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
@@ -487,10 +528,19 @@ class SettingsOptionsPane : OptionsPane() {
.add(maxRowsTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows)
.add(debugComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.beep")}:").xy(1, rows)
.add(beepComboBox).xy(3, rows)
.add(beepBtn).xy(5, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.select-copy")}:").xy(1, rows)
.add(selectCopyComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows)
.add(cursorStyleComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.cursor-blink")}:").xy(1, rows)
.add(cursorBlinkComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.floating-toolbar")}:").xy(1, rows)
.add(floatingToolbarComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.auto-close-tab")}:").xy(1, rows)
.add(autoCloseTabComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.local-shell")}:").xy(1, rows)
.add(shellComboBox).xyw(3, rows, 5)
.build()
@@ -514,6 +564,7 @@ class SettingsOptionsPane : OptionsPane() {
val sync get() = database.sync
val hostsCheckBox = JCheckBox(I18n.getString("termora.welcome.my-hosts"))
val keysCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keys"))
val snippetsCheckBox = JCheckBox(I18n.getString("termora.snippet.title"))
val keywordHighlightsCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keyword-highlights"))
val macrosCheckBox = JCheckBox(I18n.getString("termora.macro"))
val keymapCheckBox = JCheckBox(I18n.getString("termora.settings.keymap"))
@@ -550,12 +601,6 @@ class SettingsOptionsPane : OptionsPane() {
}
}
if (typeComboBox.selectedItem == SyncType.Gitee) {
gistTextField.trailingComponent = null
} else {
gistTextField.trailingComponent = visitGistBtn
}
removeAll()
add(getCenterComponent(), BorderLayout.CENTER)
revalidate()
@@ -624,6 +669,7 @@ class SettingsOptionsPane : OptionsPane() {
keysCheckBox.addActionListener { refreshButtons() }
hostsCheckBox.addActionListener { refreshButtons() }
snippetsCheckBox.addActionListener { refreshButtons() }
keywordHighlightsCheckBox.addActionListener { refreshButtons() }
}
@@ -631,6 +677,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun refreshButtons() {
sync.rangeKeyPairs = keysCheckBox.isSelected
sync.rangeHosts = hostsCheckBox.isSelected
sync.rangeSnippets = snippetsCheckBox.isSelected
sync.rangeKeywordHighlights = keywordHighlightsCheckBox.isSelected
downloadConfigButton.isEnabled = keysCheckBox.isSelected || hostsCheckBox.isSelected
@@ -642,13 +689,40 @@ class SettingsOptionsPane : OptionsPane() {
private fun export() {
assertEventDispatchThread()
val passwordField = OutlinePasswordField()
val panel = object : JPanel(BorderLayout()) {
override fun requestFocusInWindow(): Boolean {
return passwordField.requestFocusInWindow()
}
}
val label = JLabel(I18n.getString("termora.settings.sync.export-encrypt") + StringUtils.SPACE.repeat(25))
label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
panel.add(label, BorderLayout.NORTH)
panel.add(passwordField, BorderLayout.CENTER)
var password = StringUtils.EMPTY
if (OptionPane.showConfirmDialog(
owner,
panel,
optionType = JOptionPane.YES_NO_OPTION,
initialValue = passwordField
) == JOptionPane.YES_OPTION
) {
password = String(passwordField.password).trim()
}
val fileChooser = FileChooser()
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
fileChooser.win32Filters.add(Pair("All Files", listOf("*")))
fileChooser.win32Filters.add(Pair("JSON files", listOf("json")))
fileChooser.showSaveDialog(owner, "${Application.getName()}.json").thenAccept { file ->
if (file != null) {
SwingUtilities.invokeLater { exportText(file) }
SwingUtilities.invokeLater { exportText(file, password) }
}
}
}
@@ -665,6 +739,7 @@ class SettingsOptionsPane : OptionsPane() {
}
}
@Suppress("DuplicatedCode")
private fun importFromFile(file: File) {
if (!file.exists()) {
return
@@ -695,7 +770,79 @@ class SettingsOptionsPane : OptionsPane() {
return
}
val json = jsonResult.getOrNull() ?: return
var json = jsonResult.getOrNull() ?: return
// 如果加密了 则解密数据
if (json["encryption"]?.jsonPrimitive?.booleanOrNull == true) {
val data = json["data"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
if (data.isBlank()) {
OptionPane.showMessageDialog(
owner, "Data file corruption",
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
while (true) {
val passwordField = OutlinePasswordField()
val panel = object : JPanel(BorderLayout()) {
override fun requestFocusInWindow(): Boolean {
return passwordField.requestFocusInWindow()
}
}
val label = JLabel("Please enter the password" + StringUtils.SPACE.repeat(25))
label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
panel.add(label, BorderLayout.NORTH)
panel.add(passwordField, BorderLayout.CENTER)
if (OptionPane.showConfirmDialog(
owner,
panel,
optionType = JOptionPane.YES_NO_OPTION,
initialValue = passwordField
) != JOptionPane.YES_OPTION
) {
return
}
if (passwordField.password.isEmpty()) {
OptionPane.showMessageDialog(
owner, I18n.getString("termora.doorman.unlock-data"),
messageType = JOptionPane.ERROR_MESSAGE
)
continue
}
val password = String(passwordField.password)
val key = PBKDF2.generateSecret(
password.toCharArray(),
password.toByteArray(), keyLength = 128
)
try {
val dataText = AES.ECB.decrypt(key, Base64.decodeBase64(data)).toString(Charsets.UTF_8)
val dataJsonResult = ohMyJson.runCatching { decodeFromString<JsonObject>(dataText) }
if (dataJsonResult.isFailure) {
val e = dataJsonResult.exceptionOrNull() ?: return
OptionPane.showMessageDialog(
owner, ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
json = dataJsonResult.getOrNull() ?: return
break
} catch (_: Exception) {
OptionPane.showMessageDialog(
owner, I18n.getString("termora.doorman.password-wrong"),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
}
if (ranges.contains(SyncRange.Hosts)) {
val hosts = json["hosts"]
if (hosts is JsonArray) {
@@ -707,6 +854,17 @@ class SettingsOptionsPane : OptionsPane() {
}
}
if (ranges.contains(SyncRange.Snippets)) {
val snippets = json["snippets"]
if (snippets is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<Snippet>>(snippets.jsonArray) }.onSuccess {
for (snippet in it) {
snippetManager.addSnippet(snippet)
}
}
}
}
if (ranges.contains(SyncRange.KeyPairs)) {
val keyPairs = json["keyPairs"]
if (keyPairs is JsonArray) {
@@ -756,9 +914,9 @@ class SettingsOptionsPane : OptionsPane() {
)
}
private fun exportText(file: File) {
private fun exportText(file: File, password: String) {
val syncConfig = getSyncConfig()
val text = ohMyJson.encodeToString(buildJsonObject {
var text = ohMyJson.encodeToString(buildJsonObject {
val now = System.currentTimeMillis()
put("exporter", SystemUtils.USER_NAME)
put("version", Application.getVersion())
@@ -768,6 +926,9 @@ class SettingsOptionsPane : OptionsPane() {
if (syncConfig.ranges.contains(SyncRange.Hosts)) {
put("hosts", ohMyJson.encodeToJsonElement(hostManager.hosts()))
}
if (syncConfig.ranges.contains(SyncRange.Snippets)) {
put("snippets", ohMyJson.encodeToJsonElement(snippetManager.snippets()))
}
if (syncConfig.ranges.contains(SyncRange.KeyPairs)) {
put("keyPairs", ohMyJson.encodeToJsonElement(keyManager.getOhKeyPairs()))
}
@@ -797,6 +958,19 @@ class SettingsOptionsPane : OptionsPane() {
put("terminal", ohMyJson.encodeToJsonElement(database.terminal.getProperties()))
})
})
if (password.isNotBlank()) {
val key = PBKDF2.generateSecret(
password.toCharArray(),
password.toByteArray(), keyLength = 128
)
text = ohMyJson.encodeToString(buildJsonObject {
put("encryption", true)
put("data", AES.ECB.encrypt(key, text.toByteArray(Charsets.UTF_8)).encodeBase64String())
})
}
file.outputStream().use {
IOUtils.write(text, it, StandardCharsets.UTF_8)
OptionPane.openFileInFolder(
@@ -824,6 +998,9 @@ class SettingsOptionsPane : OptionsPane() {
if (keymapCheckBox.isSelected) {
range.add(SyncRange.Keymap)
}
if (snippetsCheckBox.isSelected) {
range.add(SyncRange.Snippets)
}
return SyncConfig(
type = typeComboBox.selectedItem as SyncType,
token = String(tokenTextField.password),
@@ -900,6 +1077,7 @@ class SettingsOptionsPane : OptionsPane() {
keymapCheckBox.isEnabled = false
keywordHighlightsCheckBox.isEnabled = false
hostsCheckBox.isEnabled = false
snippetsCheckBox.isEnabled = false
domainTextField.isEnabled = false
if (push) {
@@ -929,6 +1107,7 @@ class SettingsOptionsPane : OptionsPane() {
uploadConfigButton.isEnabled = true
keysCheckBox.isEnabled = true
hostsCheckBox.isEnabled = true
snippetsCheckBox.isEnabled = true
typeComboBox.isEnabled = true
macrosCheckBox.isEnabled = true
keymapCheckBox.isEnabled = true
@@ -987,14 +1166,17 @@ class SettingsOptionsPane : OptionsPane() {
typeComboBox.addItem(SyncType.GitHub)
typeComboBox.addItem(SyncType.GitLab)
typeComboBox.addItem(SyncType.Gitee)
typeComboBox.addItem(SyncType.WebDAV)
hostsCheckBox.isFocusable = false
snippetsCheckBox.isFocusable = false
keysCheckBox.isFocusable = false
keywordHighlightsCheckBox.isFocusable = false
macrosCheckBox.isFocusable = false
keymapCheckBox.isFocusable = false
hostsCheckBox.isSelected = sync.rangeHosts
snippetsCheckBox.isSelected = sync.rangeSnippets
keysCheckBox.isSelected = sync.rangeKeyPairs
keywordHighlightsCheckBox.isSelected = sync.rangeKeywordHighlights
macrosCheckBox.isSelected = sync.rangeMacros
@@ -1005,7 +1187,31 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.text = sync.token
domainTextField.trailingComponent = JButton(Icons.externalLink).apply {
addActionListener {
if (typeComboBox.selectedItem == SyncType.GitLab) {
Application.browse(URI.create("https://docs.gitlab.com/ee/api/snippets.html"))
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
val url = domainTextField.text
if (url.isNullOrBlank()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.settings.sync.webdav.help")
)
} else {
val uri = URI.create(url)
val sb = StringBuilder()
sb.append(uri.scheme).append("://")
if (tokenTextField.password.isNotEmpty() && gistTextField.text.isNotBlank()) {
sb.append(String(tokenTextField.password)).append(":").append(gistTextField.text)
sb.append('@')
}
sb.append(uri.authority).append(uri.path)
if (!uri.query.isNullOrBlank()) {
sb.append('?').append(uri.query)
}
Application.browse(URI.create(sb.toString()))
}
}
}
}
@@ -1015,12 +1221,15 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null
if (typeComboBox.selectedItem == SyncType.GitLab) {
if (domainTextField.text.isBlank()) {
if (typeComboBox.selectedItem == SyncType.GitLab) {
domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api")
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
domainTextField.text = sync.domain
}
}
val lastSyncTime = sync.lastSyncTime
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${
if (lastSyncTime > 0) DateFormatUtils.format(
@@ -1054,7 +1263,7 @@ class SettingsOptionsPane : OptionsPane() {
.layout(
FormLayout(
"left:pref, $formMargin, left:pref, $formMargin, left:pref",
"pref, $formMargin, pref"
"pref, 2dlu, pref"
)
)
.add(hostsCheckBox).xy(1, 1)
@@ -1062,6 +1271,7 @@ class SettingsOptionsPane : OptionsPane() {
.add(keywordHighlightsCheckBox).xy(5, 1)
.add(macrosCheckBox).xy(1, 3)
.add(keymapCheckBox).xy(3, 3)
.add(snippetsCheckBox).xy(5, 3)
.build()
var rows = 1
@@ -1069,17 +1279,37 @@ class SettingsOptionsPane : OptionsPane() {
val builder = FormBuilder.create().layout(layout).debug(false)
val box = Box.createHorizontalBox()
box.add(typeComboBox)
if (typeComboBox.selectedItem == SyncType.GitLab) {
if (typeComboBox.selectedItem == SyncType.GitLab || typeComboBox.selectedItem == SyncType.WebDAV) {
box.add(Box.createHorizontalStrut(4))
box.add(domainTextField)
}
builder.add("${I18n.getString("termora.settings.sync.type")}:").xy(1, rows)
.add(box).xy(3, rows).apply { rows += step }
builder.add("${I18n.getString("termora.settings.sync.token")}:").xy(1, rows)
.add(tokenTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.gist")}:").xy(1, rows)
.add(gistTextField).xy(3, rows).apply { rows += step }
val isWebDAV = typeComboBox.selectedItem == SyncType.WebDAV
val tokenText = if (isWebDAV) {
I18n.getString("termora.new-host.general.username")
} else {
I18n.getString("termora.settings.sync.token")
}
val gistText = if (isWebDAV) {
I18n.getString("termora.new-host.general.password")
} else {
I18n.getString("termora.settings.sync.gist")
}
if (typeComboBox.selectedItem == SyncType.Gitee || isWebDAV) {
gistTextField.trailingComponent = null
} else {
gistTextField.trailingComponent = visitGistBtn
}
builder.add("${tokenText}:").xy(1, rows)
.add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step }
.add("${gistText}:").xy(1, rows)
.add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows)
.add(rangeBox).xy(3, rows).apply { rows += step }
// Sync buttons
@@ -1100,6 +1330,105 @@ class SettingsOptionsPane : OptionsPane() {
}
}
private inner class SFTPOption : JPanel(BorderLayout()), Option {
private val editCommandField = OutlineTextField(255)
private val sftpCommandField = OutlineTextField(255)
private val pinTabComboBox = YesOrNoComboBox()
private val sftp get() = database.sftp
private val sftpAction get() = actionManager.getAction(Actions.SFTP) as SFTPAction
init {
initView()
initEvents()
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
editCommandField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
sftp.editCommand = editCommandField.text
}
})
sftpCommandField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
sftp.sftpCommand = sftpCommandField.text
}
})
pinTabComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
sftp.pinTab = pinTabComboBox.selectedItem as Boolean
for (window in TermoraFrameManager.getInstance().getWindows()) {
val evt = AnActionEvent(window, StringUtils.EMPTY, EventObject(window))
if (pinTabComboBox.selectedItem == true) {
sftpAction.openOrCreateSFTPTerminalTab(evt)
}
val tabbed = evt.getData(DataProviders.TabbedPane) ?: continue
val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue
for ((index, tab) in manager.getTerminalTabs().withIndex()) {
if (tab is SFTPTerminalTab) {
tabbed.setTabClosable(index, pinTabComboBox.selectedItem != true)
break
}
}
}
}
}
}
private fun initView() {
if (SystemInfo.isWindows || SystemInfo.isLinux) {
editCommandField.placeholderText = "notepad {0}"
} else if (SystemInfo.isMacOS) {
editCommandField.placeholderText = "open -a TextEdit {0}"
}
if (SystemInfo.isWindows) {
sftpCommandField.placeholderText = "sftp.exe"
} else {
sftpCommandField.placeholderText = "sftp"
}
editCommandField.text = sftp.editCommand
sftpCommandField.text = sftp.sftpCommand
pinTabComboBox.selectedItem = sftp.pinTab
}
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, 30dlu",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, 1)
builder.add(pinTabComboBox).xy(3, 1)
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, 3)
builder.add(editCommandField).xy(3, 3)
builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, 5)
builder.add(sftpCommandField).xy(3, 5)
return builder.build()
}
}
private inner class AboutOption : JPanel(BorderLayout()), Option {
init {
@@ -1311,13 +1640,15 @@ class SettingsOptionsPane : OptionsPane() {
val hosts = hostManager.hosts()
val keyPairs = keyManager.getOhKeyPairs()
val snippets = snippetManager.snippets()
// 获取到安全的属性,如果设置密码那表示之前并未加密
// 这里取出来之后重新存储加密
val properties = database.getSafetyProperties().map { Pair(it, it.getProperties()) }
val key = doorman.work(passwordTextField.password)
hosts.forEach { hostManager.addHost(it, false) }
hosts.forEach { hostManager.addHost(it) }
snippets.forEach { snippetManager.addSnippet(it) }
keyPairs.forEach { keyManager.addOhKeyPair(it) }
for (e in properties) {
for ((k, v) in e.second) {

View File

@@ -0,0 +1,343 @@
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, 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 {
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) {}
protected open fun refreshNode(node: SimpleTreeNode<*>) {
val state = TreeUtils.saveExpansionState(tree)
val rows = selectionRows
model.reload(node)
TreeUtils.loadExpansionState(tree, state)
super.setSelectionRows(rows)
}
/**
* 包含孙子
*/
open fun getSelectionSimpleTreeNodes(include: Boolean = false): List<SimpleTreeNode<*>> {
val paths = selectionPaths ?: return emptyList()
if (paths.isEmpty()) return emptyList()
val nodes = mutableListOf<SimpleTreeNode<*>>()
val parents = paths.mapNotNull { it.lastPathComponent }
.filterIsInstance<SimpleTreeNode<*>>().toMutableList()
if (include) {
while (parents.isNotEmpty()) {
val node = parents.removeFirst()
nodes.add(node)
parents.addAll(node.children().toList().filterIsInstance<SimpleTreeNode<*>>())
}
}
return if (include) nodes else parents
}
protected open fun isCellEditable(e: EventObject?): Boolean {
return getLastSelectedPathNode() != model.root
}
protected open fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>) {
}
private class MoveNodeTransferable(val nodes: List<SimpleTreeNode<*>>) : Transferable {
companion object {
val dataFlavor =
DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveNodeTransferable::class.java.name}")
}
override fun getTransferDataFlavors(): Array<DataFlavor> {
return arrayOf(dataFlavor)
}
override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean {
return dataFlavor == flavor
}
override fun getTransferData(flavor: DataFlavor?): Any {
if (flavor == dataFlavor) {
return nodes
}
throw UnsupportedFlavorException(flavor)
}
}
}

View File

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

View File

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

View File

@@ -2,28 +2,49 @@ package app.termora
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize
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.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.ClientSession
import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.PtyChannelConfiguration
import org.apache.sshd.common.config.keys.KeyUtils
import org.apache.sshd.common.global.KeepAliveHandler
import org.apache.sshd.common.kex.BuiltinDHFactories
import org.apache.sshd.common.keyprovider.KeyIdentityProvider
import org.apache.sshd.common.util.net.SshdSocketAddress
import org.apache.sshd.core.CoreModuleProperties
import org.apache.sshd.server.forward.AcceptAllForwardingFilter
import org.apache.sshd.server.forward.RejectAllForwardingFilter
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.eclipse.jgit.internal.transport.sshd.JGitSshClient
import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
import org.eclipse.jgit.transport.sshd.ProxyData
import org.slf4j.LoggerFactory
import java.awt.Window
import java.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.atomic.AtomicBoolean
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import kotlin.math.max
object SshClients {
@@ -58,6 +79,34 @@ 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())
}
/**
* 打开一个会话
*/
@@ -88,7 +137,7 @@ object SshClients {
val sessions = mutableListOf<ClientSession>()
for (i in 0 until jumpHosts.size) {
val currentHost = jumpHosts[i]
sessions.add(doOpenSession(currentHost, client))
sessions.add(doOpenSession(currentHost, client, i != 0))
// 如果有下一跳
if (i < jumpHosts.size - 1) {
@@ -102,15 +151,34 @@ object SshClients {
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)
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
}
}
return sessions.last()
}
private fun doOpenSession(host: Host, client: SshClient): ClientSession {
val session = client.connect(host.username, host.host, host.port)
fun isMiddleware(session: ClientSession): Boolean {
if (session is JGitClientSession) {
if (session.hostConfigEntry.properties["Middleware"]?.toBoolean() == true) {
return true
}
}
return false
}
/**
* @param middleware 如果为 true 表示是跳板
*/
private fun doOpenSession(host: Host, client: SshClient, middleware: Boolean = false): ClientSession {
val entry = HostConfigEntry()
entry.port = host.port
entry.username = host.username
entry.hostName = host.host
entry.setProperty("Middleware", middleware.toString())
val session = client.connect(entry)
.verify(timeout).session
if (host.authentication.type == AuthenticationType.Password) {
session.addPasswordIdentity(host.authentication.password)
@@ -126,6 +194,41 @@ object SshClients {
return session
}
fun openTunneling(session: ClientSession, host: Host, tunneling: Tunneling): SshdSocketAddress {
val sshdSocketAddress = if (tunneling.type == TunnelingType.Local) {
session.startLocalPortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
)
} else if (tunneling.type == TunnelingType.Remote) {
session.startRemotePortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
)
} else if (tunneling.type == TunnelingType.Dynamic) {
session.startDynamicPortForwarding(
SshdSocketAddress(
tunneling.sourceHost,
tunneling.sourcePort
)
)
} else {
SshdSocketAddress.LOCALHOST_ADDRESS
}
if (log.isInfoEnabled) {
log.info(
"SSH [{}] started {} port forwarding. host: {} , port: {}",
host.name,
tunneling.name,
sshdSocketAddress.hostName,
sshdSocketAddress.port
)
}
return sshdSocketAddress
}
/**
* 打开一个客户端
@@ -156,6 +259,11 @@ object SshClients {
builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY)
val sshClient = builder.build() as JGitSshClient
// https://github.com/TermoraDev/termora/issues/180
// JGit 会尝试读取本地的私钥或缓存的私钥
sshClient.keyIdentityProvider = KeyIdentityProvider { mutableListOf() }
val heartbeatInterval = max(host.options.heartbeatInterval, 3)
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong()))
CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true)
@@ -186,3 +294,93 @@ object SshClients {
return sshClient
}
}
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
override fun verifyServerKey(
clientSession: ClientSession,
remoteAddress: SocketAddress,
serverKey: PublicKey
): Boolean {
if (SshClients.isMiddleware(clientSession)) {
return true
}
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait {
result.set(
OptionPane.showConfirmDialog(
parentComponent = owner,
message = I18n.getString(
"termora.host.verify-server-key",
remoteAddress.toString().replace("/", StringUtils.EMPTY),
KeyUtils.getKeyType(serverKey),
KeyUtils.getFingerPrint(serverKey)
),
optionType = JOptionPane.OK_CANCEL_OPTION,
messageType = JOptionPane.WARNING_MESSAGE,
) == JOptionPane.OK_OPTION
)
}
return result.get()
}
override fun acceptModifiedServerKey(
clientSession: ClientSession?,
remoteAddress: SocketAddress?,
entry: KnownHostEntry?,
expected: PublicKey?,
actual: PublicKey?
): Boolean {
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait {
result.set(
OptionPane.showConfirmDialog(
parentComponent = owner,
message = I18n.getString(
"termora.host.modified-server-key",
remoteAddress.toString().replace("/", StringUtils.EMPTY),
KeyUtils.getKeyType(expected),
KeyUtils.getFingerPrint(expected),
KeyUtils.getKeyType(actual),
KeyUtils.getFingerPrint(actual),
),
optionType = JOptionPane.OK_CANCEL_OPTION,
messageType = JOptionPane.WARNING_MESSAGE,
) == JOptionPane.OK_OPTION
)
}
return result.get()
}
}
class DialogServerKeyVerifier(
owner: Window,
) : KnownHostsServerKeyVerifier(
MyDialogServerKeyVerifier(owner),
Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts")
) {
init {
modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor
}
override fun updateKnownHostsFile(
clientSession: ClientSession?,
remoteAddress: SocketAddress?,
serverKey: PublicKey?,
file: Path?,
knownHosts: Collection<HostEntryPair?>?
): KnownHostEntry? {
if (clientSession is JGitClientSession) {
if (SshClients.isMiddleware(clientSession)) {
return null
}
}
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts)
}
}

View File

@@ -21,6 +21,11 @@ class TerminalFactory private constructor() : Disposable {
// 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
@@ -51,6 +56,11 @@ class TerminalFactory private constructor() : Disposable {
return colorPalette
}
override fun bell() {
if (config.beep) {
super.bell()
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(key: DataKey<T>): T {

View File

@@ -1,39 +1,67 @@
package app.termora
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 kotlinx.coroutines.*
import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener
import javax.swing.SwingUtilities
import kotlin.time.Duration.Companion.milliseconds
class TerminalPanelFactory {
class TerminalPanelFactory : Disposable {
private val terminalPanels = mutableListOf<TerminalPanel>()
companion object {
private val Factory = DataKey(TerminalPanelFactory::class)
fun getInstance(scope: Scope): TerminalPanelFactory {
return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
}
fun getAllTerminalPanel(): Array<TerminalPanel> {
return ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }
.flatMap { it.terminalPanels }.toTypedArray()
}
}
init {
// repaint
Painter.getInstance()
}
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
val terminalPanel = TerminalPanel(terminal, ptyConnector)
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
terminalPanels.add(terminalPanel)
terminal.getTerminalModel().setData(Factory, this)
Disposer.register(terminalPanel, object : Disposable {
override fun dispose() {
if (terminal.getTerminalModel().hasData(Factory)) {
terminal.getTerminalModel().getData(Factory).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() }
}
@@ -47,4 +75,39 @@ class TerminalPanelFactory {
}
}
fun removeTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.remove(terminalPanel)
}
fun addTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.add(terminalPanel)
terminalPanel.terminal.getTerminalModel().setData(Factory, this)
}
private class Painter : Disposable {
companion object {
fun getInstance(): Painter {
return ApplicationScope.forApplicationScope().getOrCreate(Painter::class) { Painter() }
}
}
private val coroutineScope = CoroutineScope(Dispatchers.IO)
init {
coroutineScope.launch {
while (coroutineScope.isActive) {
delay(500.milliseconds)
SwingUtilities.invokeLater {
ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }.forEach { it.repaintAll() }
}
}
}
}
override fun dispose() {
coroutineScope.cancel()
}
}
}

View File

@@ -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 {
/**
* 标题

View File

@@ -18,11 +18,8 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.beans.PropertyChangeListener
import java.util.*
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.*
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
import javax.swing.SwingUtilities
import kotlin.math.min
class TerminalTabbed(
@@ -76,21 +73,19 @@ class TerminalTabbed(
tabbedPane.addPropertyChangeListener("selectedIndex") { evt ->
val oldIndex = evt.oldValue as Int
val newIndex = evt.newValue as Int
if (oldIndex >= 0 && tabs.size > newIndex) {
tabs[oldIndex].onLostFocus()
}
tabbedPane.getComponentAt(newIndex).requestFocusInWindow()
if (newIndex >= 0 && tabs.size > newIndex) {
tabs[newIndex].onGrabFocus()
}
}
// 选择变动
tabbedPane.addChangeListener {
if (tabbedPane.selectedIndex >= 0) {
val c = tabbedPane.getComponentAt(tabbedPane.selectedIndex)
c.requestFocusInWindow()
}
}
// 右键菜单
tabbedPane.addMouseListener(object : MouseAdapter() {
@@ -177,6 +172,9 @@ class TerminalTabbed(
// 新的获取到焦点
tabs[tabbedPane.selectedIndex].onGrabFocus()
// 新的真正获取焦点
tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow()
if (disposable) {
Disposer.dispose(tab)
}
@@ -238,6 +236,17 @@ class TerminalTabbed(
}
})
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()
// 关闭
@@ -264,7 +273,7 @@ class TerminalTabbed(
}
close.isEnabled = c !is WelcomePanel
close.isEnabled = tab.canClose()
rename.isEnabled = close.isEnabled
clone.isEnabled = close.isEnabled
openInNewWindow.isEnabled = close.isEnabled
@@ -290,7 +299,7 @@ class TerminalTabbed(
}
private fun addTab(index: Int, tab: TerminalTab) {
private fun addTab(index: Int, tab: TerminalTab, selected: Boolean) {
val c = tab.getJComponent()
val title = (c.getClientProperty(titleProperty) ?: tab.getTitle()).toString()
@@ -301,16 +310,53 @@ class TerminalTabbed(
StringUtils.EMPTY,
index
)
c.putClientProperty(titleProperty, title)
// 设置标题
c.putClientProperty(titleProperty, title)
// 监听 icons 变化
tab.addPropertyChangeListener(iconListener)
tabs.add(index, tab)
if (selected) {
tabbedPane.selectedIndex = index
}
tabbedPane.setTabClosable(index, tab.canClose())
Disposer.register(this, tab)
}
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
if (!SFTPPtyTerminalTab.canSupports) {
OptionPane.showMessageDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.tabbed.contextmenu.sftp-not-install"),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
var host = tab.host
if (host.protocol == Protocol.SSH) {
val envs = tab.host.options.envs().toMutableMap()
val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel()
?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY
if (currentDir.isNotBlank()) {
envs["CurrentDir"] = currentDir
}
host = host.copy(
protocol = Protocol.SFTPPty, updateDate = System.currentTimeMillis(),
options = host.options.copy(env = envs.toPropertiesString())
)
}
openHostAction.actionPerformed(OpenHostActionEvent(this, host, evt))
}
/**
* 对着 ToolBar 右键
*/
@@ -399,12 +445,12 @@ class TerminalTabbed(
override fun dispose() {
}
override fun addTerminalTab(tab: TerminalTab) {
addTab(tabs.size, tab)
override fun addTerminalTab(tab: TerminalTab, selected: Boolean) {
addTab(tabs.size, tab, selected)
}
override fun addTerminalTab(index: Int, tab: TerminalTab) {
addTab(index, tab)
override fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean) {
addTab(index, tab, selected)
}
override fun getSelectedTerminalTab(): TerminalTab? {

View File

@@ -1,8 +1,8 @@
package app.termora
interface TerminalTabbedManager {
fun addTerminalTab(tab: TerminalTab)
fun addTerminalTab(index: Int, 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)

View File

@@ -1,7 +1,6 @@
package app.termora
import app.termora.actions.ActionManager
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders
@@ -12,7 +11,6 @@ import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import java.awt.Dimension
import java.awt.Insets
import java.awt.KeyboardFocusManager
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.*
@@ -32,7 +30,6 @@ fun assertEventDispatchThread() {
class TermoraFrame : JFrame(), DataProvider {
private val actionManager get() = ActionManager.getInstance()
private val id = UUID.randomUUID().toString()
private val windowScope = ApplicationScope.forWindowScope(this)
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
@@ -42,7 +39,7 @@ class TermoraFrame : JFrame(), DataProvider {
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
private val dataProviderSupport = DataProviderSupport()
private val welcomePanel = WelcomePanel(windowScope)
private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() }
private val sftp get() = Database.getDatabase().sftp
init {
@@ -103,6 +100,13 @@ class TermoraFrame : JFrame(), DataProvider {
minimumSize = Dimension(640, 400)
terminalTabbed.addTerminalTab(welcomePanel)
// 下一次事件循环检测是否固定 SFTP
SwingUtilities.invokeLater {
if (sftp.pinTab) {
terminalTabbed.addTerminalTab(SFTPTerminalTab(), false)
}
}
// macOS 要避开左边的控制栏
if (SystemInfo.isMacOS) {
val left = max(titleBar.leftInset.toInt(), 76)
@@ -116,6 +120,7 @@ class TermoraFrame : JFrame(), DataProvider {
Disposer.register(windowScope, terminalTabbed)
add(terminalTabbed)
dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane)
dataProviderSupport.addData(DataProviders.TermoraFrame, this)
dataProviderSupport.addData(DataProviders.WindowScope, windowScope)
}

View File

@@ -2,9 +2,13 @@ package app.termora
import com.formdev.flatlaf.util.SystemInfo
import org.slf4j.LoggerFactory
import java.awt.Frame
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.WindowConstants.DISPOSE_ON_CLOSE
import javax.swing.JOptionPane
import javax.swing.UIManager
import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
import kotlin.math.max
import kotlin.system.exitProcess
class TermoraFrameManager {
@@ -18,14 +22,37 @@ class TermoraFrameManager {
}
}
private val frames = mutableListOf<TermoraFrame>()
private val properties get() = Database.getDatabase().properties
fun createWindow(): TermoraFrame {
val frame = TermoraFrame()
registerCloseCallback(frame)
val frame = TermoraFrame().apply { registerCloseCallback(this) }
frame.title = if (SystemInfo.isLinux) null else Application.getName()
frame.defaultCloseOperation = DISPOSE_ON_CLOSE
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)
return frame
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))
}
}
return frame.apply { frames.add(this) }
}
fun getWindows(): Array<TermoraFrame> {
return frames.toTypedArray()
}
@@ -33,8 +60,16 @@ class TermoraFrameManager {
window.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
// 存储位置信息
saveFrameRectangle(window)
// 删除
frames.remove(window)
// dispose windowScope
Disposer.dispose(ApplicationScope.forWindowScope(e.window))
val windowScope = ApplicationScope.forWindowScope(e.window)
Disposer.disposeChildren(windowScope, null)
Disposer.dispose(windowScope)
val windowScopes = ApplicationScope.windowScopes()
@@ -43,6 +78,21 @@ class TermoraFrameManager {
this@TermoraFrameManager.dispose()
}
}
override fun windowClosing(e: WindowEvent) {
if (ApplicationScope.windowScopes().size == 1) {
if (OptionPane.showConfirmDialog(
window,
I18n.getString("termora.quit-confirm", Application.getName()),
optionType = JOptionPane.YES_NO_OPTION,
) == JOptionPane.YES_OPTION
) {
window.dispose()
}
} else {
window.dispose()
}
}
})
}
@@ -52,9 +102,34 @@ class TermoraFrameManager {
try {
Disposer.getTree().assertIsEmpty(true)
} catch (e: Exception) {
log.error(e.message)
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)
}
private data class FrameRectangle(
val x: Int, val y: Int, val w: Int, val h: Int, val s: Int
) {
val isMaximized get() = (s and Frame.MAXIMIZED_BOTH) == Frame.MAXIMIZED_BOTH
}
}

View File

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

View File

@@ -6,6 +6,7 @@ import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import app.termora.findeverywhere.FindEverywhereAction
import app.termora.snippet.SnippetAction
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.WindowDecorations
@@ -42,6 +43,7 @@ class TermoraToolBar(
*/
fun getAllActions(): List<ToolBarAction> {
return listOf(
ToolBarAction(SnippetAction.SNIPPET, true),
ToolBarAction(Actions.SFTP, true),
ToolBarAction(Actions.TERMINAL_LOGGER, true),
ToolBarAction(Actions.MACRO, true),
@@ -109,6 +111,10 @@ class TermoraToolBar(
toolbar.add(Box.createHorizontalGlue())
if (SystemInfo.isLinux || SystemInfo.isWindows) {
toolbar.add(Box.createHorizontalStrut(16))
}
// update btn
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))

View File

@@ -99,6 +99,8 @@ class OutlinePasswordField(
styleMap = mapOf(
"showRevealButton" to true
)
putClientProperty("JPasswordField.cutCopyAllowed", true)
}
}

View File

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

View File

@@ -4,6 +4,7 @@ import app.termora.Application.ohMyJson
import kotlinx.serialization.json.*
import okhttp3.Request
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.commonmark.node.BulletList
import org.commonmark.node.Heading
import org.commonmark.node.Paragraph
@@ -59,7 +60,6 @@ class UpdaterManager private constructor() {
val isSelf get() = this == self
}
private val properties get() = Database.getDatabase().properties
var lastVersion = LatestVersion.self
fun fetchLatestVersion(): LatestVersion {
@@ -69,6 +69,9 @@ class UpdaterManager private constructor() {
.build()
val response = Application.httpClient.newCall(request).execute()
if (!response.isSuccessful) {
if (log.isErrorEnabled) {
log.error("Failed to fetch latest version, response was ${response.code}")
}
return LatestVersion.self
}
@@ -97,7 +100,14 @@ class UpdaterManager private constructor() {
}
val parser = Parser.builder().build()
val document = parser.parse("# ${name.trim()}\n${body.trim()}")
val document = parser.parse(
"# 🎉 ${name.trim()} (${
DateFormatUtils.format(
publishedDate,
"yyyy-MM-dd"
)
}) \n${body.trim()}"
)
val renderer = HtmlRenderer.builder()
.attributeProviderFactory {
AttributeProvider { node, _, attributes ->
@@ -106,7 +116,7 @@ class UpdaterManager private constructor() {
attributes["style"] = "margin: 5px 0;"
} else if (node is BulletList) {
attributes["style"] = "margin: 0 20px;"
}else if(node is Paragraph){
} else if (node is Paragraph) {
attributes["style"] = "margin: 0;"
}
}
@@ -135,16 +145,4 @@ class UpdaterManager private constructor() {
return LatestVersion.self
}
fun isIgnored(version: String): Boolean {
return properties.getString("ignored.version.$version", "false").toBoolean()
}
fun ignore(version: String) {
properties.putString("ignored.version.$version", "true")
}
private fun doGetLatestVersion() {
}
}

View File

@@ -2,7 +2,6 @@ package app.termora
import app.termora.actions.*
import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.terminal.DataKey
@@ -14,7 +13,9 @@ import com.formdev.flatlaf.extras.components.FlatTextField
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.KeyboardFocusManager
import java.awt.event.ActionEvent
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
@@ -27,11 +28,16 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
private val properties get() = Database.getDatabase().properties
private val rootPanel = JPanel(BorderLayout())
private val searchTextField = FlatTextField()
private val hostTree = HostTree()
private val hostTree = NewHostTree()
private val bannerPanel = BannerPanel()
private val toggle = FlatButton()
private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean()
private val dataProviderSupport = DataProviderSupport()
private val hostTreeModel = hostTree.model as NewHostTreeModel
private var lastFocused: Component? = null
private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) {
searchTextField.text.isBlank()
}
init {
initView()
@@ -126,8 +132,6 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
})
hostTree.showsRootHandles = true
Disposer.register(this, hostTree)
val scrollPane = JScrollPane(hostTree)
scrollPane.verticalScrollBar.maximumSize = Dimension(0, 0)
scrollPane.verticalScrollBar.preferredSize = Dimension(0, 0)
@@ -138,6 +142,11 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
panel.add(scrollPane, BorderLayout.CENTER)
panel.border = BorderFactory.createEmptyBorder(10, 0, 0, 0)
hostTree.model = filterableHostTreeModel
TreeUtils.loadExpansionState(
hostTree,
properties.getString("Welcome.HostTree.state", StringUtils.EMPTY)
)
return panel
}
@@ -163,13 +172,23 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
})
FindEverywhereProvider.getFindEverywhereProviders(windowScope)
.add(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider {
FindEverywhereProvider.getFindEverywhereProviders(windowScope).add(object : FindEverywhereProvider {
override fun find(pattern: String): List<FindEverywhereResult> {
return TreeUtils.children(hostTree.model, hostTree.model.root)
.filterIsInstance<Host>()
var filter = hostTreeModel.root.getAllChildren()
.map { it.host }
.filter { it.protocol != Protocol.Folder }
.map { HostFindEverywhereResult(it) }
if (pattern.isNotBlank()) {
filter = filter.filter {
if (it.protocol == Protocol.SSH) {
it.name.contains(pattern, true) || it.host.contains(pattern, true)
} else {
it.name.contains(pattern, true)
}
}
}
return filter.map { HostFindEverywhereResult(it) }
}
override fun group(): String {
@@ -179,21 +198,23 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
override fun order(): Int {
return Integer.MIN_VALUE + 2
}
}))
})
filterableHostTreeModel.addFilter {
val text = searchTextField.text
val host = it.host
text.isBlank() || host.name.contains(text, true)
|| host.host.contains(text, true)
|| host.username.contains(text, true)
}
searchTextField.document.addDocumentListener(object : DocumentAdaptor() {
private var state = StringUtils.EMPTY
override fun changedUpdate(e: DocumentEvent) {
val text = searchTextField.text
if (text.isBlank()) {
hostTree.setModel(hostTree.model)
TreeUtils.loadExpansionState(hostTree, state)
state = String()
} else {
if (state.isBlank()) state = TreeUtils.saveExpansionState(hostTree)
hostTree.setModel(hostTree.searchableModel)
hostTree.searchableModel.search(text)
TreeUtils.expandAll(hostTree)
filterableHostTreeModel.refresh()
if (text.isNotBlank()) {
hostTree.expandAll()
}
}
})
@@ -240,12 +261,22 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
return false
}
override fun dispose() {
hostTree.setModel(null)
properties.putString("WelcomeFullContent", fullContent.toString())
override fun onLostFocus() {
lastFocused = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
}
private class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
override fun onGrabFocus() {
SwingUtilities.invokeLater { lastFocused?.requestFocusInWindow() }
}
override fun dispose() {
properties.putString("WelcomeFullContent", fullContent.toString())
properties.putString("Welcome.HostTree.state", TreeUtils.saveExpansionState(hostTree))
}
private inner class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
private val showMoreInfo get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
override fun actionPerformed(e: ActionEvent) {
ActionManager.getInstance()
.getAction(OpenHostAction.OPEN_HOST)
@@ -261,7 +292,18 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
return Icons.terminal
}
override fun toString(): String {
override fun getText(isSelected: Boolean): String {
if (showMoreInfo) {
val color = UIManager.getColor(if (isSelected) "textHighlightText" else "textInactiveText")
val moreInfo = when (host.protocol) {
Protocol.SSH -> "${host.username}@${host.host}"
Protocol.Serial -> host.options.serialComm.port
else -> StringUtils.EMPTY
}
if (moreInfo.isNotBlank()) {
return "<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;<font color=rgb(${color.red},${color.green},${color.blue})>${moreInfo}</font></html>"
}
}
return host.name
}
}

View File

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

View File

@@ -1,14 +1,28 @@
package app.termora.actions
import app.termora.*
import app.termora.Application.httpClient
import com.formdev.flatlaf.util.SystemInfo
import com.sun.jna.platform.win32.Advapi32
import com.sun.jna.platform.win32.WinError
import com.sun.jna.platform.win32.WinNT
import com.sun.jna.platform.win32.WinReg
import io.github.g00fy2.versioncompare.Version
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import okhttp3.Request
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXEditorPane
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.KeyboardFocusManager
import java.io.File
import java.net.ProxySelector
import java.net.URI
import java.util.*
import java.util.concurrent.TimeUnit
import javax.swing.BorderFactory
import javax.swing.JOptionPane
import javax.swing.JScrollPane
@@ -18,12 +32,22 @@ import kotlin.concurrent.fixedRateTimer
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
class AppUpdateAction : AnAction(
class AppUpdateAction private constructor() : AnAction(
StringUtils.EMPTY,
Icons.ideUpdate
) {
companion object {
private val log = LoggerFactory.getLogger(AppUpdateAction::class.java)
private const val PKG_FILE_KEY = "pkgFile"
fun getInstance(): AppUpdateAction {
return ApplicationScope.forApplicationScope().getOrCreate(AppUpdateAction::class) { AppUpdateAction() }
}
}
private val updaterManager get() = UpdaterManager.getInstance()
private var isRemindMeNextTime = false
init {
isEnabled = false
@@ -42,9 +66,11 @@ class AppUpdateAction : AnAction(
initialDelay = 3.minutes.inWholeMilliseconds,
period = 5.hours.inWholeMilliseconds, daemon = true
) {
if (!isRemindMeNextTime) {
GlobalScope.launch(Dispatchers.IO) { supervisorScope { launch { checkUpdate() } } }
}
}
}
private suspend fun checkUpdate() {
@@ -59,15 +85,75 @@ class AppUpdateAction : AnAction(
return
}
if (updaterManager.isIgnored(latestVersion.version)) {
try {
downloadLatestPkg(latestVersion)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
withContext(Dispatchers.Swing) { isEnabled = true }
}
private suspend fun downloadLatestPkg(latestVersion: UpdaterManager.LatestVersion) {
if (SystemInfo.isLinux) return
super.putValue(PKG_FILE_KEY, null)
val arch = if (SystemInfo.isAARCH64) "aarch64" else "x86-64"
val osName = if (SystemInfo.isWindows) "windows" else "osx"
val suffix = if (SystemInfo.isWindows) "exe" else "dmg"
val filename = "termora-${latestVersion.version}-${osName}-${arch}.${suffix}"
val asset = latestVersion.assets.find { it.name == filename } ?: return
val response = httpClient
.newBuilder()
.callTimeout(15, TimeUnit.MINUTES)
.readTimeout(15, TimeUnit.MINUTES)
.proxySelector(ProxySelector.getDefault())
.build()
.newCall(Request.Builder().url(asset.downloadUrl).build())
.execute()
if (!response.isSuccessful) {
if (log.isErrorEnabled) {
log.warn("Failed to download latest version ${latestVersion.version}, response code ${response.code}")
}
IOUtils.closeQuietly(response)
return
}
withContext(Dispatchers.Swing) {
ActionManager.getInstance()
.setEnabled(Actions.APP_UPDATE, true)
val body = response.body
val input = body?.byteStream()
val file = FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}-${filename}")
val output = file.outputStream()
val downloaded = runCatching { IOUtils.copy(input, output) }.isSuccess
IOUtils.closeQuietly(input, output, body, response)
if (!downloaded) {
if (log.isErrorEnabled) {
log.error("Failed to download latest version to $filename")
}
return
}
if (log.isInfoEnabled) {
log.info("Successfully downloaded latest version to $file")
}
withContext(Dispatchers.Swing) { setLatestPkgFile(file) }
}
private fun setLatestPkgFile(file: File) {
putValue(PKG_FILE_KEY, file)
}
private fun getLatestPkgFile(): File? {
return getValue(PKG_FILE_KEY) as? File
}
private fun showUpdateDialog() {
@@ -106,12 +192,59 @@ class AppUpdateAction : AnAction(
if (option == JOptionPane.CANCEL_OPTION) {
return
} else if (option == JOptionPane.NO_OPTION) {
ActionManager.getInstance().setEnabled(Actions.APP_UPDATE, false)
updaterManager.ignore(updaterManager.lastVersion.version)
isEnabled = false
isRemindMeNextTime = true
} 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}"))
updateSelf(lastVersion)
}
}
private fun updateSelf(latestVersion: UpdaterManager.LatestVersion) {
val file = getLatestPkgFile()
if (SystemInfo.isLinux || file == null) {
isEnabled = false
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${latestVersion.version}"))
return
}
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
val commands = if (SystemInfo.isMacOS) listOf("open", "-n", file.absolutePath)
// 如果安装过,那么直接静默安装和自动启动
else if (isAppInstalled()) listOf(
file.absolutePath,
"/SILENT",
"/AUTOSTART",
"/NORESTART",
"/FORCECLOSEAPPLICATIONS"
)
// 没有安装过 则打开安装向导
else listOf(file.absolutePath)
println(commands)
TermoraRestarter.getInstance().scheduleRestart(owner, commands)
}
private fun isAppInstalled(): Boolean {
val keyPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${Application.getName()}_is1"
val phkKey = WinReg.HKEYByReference()
// 尝试打开注册表键
val result = Advapi32.INSTANCE.RegOpenKeyEx(
WinReg.HKEY_LOCAL_MACHINE,
keyPath,
0,
WinNT.KEY_READ,
phkKey
)
if (result == WinError.ERROR_SUCCESS) {
// 键存在,关闭句柄
Advapi32.INSTANCE.RegCloseKey(phkKey.getValue())
return true
} else {
// 键不存在或无权限
return false
}
}
}

View File

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

View File

@@ -5,8 +5,9 @@ import app.termora.terminal.DataKey
object DataProviders {
val TerminalPanel = DataKey(app.termora.terminal.panel.TerminalPanel::class)
val Terminal = DataKey(app.termora.terminal.Terminal::class)
val PtyConnector = DataKey(app.termora.terminal.PtyConnector::class)
val PtyConnector get() = DataKey.PtyConnector
val TabbedPane = DataKey(app.termora.MyTabbedPane::class)
val TerminalTabbed = DataKey(app.termora.TerminalTabbed::class)
val TerminalTab = DataKey(app.termora.TerminalTab::class)
val TerminalTabbedManager = DataKey(app.termora.TerminalTabbedManager::class)
@@ -16,6 +17,6 @@ object DataProviders {
object Welcome {
val HostTree = DataKey(app.termora.HostTree::class)
val HostTree = DataKey(app.termora.NewHostTree::class)
}
}

View File

@@ -1,9 +1,6 @@
package app.termora.actions
import app.termora.Host
import app.termora.HostDialog
import app.termora.HostManager
import app.termora.Protocol
import app.termora.*
import javax.swing.tree.TreePath
class NewHostAction : AnAction() {
@@ -20,27 +17,27 @@ class NewHostAction : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tree = evt.getData(DataProviders.Welcome.HostTree) ?: return
val model = tree.model
var lastHost = tree.lastSelectedPathComponent ?: model.root
if (lastHost !is Host) {
return
}
if (lastHost.protocol != Protocol.Folder) {
val p = model.getParent(lastHost) ?: return
lastHost = p
var lastNode = (tree.lastSelectedPathComponent ?: tree.model.root) as? HostTreeNode ?: return
if (lastNode.host.protocol != Protocol.Folder) {
lastNode = lastNode.parent ?: return
}
val lastHost = lastNode.host
val dialog = HostDialog(evt.window)
dialog.setLocationRelativeTo(evt.window)
dialog.isVisible = true
val host = (dialog.host ?: return).copy(parentId = lastHost.id)
hostManager.addHost(host)
val newNode = HostTreeNode(host)
tree.expandNode(lastHost)
val model = if (tree.model is FilterableHostTreeModel) (tree.model as FilterableHostTreeModel).getModel()
else tree.model
tree.selectionPath = TreePath(model.getPathToRoot(host))
if (model is NewHostTreeModel) {
model.insertNodeInto(newNode, lastNode, lastNode.childCount)
tree.selectionPath = TreePath(model.getPathToRoot(newNode))
}
}
}

View File

@@ -1,9 +1,6 @@
package app.termora.actions
import app.termora.LocalTerminalTab
import app.termora.OpenHostActionEvent
import app.termora.Protocol
import app.termora.SSHTerminalTab
import app.termora.*
class OpenHostAction : AnAction() {
companion object {
@@ -18,9 +15,19 @@ class OpenHostAction : AnAction() {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
val tab = if (evt.host.protocol == Protocol.SSH)
SSHTerminalTab(windowScope, evt.host)
else LocalTerminalTab(windowScope, evt.host)
// 如果不支持 SFTP 那么不处理这个响应
if (evt.host.protocol == Protocol.SFTPPty) {
if (!SFTPPtyTerminalTab.canSupports) {
return
}
}
val tab = when (evt.host.protocol) {
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
Protocol.SFTPPty -> SFTPPtyTerminalTab(windowScope, evt.host)
else -> LocalTerminalTab(windowScope, evt.host)
}
terminalTabbedManager.addTerminalTab(tab)
tab.start()

View File

@@ -0,0 +1,30 @@
package app.termora.actions
import app.termora.HostTerminalTab
import app.termora.I18n
import app.termora.OpenHostActionEvent
import app.termora.Protocol
class SFTPCommandAction : AnAction() {
companion object {
/**
* 打开 SFTP command
*/
const val SFTP_COMMAND = "SFTPCommandAction"
}
init {
putValue(ACTION_COMMAND_KEY, SFTP_COMMAND)
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.open-sftp-command"))
}
override fun actionPerformed(evt: AnActionEvent) {
val actionManager = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val tab = terminalTabbedManager.getSelectedTerminalTab() as? HostTerminalTab ?: return
val host = tab.host
if (!(host.protocol == Protocol.SSH || host.protocol == Protocol.SFTPPty)) return
actionManager.actionPerformed(OpenHostActionEvent(evt.source, host.copy(protocol = Protocol.SFTPPty), evt))
evt.consume()
}
}

View File

@@ -1,12 +1,14 @@
package app.termora.actions
import app.termora.I18n
class TerminalClearScreenAction : AnAction() {
companion object {
const val CLEAR_SCREEN = "ClearScreen"
}
init {
putValue(SHORT_DESCRIPTION, "Clear Terminal Buffer")
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.clear-screen"))
putValue(ACTION_COMMAND_KEY, CLEAR_SCREEN)
}

View File

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

View File

@@ -8,5 +8,6 @@ interface FindEverywhereResult : ActionListener {
fun getIcon(isSelected: Boolean): Icon = Icons.empty
fun getText(isSelected: Boolean) = toString()
}

View File

@@ -94,16 +94,16 @@ class FindEverywhereXList(private val model: DefaultListModel<FindEverywhereResu
label.font = font.deriveFont(font.size - 2f)
val box = Box.createHorizontalBox()
box.add(label)
/*box.add(object : JComponent() {
override fun paintComponent(g: Graphics) {
g.color = DynamicColor.BorderColor
g.drawLine(10, height / 2, width, height / 2)
}
})*/
return box
}
val c = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
val c = super.getListCellRendererComponent(
list,
if (value is FindEverywhereResult) value.getText(isSelected) else value,
index,
isSelected,
cellHasFocus
)
if (isSelected) {
background = UIManager.getColor("List.selectionBackground")
foreground = UIManager.getColor("List.selectionForeground")

View File

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

View File

@@ -28,6 +28,7 @@ class TerminalUserInteraction(
prompt[i],
true
)
dialog.setLocationRelativeTo(owner)
dialog.title = instruction ?: name ?: StringUtils.EMPTY
passwords[i] = dialog.getText()
if (passwords[i].isBlank()) {

View File

@@ -76,6 +76,12 @@ class KeymapImpl(private val menuShortcutKeyMaskEx: Int) : Keymap("Keymap", null
KeyShortcut(KeyStroke.getKeyStroke(KeyEvent.VK_R, menuShortcutKeyMaskEx or InputEvent.SHIFT_DOWN_MASK))
)
// Command + Shift + P
addShortcut(
SFTPCommandAction.SFTP_COMMAND,
KeyShortcut(KeyStroke.getKeyStroke(KeyEvent.VK_P, menuShortcutKeyMaskEx or InputEvent.SHIFT_DOWN_MASK))
)
// switch map
for (i in KeyEvent.VK_1..KeyEvent.VK_9) {

View File

@@ -1,21 +1,18 @@
package app.termora.keymap
import app.termora.ApplicationScope
import app.termora.Database
import app.termora.DialogWrapper
import app.termora.Disposable
import app.termora.*
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.findeverywhere.FindEverywhereAction
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.awt.Container
import java.awt.KeyEventDispatcher
import java.awt.KeyboardFocusManager
import java.awt.event.KeyEvent
import javax.swing.JComponent
import javax.swing.JDialog
import javax.swing.JPopupMenu
import javax.swing.KeyStroke
class KeymapManager private constructor() : Disposable {
@@ -30,15 +27,14 @@ class KeymapManager private constructor() : Disposable {
}
private val keymapKeyEventDispatcher = KeymapKeyEventDispatcher()
private val myKeyEventDispatcher = MyKeyEventDispatcher()
private val database get() = Database.getDatabase()
private val properties get() = database.properties
private val keymaps = linkedMapOf<String, Keymap>()
private val activeKeymap get() = database.properties.getString("Keymap.Active")
private val activeKeymap get() = properties.getString("Keymap.Active")
private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() }
init {
keyboardFocusManager.addKeyEventDispatcher(keymapKeyEventDispatcher)
keyboardFocusManager.addKeyEventDispatcher(myKeyEventDispatcher)
try {
for (keymap in database.getKeymaps()) {
@@ -128,6 +124,17 @@ class KeymapManager private constructor() : Disposable {
return false
}
// 如果当前有 Popup ,那么不派发事件
val c = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
if (c is Container) {
val popups: List<JPopupMenu> = SwingUtils.getDescendantsOfType(
JPopupMenu::class.java,
c, true
)
if (popups.isNotEmpty()) {
return false
}
}
val evt = AnActionEvent(e.source, StringUtils.EMPTY, e)
for (actionId in actionIds) {
@@ -146,35 +153,7 @@ class KeymapManager private constructor() : Disposable {
}
private inner class MyKeyEventDispatcher : KeyEventDispatcher {
// double shift
private var lastTime = -1L
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
if (e.keyCode == KeyEvent.VK_SHIFT && e.id == KeyEvent.KEY_PRESSED) {
val owner = AnActionEvent(e.source, StringUtils.EMPTY, e).getData(DataProviders.TermoraFrame)
?: return false
if (keyboardFocusManager.focusedWindow == owner) {
val now = System.currentTimeMillis()
if (now - 250 < lastTime) {
app.termora.actions.ActionManager.getInstance()
.getAction(FindEverywhereAction.FIND_EVERYWHERE)
?.actionPerformed(AnActionEvent(e.source, StringUtils.EMPTY, e))
}
lastTime = now
}
} else if (e.keyCode != KeyEvent.VK_SHIFT) { // 如果不是 Shift 键,那么就阻断了连续性,重置时间
lastTime = -1
}
return false
}
}
override fun dispose() {
keyboardFocusManager.removeKeyEventDispatcher(keymapKeyEventDispatcher)
keyboardFocusManager.removeKeyEventDispatcher(myKeyEventDispatcher)
}
}

View File

@@ -30,6 +30,8 @@ class KeymapTableModel : DefaultTableModel() {
FindEverywhereAction.FIND_EVERYWHERE,
NewWindowAction.NEW_WINDOW,
TabReconnectAction.RECONNECT_TAB,
TerminalClearScreenAction.CLEAR_SCREEN,
SFTPCommandAction.SFTP_COMMAND,
SwitchTabAction.SWITCH_TAB,
)) {
val action = actionManager.getAction(id) ?: continue

View File

@@ -2,6 +2,9 @@ package app.termora.keymgr
import app.termora.*
import app.termora.AES.decodeBase64
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.native.FileChooser
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatTable
@@ -48,6 +51,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
private val exportBtn = JButton(I18n.getString("termora.keymgr.export"))
private val editBtn = JButton(I18n.getString("termora.keymgr.edit"))
private val deleteBtn = JButton(I18n.getString("termora.remove"))
private val sshCopyIdBtn = JButton("ssh-copy-id")
init {
initView()
@@ -59,6 +63,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
exportBtn.isEnabled = false
editBtn.isEnabled = false
sshCopyIdBtn.isEnabled = false
deleteBtn.isEnabled = false
keyPairTableModel.addColumn(I18n.getString("termora.keymgr.table.name"))
@@ -75,7 +80,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin"
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref"
)
var rows = 1
@@ -91,6 +96,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
.add(importBtn).xy(1, rows).apply { rows += step }
.add(exportBtn).xy(1, rows).apply { rows += step }
.add(deleteBtn).xy(1, rows).apply { rows += step }
.add(sshCopyIdBtn).xy(1, rows).apply { rows += step }
.build(), BorderLayout.EAST)
border = BorderFactory.createEmptyBorder(if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0, 12, 12, 12)
@@ -175,13 +181,48 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
}
})
sshCopyIdBtn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
sshCopyId(evt)
}
})
keyPairTable.selectionModel.addListSelectionListener {
exportBtn.isEnabled = keyPairTable.selectedRowCount > 0
editBtn.isEnabled = exportBtn.isEnabled
deleteBtn.isEnabled = exportBtn.isEnabled
sshCopyIdBtn.isEnabled = exportBtn.isEnabled
}
}
private fun sshCopyId(evt: AnActionEvent) {
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
val keyPairs = keyPairTable.selectedRows.map { keyPairTableModel.getOhKeyPair(it) }
val publicKeys = mutableListOf<Pair<String, String>>()
for (keyPair in keyPairs) {
val publicKey = OhKeyPairKeyPairProvider.generateKeyPair(keyPair).public
val baos = ByteArrayOutputStream()
OpenSSHKeyPairResourceWriter.INSTANCE.writePublicKey(publicKey, keyPair.name, baos)
publicKeys.add(Pair(keyPair.name, baos.toString(Charsets.UTF_8)))
}
if (publicKeys.isEmpty()) {
return
}
val owner = SwingUtilities.getWindowAncestor(this) ?: return
val hostTreeDialog = NewHostTreeDialog(owner)
hostTreeDialog.setFilter { it.host.protocol == Protocol.SSH }
hostTreeDialog.setTreeName("KeyManagerPanel.SSHCopyIdTree")
hostTreeDialog.isVisible = true
val hosts = hostTreeDialog.hosts
if (hosts.isEmpty()) {
return
}
SSHCopyIdDialog(owner, windowScope, hosts, publicKeys).start()
}
private fun exportKeyPairs(file: File, keyPairs: List<OhKeyPair>) {
file.outputStream().use { fis ->
val names = mutableMapOf<String, Int>()

View File

@@ -0,0 +1,198 @@
package app.termora.keymgr
import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnectorDelegate
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.IOUtils
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ClientChannelEvent
import org.apache.sshd.client.session.ClientSession
import org.slf4j.LoggerFactory
import java.awt.Dimension
import java.awt.Window
import java.io.ByteArrayOutputStream
import java.time.Duration
import java.util.*
import javax.swing.AbstractAction
import javax.swing.JComponent
import javax.swing.UIManager
class SSHCopyIdDialog(
owner: Window,
private val windowScope: WindowScope,
private val hosts: List<Host>,
// key: name , value: public key
private val publicKeys: List<Pair<String, String>>,
) : DialogWrapper(owner) {
companion object {
private val log = LoggerFactory.getLogger(SSHCopyIdDialog::class.java)
}
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
private val terminal by lazy {
TerminalFactory.getInstance(windowScope).createTerminal().apply {
getTerminalModel().setData(DataKey.ShowCursor, false)
getTerminalModel().setData(DataKey.AutoNewline, true)
}
}
private val terminalPanel by lazy {
terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate())
.apply { enableFloatingToolbar = false }
}
private val coroutineScope = CoroutineScope(Job() + Dispatchers.IO)
init {
size = Dimension(UIManager.getInt("Dialog.width") - 100, UIManager.getInt("Dialog.height") - 100)
isModal = true
title = "SSH Copy ID"
setLocationRelativeTo(null)
Disposer.register(disposable, object : Disposable {
override fun dispose() {
coroutineScope.cancel()
terminal.close()
Disposer.dispose(terminalPanel)
}
})
init()
}
override fun createCenterPanel(): JComponent {
return terminalPanel
}
fun start() {
coroutineScope.launch {
try {
doStart()
} catch (e: Exception) {
log.error(e.message, e)
}
}
isVisible = true
}
override fun createActions(): List<AbstractAction> {
return listOf(CancelAction())
}
private fun magenta(text: Any): String {
return "${ControlCharacters.ESC}[35m${text}${ControlCharacters.ESC}[0m"
}
private fun cyan(text: Any): String {
return "${ControlCharacters.ESC}[36m${text}${ControlCharacters.ESC}[0m"
}
private fun red(text: Any): String {
return "${ControlCharacters.ESC}[31m${text}${ControlCharacters.ESC}[0m"
}
private fun green(text: Any): String {
return "${ControlCharacters.ESC}[32m${text}${ControlCharacters.ESC}[0m"
}
private suspend fun doStart() {
withContext(Dispatchers.Swing) {
terminal.write(
I18n.getString(
"termora.keymgr.ssh-copy-id.number",
magenta(hosts.size),
magenta(publicKeys.size)
)
)
terminal.getDocument().newline()
terminal.getDocument().newline()
}
var myClient: SshClient? = null
var mySession: ClientSession? = null
val timeout = Duration.ofMinutes(1)
// 获取公钥名称最长的
val publicKeyNameLength = publicKeys.maxOfOrNull { it.first.length } ?: 0
for (index in hosts.indices) {
if (!coroutineScope.isActive) {
return
}
val host = hosts[index]
withContext(Dispatchers.Swing) {
terminal.write("[${cyan(index + 1)}/${cyan(hosts.size)}] ${host.name}")
terminal.getDocument().newline()
}
for ((j, e) in publicKeys.withIndex()) {
if (!coroutineScope.isActive) {
return
}
val publicKeyName = e.first.padEnd(publicKeyNameLength, ' ')
val publicKey = e.second
withContext(Dispatchers.Swing) {
// @formatter:off
terminal.write("\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${I18n.getString("termora.transport.sftp.connecting")}")
// @formatter:on
}
try {
val client = SshClients.openClient(host).apply { myClient = this }
client.userInteraction = TerminalUserInteraction(owner)
val session = SshClients.openSession(host, client).apply { mySession = this }
val channel =
session.createExecChannel("mkdir -p ~/.ssh && grep -qxF \"$publicKey\" ~/.ssh/authorized_keys || echo \"$publicKey\" >> ~/.ssh/authorized_keys")
val baos = ByteArrayOutputStream()
channel.out = baos
if (channel.open().verify(timeout).await(timeout)) {
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout)
}
if (channel.exitStatus != 0) {
throw IllegalStateException("Server response: ${channel.exitStatus}")
}
withContext(Dispatchers.Swing) {
terminal.getDocument().eraseInLine(2)
// @formatter:off
terminal.write("\r\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${green(I18n.getString("termora.keymgr.ssh-copy-id.successful"))}")
// @formatter:on
}
} catch (e: Exception) {
withContext(Dispatchers.Swing) {
terminal.getDocument().eraseInLine(2)
// @formatter:off
terminal.write("\r\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] $publicKeyName ${red("${I18n.getString("termora.keymgr.ssh-copy-id.failed")}: ${e.message}")}")
// @formatter:on
}
} finally {
IOUtils.closeQuietly(mySession)
IOUtils.closeQuietly(myClient)
}
withContext(Dispatchers.Swing) {
terminal.getDocument().newline()
}
}
withContext(Dispatchers.Swing) {
terminal.getDocument().newline()
}
}
withContext(Dispatchers.Swing) {
terminal.write(I18n.getString("termora.keymgr.ssh-copy-id.end"))
}
}
}

View File

@@ -0,0 +1,24 @@
package app.termora.snippet
import app.termora.toSimpleString
import kotlinx.serialization.Serializable
import org.apache.commons.lang3.StringUtils
import java.util.*
enum class SnippetType {
Folder,
Snippet,
}
@Serializable
data class Snippet(
val id: String = UUID.randomUUID().toSimpleString(),
val name: String,
val snippet: String = StringUtils.EMPTY,
val parentId: String = StringUtils.EMPTY,
val type: SnippetType = SnippetType.Snippet,
val deleted: Boolean = false,
val sort: Long = System.currentTimeMillis(),
val createDate: Long = System.currentTimeMillis(),
val updateDate: Long = System.currentTimeMillis(),
)

View File

@@ -0,0 +1,47 @@
package app.termora.snippet
import app.termora.ApplicationScope
import app.termora.I18n
import app.termora.Icons
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.Terminal
class SnippetAction private constructor() : AnAction(I18n.getString("termora.snippet.title"), Icons.codeSpan) {
companion object {
fun getInstance(): SnippetAction {
return ApplicationScope.forApplicationScope().getOrCreate(SnippetAction::class) { SnippetAction() }
}
const val SNIPPET = "SnippetAction"
}
override fun actionPerformed(evt: AnActionEvent) {
SnippetDialog(evt.window).isVisible = true
}
fun runSnippet(snippet: Snippet, terminal: Terminal) {
if (snippet.type != SnippetType.Snippet) return
val terminalModel = terminal.getTerminalModel()
val map = mapOf(
"\\r" to ControlCharacters.CR,
"\\n" to ControlCharacters.LF,
"\\t" to ControlCharacters.TAB,
"\\a" to ControlCharacters.BEL,
"\\e" to ControlCharacters.ESC,
"\\b" to ControlCharacters.BS,
)
if (terminalModel.hasData(DataKey.PtyConnector)) {
var text = snippet.snippet
for (e in map.entries) {
text = text.replace(e.key, e.value.toString())
}
val ptyConnector = terminalModel.getData(DataKey.PtyConnector)
ptyConnector.write(text.toByteArray(ptyConnector.getCharset()))
}
}
}

View File

@@ -0,0 +1,50 @@
package app.termora.snippet
import java.awt.*
import javax.swing.JComponent
import javax.swing.UIManager
class SnippetBannerPanel(fontSize: Int = 12) : JComponent() {
private val banner = """
_____ _ __
/ ___/____ (_)___ ____ ___ / /_
\__ \/ __ \/ / __ \/ __ \/ _ \/ __/
___/ / / / / / /_/ / /_/ / __/ /_
/____/_/ /_/_/ .___/ .___/\___/\__/
/_/ /_/
""".trimIndent().lines()
init {
font = Font("JetBrains Mono", Font.PLAIN, fontSize)
preferredSize = Dimension(width, getFontMetrics(font).height * banner.size)
size = preferredSize
}
override fun paintComponent(g: Graphics) {
if (g is Graphics2D) {
g.setRenderingHints(
RenderingHints(
RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON
)
)
}
g.font = font
g.color = UIManager.getColor("TextField.placeholderForeground")
val height = g.fontMetrics.height
val descent = g.fontMetrics.descent
val offset = width / 2 - g.fontMetrics.stringWidth(banner.maxBy { it.length }) / 2
for (i in banner.indices) {
var x = offset
val y = height * (i + 1) - descent
val chars = banner[i].toCharArray()
for (j in chars.indices) {
g.drawChars(chars, j, 1, x, y)
x += g.fontMetrics.charWidth(chars[j])
}
}
}
}

View File

@@ -0,0 +1,59 @@
package app.termora.snippet
import app.termora.*
import java.awt.Dimension
import java.awt.Window
import javax.swing.JComponent
import javax.swing.UIManager
import kotlin.math.max
class SnippetDialog(owner: Window) : DialogWrapper(owner) {
private val properties get() = Database.getDatabase().properties
init {
initViews()
initEvents()
init()
}
private fun initViews() {
val w = properties.getString("SnippetDialog.width", "0").toIntOrNull() ?: 0
val h = properties.getString("SnippetDialog.height", "0").toIntOrNull() ?: 0
val x = properties.getString("SnippetDialog.x", "-1").toIntOrNull() ?: -1
val y = properties.getString("SnippetDialog.y", "-1").toIntOrNull() ?: -1
size = if (w > 0 && h > 0) {
Dimension(w, h)
} else {
Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
}
isModal = true
isResizable = true
title = I18n.getString("termora.snippet.title")
if (x != -1 && y != -1) {
setLocation(max(x, 0), max(y, 0))
} else {
setLocationRelativeTo(owner)
}
}
private fun initEvents() {
Disposer.register(disposable, object : Disposable {
override fun dispose() {
properties.putString("SnippetDialog.width", width.toString())
properties.putString("SnippetDialog.height", height.toString())
properties.putString("SnippetDialog.x", x.toString())
properties.putString("SnippetDialog.y", y.toString())
}
})
}
override fun createCenterPanel(): JComponent {
return SnippetPanel().apply { Disposer.register(disposable, this) }
}
override fun createSouthPanel(): JComponent? {
return null
}
}

View File

@@ -0,0 +1,44 @@
package app.termora.snippet
import app.termora.ApplicationScope
import app.termora.Database
import app.termora.assertEventDispatchThread
class SnippetManager private constructor() {
companion object {
fun getInstance(): SnippetManager {
return ApplicationScope.forApplicationScope().getOrCreate(SnippetManager::class) { SnippetManager() }
}
}
private val database get() = Database.getDatabase()
private var snippets = mutableMapOf<String, Snippet>()
/**
* 修改缓存并存入数据库
*/
fun addSnippet(snippet: Snippet) {
assertEventDispatchThread()
database.addSnippet(snippet)
if (snippet.deleted) {
snippets.entries.removeIf { it.value.id == snippet.id || it.value.parentId == snippet.id }
} else {
snippets[snippet.id] = snippet
}
}
/**
* 第一次调用从数据库中获取,后续从缓存中获取
*/
fun snippets(): List<Snippet> {
if (snippets.isEmpty()) {
database.getSnippets().filter { !it.deleted }
.forEach { snippets[it.id] = it }
}
return snippets.values.filter { !it.deleted }
.sortedWith(compareBy<Snippet> { if (it.type == SnippetType.Folder) 0 else 1 }.thenBy { it.sort })
}
}

View File

@@ -0,0 +1,225 @@
package app.termora.snippet
import app.termora.*
import com.formdev.flatlaf.extras.components.FlatTextArea
import com.formdev.flatlaf.ui.FlatRoundBorder
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.Dimension
import java.awt.KeyboardFocusManager
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import javax.swing.*
import javax.swing.event.DocumentEvent
import javax.swing.undo.UndoManager
class SnippetPanel : JPanel(BorderLayout()), Disposable {
companion object {
private val log = LoggerFactory.getLogger(SnippetPanel::class.java)
private val properties get() = Database.getDatabase().properties
private val snippetManager get() = SnippetManager.getInstance()
}
private val leftPanel = JPanel(BorderLayout())
private val cardLayout = CardLayout()
private val rightPanel = JPanel(cardLayout)
private val snippetTree = SnippetTree()
private val editor = SnippetEditor()
private val lastNode get() = snippetTree.getLastSelectedPathNode()
init {
initViews()
initEvents()
}
private fun initViews() {
val splitPane = JSplitPane()
splitPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
leftPanel.add(snippetTree, BorderLayout.CENTER)
leftPanel.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(4, 4, 4, 4)
)
leftPanel.preferredSize = Dimension(
properties.getString("SnippetPanel.LeftPanel.width", "180").toIntOrNull() ?: 180,
-1
)
rightPanel.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(6, 6, 6, 6)
)
val bannerPanel = JPanel(BorderLayout())
bannerPanel.add(SnippetBannerPanel(), BorderLayout.CENTER)
bannerPanel.border = BorderFactory.createEmptyBorder(32, 0, 0, 0)
rightPanel.add(bannerPanel, "Banner")
rightPanel.add(editor, "Editor")
splitPane.leftComponent = leftPanel
splitPane.rightComponent = rightPanel
add(splitPane, BorderLayout.CENTER)
cardLayout.show(rightPanel, "Banner")
}
private fun initEvents() {
snippetTree.addTreeSelectionListener {
val lastNode = this.lastNode
if (lastNode == null || lastNode.isFolder) {
cardLayout.show(rightPanel, "Banner")
} else {
cardLayout.show(rightPanel, "Editor")
editor.textArea.text = lastNode.data.snippet
editor.resetUndo()
}
}
SwingUtilities.invokeLater {
if (snippetTree.selectionRows?.isEmpty() == true) {
snippetTree.addSelectionRow(0)
}
snippetTree.requestFocusInWindow()
}
val expansionState = properties.getString("SnippetPanel.LeftTreePanel.expansionState", StringUtils.EMPTY)
if (expansionState.isNotBlank()) {
TreeUtils.loadExpansionState(snippetTree, expansionState)
}
val selectionRows = properties.getString("SnippetPanel.LeftTreePanel.selectionRows", StringUtils.EMPTY)
if (selectionRows.isNotBlank()) {
TreeUtils.loadSelectionRows(snippetTree, selectionRows)
}
}
override fun dispose() {
properties.putString("SnippetPanel.LeftPanel.width", leftPanel.width.toString())
properties.putString("SnippetPanel.LeftPanel.height", leftPanel.height.toString())
properties.putString("SnippetPanel.LeftTreePanel.expansionState", TreeUtils.saveExpansionState(snippetTree))
properties.putString("SnippetPanel.LeftTreePanel.selectionRows", TreeUtils.saveSelectionRows(snippetTree))
}
private inner class SnippetEditor : JPanel(BorderLayout()) {
val textArea = FlatTextArea()
private var undoManager = UndoManager()
init {
initViews()
initEvents()
}
private fun initViews() {
val panel = JPanel(BorderLayout())
panel.add(JScrollPane(textArea).apply { border = BorderFactory.createEmptyBorder() }, BorderLayout.CENTER)
panel.border = FlatRoundBorder()
add(panel, BorderLayout.CENTER)
add(createTip(), BorderLayout.SOUTH)
textArea.setFocusTraversalKeys(
KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS)
)
textArea.setFocusTraversalKeys(
KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS)
)
}
private fun initEvents() {
textArea.document.addUndoableEditListener(undoManager)
textArea.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if ((e.keyCode == KeyEvent.VK_Z || e.keyCode == KeyEvent.VK_Y) && (if (SystemInfo.isMacOS) e.isMetaDown else e.isControlDown)) {
try {
if (e.keyCode == KeyEvent.VK_Z) {
if (undoManager.canUndo()) {
undoManager.undo()
}
} else {
if (undoManager.canRedo()) {
undoManager.redo()
}
}
} catch (cue: Exception) {
if (log.isErrorEnabled) {
log.error(cue.message, cue.message)
}
}
}
}
})
textArea.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
val lastNode = lastNode ?: return
lastNode.data = lastNode.data.copy(snippet = textArea.text, updateDate = System.currentTimeMillis())
snippetManager.addSnippet(lastNode.data)
}
})
}
fun resetUndo() {
textArea.document.removeUndoableEditListener(undoManager)
undoManager = UndoManager()
textArea.document.addUndoableEditListener(undoManager)
}
private fun createTip(): JPanel {
val formMargin = "10dlu"
val panel = FormBuilder.create().debug(false)
.border(
BorderFactory.createCompoundBorder(
FlatRoundBorder(),
BorderFactory.createEmptyBorder(2, 4, 4, 4)
)
)
.layout(
FormLayout(
"left:pref, left:pref, $formMargin, left:pref, left:pref, $formMargin, left:pref, left:pref",
"pref, $formMargin, pref"
)
)
.add(createTipLabel("\\r - ")).xy(1, 1)
.add(createTipLabel("CR")).xy(2, 1)
.add(createTipLabel("\\n - ")).xy(4, 1)
.add(createTipLabel("LF")).xy(5, 1)
.add(createTipLabel("\\t - ")).xy(7, 1)
.add(createTipLabel("Tab")).xy(8, 1)
.add(createTipLabel("\\a - ")).xy(1, 2)
.add(createTipLabel("Bell")).xy(2, 2)
.add(createTipLabel("\\e - ")).xy(4, 2)
.add(createTipLabel("Escape")).xy(5, 2)
.add(createTipLabel("\\b - ")).xy(7, 2)
.add(createTipLabel("Backspace")).xy(8, 2)
.build()
return JPanel(BorderLayout()).apply {
add(panel, BorderLayout.CENTER)
border = BorderFactory.createEmptyBorder(4, 0, 0, 0)
}
}
private fun createTipLabel(text: String): JLabel {
val label = JLabel(text)
label.foreground = UIManager.getColor("textInactiveText")
return label
}
}
}

View File

@@ -0,0 +1,146 @@
package app.termora.snippet
import app.termora.I18n
import app.termora.OptionPane
import app.termora.SimpleTree
import app.termora.SimpleTreeNode
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import java.awt.event.MouseEvent
import javax.swing.DropMode
import javax.swing.JMenu
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import javax.swing.tree.TreePath
class SnippetTree : SimpleTree() {
override val model = SnippetTreeModel()
private val snippetManager get() = SnippetManager.getInstance()
init {
initViews()
initEvents()
}
private fun initViews() {
super.setModel(model)
isEditable = true
dragEnabled = true
dropMode = DropMode.ON_OR_INSERT
}
private fun initEvents() {
}
override fun showContextmenu(evt: MouseEvent) {
val lastNode = getLastSelectedPathNode() ?: 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 newSnippet = newMenu.add(I18n.getString("termora.snippet"))
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove"))
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()
newFolder.addActionListener {
val snippet = Snippet(
name = I18n.getString("termora.welcome.contextmenu.new.folder.name"),
type = SnippetType.Folder,
parentId = lastNode.data.id
)
snippetManager.addSnippet(snippet)
newFolder(SnippetTreeNode(snippet))
}
newSnippet.addActionListener {
val snippet = Snippet(
name = I18n.getString("termora.snippet"),
type = SnippetType.Snippet,
parentId = lastNode.data.id
)
snippetManager.addSnippet(snippet)
newFile(SnippetTreeNode(snippet))
}
rename.addActionListener { startEditingAtPath(TreePath(model.getPathToRoot(lastNode))) }
refresh.addActionListener { refreshNode(lastNode) }
expandAll.addActionListener {
for (node in getSelectionSimpleTreeNodes(true)) {
expandPath(TreePath(model.getPathToRoot(node)))
}
}
colspanAll.addActionListener {
for (node in getSelectionSimpleTreeNodes(true).reversed()) {
collapsePath(TreePath(model.getPathToRoot(node)))
}
}
remove.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val nodes = getSelectionSimpleTreeNodes()
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) {
snippetManager.addSnippet(c.data.copy(deleted = true, updateDate = System.currentTimeMillis()))
model.removeNodeFromParent(c)
// 将所有子孙也删除
for (child in c.getAllChildren()) {
snippetManager.addSnippet(
child.data.copy(
deleted = true,
updateDate = System.currentTimeMillis()
)
)
}
}
}
}
})
rename.isEnabled = lastNode != model.root
remove.isEnabled = rename.isEnabled
newFolder.isEnabled = lastNode.data.type == SnippetType.Folder
newSnippet.isEnabled = newFolder.isEnabled
newMenu.isEnabled = newFolder.isEnabled
refresh.isEnabled = newFolder.isEnabled
popupMenu.add(newMenu)
popupMenu.show(this, evt.x, evt.y)
}
public override fun getLastSelectedPathNode(): SnippetTreeNode? {
return super.getLastSelectedPathNode() as? SnippetTreeNode
}
override fun onRenamed(node: SimpleTreeNode<*>, text: String) {
val n = node as? SnippetTreeNode ?: return
n.data = n.data.copy(name = text, updateDate = System.currentTimeMillis())
snippetManager.addSnippet(n.data)
model.nodeStructureChanged(n)
}
override fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>) {
val nNode = node as? SnippetTreeNode ?: return
val nParent = parent as? SnippetTreeNode ?: return
nNode.data = nNode.data.copy(parentId = nParent.data.id, updateDate = System.currentTimeMillis())
snippetManager.addSnippet(nNode.data)
}
override fun getSelectionSimpleTreeNodes(include: Boolean): List<SnippetTreeNode> {
return super.getSelectionSimpleTreeNodes(include).filterIsInstance<SnippetTreeNode>()
}
}

View File

@@ -0,0 +1,65 @@
package app.termora.snippet
import app.termora.*
import org.apache.commons.lang3.StringUtils
import java.awt.Dimension
import java.awt.Window
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.JScrollPane
class SnippetTreeDialog(owner: Window) : DialogWrapper(owner) {
private val snippetTree = SnippetTree()
private val properties get() = Database.getDatabase().properties
var lastNode: SnippetTreeNode? = null
init {
size = Dimension(360, 380)
title = I18n.getString("termora.snippet.title")
isModal = true
isResizable = true
controlsVisible = false
setLocationRelativeTo(null)
init()
Disposer.register(disposable, object : Disposable {
override fun dispose() {
properties.putString("SnippetTreeDialog.Tree.expansionState", TreeUtils.saveExpansionState(snippetTree))
properties.putString("SnippetTreeDialog.Tree.selectionRows", TreeUtils.saveSelectionRows(snippetTree))
}
})
val expansionState = properties.getString("SnippetTreeDialog.Tree.expansionState", StringUtils.EMPTY)
if (expansionState.isNotBlank()) {
TreeUtils.loadExpansionState(snippetTree, expansionState)
}
val selectionRows = properties.getString("SnippetTreeDialog.Tree.selectionRows", StringUtils.EMPTY)
if (selectionRows.isNotBlank()) {
TreeUtils.loadSelectionRows(snippetTree, selectionRows)
}
}
override fun createCenterPanel(): JComponent {
return JScrollPane(snippetTree).apply { border = BorderFactory.createEmptyBorder(0, 6, 6, 6) }
}
override fun doCancelAction() {
lastNode = null
super.doCancelAction()
}
override fun doOKAction() {
val node = snippetTree.getLastSelectedPathNode() ?: return
if (node.isFolder) return
lastNode = node
super.doOKAction()
}
fun getSelectedNode(): SnippetTreeNode? {
return lastNode
}
}

View File

@@ -0,0 +1,73 @@
package app.termora.snippet
import app.termora.SimpleTreeModel
import javax.swing.tree.MutableTreeNode
import javax.swing.tree.TreeNode
class SnippetTreeModel : SimpleTreeModel<Snippet>(
SnippetTreeNode(
Snippet(
id = "0",
name = "全部片段",
type = SnippetType.Folder
)
)
) {
private val snippetManager get() = SnippetManager.getInstance()
init {
reload()
}
override fun getRoot(): SnippetTreeNode {
return super.getRoot() as SnippetTreeNode
}
override fun reload(parent: TreeNode?) {
if (parent !is SnippetTreeNode) {
super.reload(parent)
return
}
parent.removeAllChildren()
val hosts = snippetManager.snippets()
val nodes = linkedMapOf<String, SnippetTreeNode>()
// 遍历 Host 列表,构建树节点
for (host in hosts) {
val node = SnippetTreeNode(host)
nodes[host.id] = node
}
for (host in hosts) {
val node = nodes[host.id] ?: continue
if (host.parentId.isBlank()) continue
val p = nodes[host.parentId] ?: continue
p.add(node)
}
for ((_, v) in nodes.entries) {
if (parent.data.id == v.data.parentId) {
parent.add(v)
}
}
super.reload(parent)
}
override fun insertNodeInto(newChild: MutableTreeNode, parent: MutableTreeNode, index: Int) {
super.insertNodeInto(newChild, parent, index)
// 重置所有排序
if (parent is SnippetTreeNode) {
for ((i, c) in parent.children().toList().filterIsInstance<SnippetTreeNode>().withIndex()) {
val sort = i.toLong()
if (c.data.sort == sort) continue
c.data = c.data.copy(sort = sort, updateDate = System.currentTimeMillis())
snippetManager.addSnippet(c.data)
}
}
}
}

View File

@@ -0,0 +1,26 @@
package app.termora.snippet
import app.termora.Icons
import app.termora.SimpleTreeNode
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeOpenIcon
import javax.swing.Icon
class SnippetTreeNode(snippet: Snippet) : SimpleTreeNode<Snippet>(snippet) {
override val folderCount: Int
get() = children().toList().count { if (it is SnippetTreeNode) it.data.type == SnippetType.Folder else false }
override val id get() = data.id
override val isFolder get() = data.type == SnippetType.Folder
override fun toString(): String {
return data.name
}
override fun getIcon(selected: Boolean, expanded: Boolean, hasFocus: Boolean): Icon {
return when (data.type) {
SnippetType.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
else -> if (selected && hasFocus) Icons.codeSpan.dark else Icons.codeSpan
}
}
}

View File

@@ -1,42 +1,19 @@
package app.termora.sync
import app.termora.*
import app.termora.AES.CBC.aesCBCDecrypt
import app.termora.AES.CBC.aesCBCEncrypt
import app.termora.AES.decodeBase64
import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject
import app.termora.ResponseException
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Request
import okhttp3.Response
import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory
import javax.swing.SwingUtilities
abstract class GitSyncer : Syncer {
abstract class GitSyncer : SafetySyncer() {
companion object {
private val log = LoggerFactory.getLogger(GitSyncer::class.java)
}
protected val description = "${Application.getName()} config"
protected val httpClient get() = Application.httpClient
protected val hostManager get() = HostManager.getInstance()
protected val keyManager get() = KeyManager.getInstance()
protected val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
protected val macroManager get() = MacroManager.getInstance()
protected val keymapManager get() = KeymapManager.getInstance()
override fun pull(config: SyncConfig): GistResponse {
if (log.isInfoEnabled) {
@@ -85,6 +62,14 @@ abstract class GitSyncer : Syncer {
}
}
// decode Snippets
if (config.ranges.contains(SyncRange.Snippets)) {
gistResponse.gists.findLast { it.filename == "Snippets" }?.let {
decodeSnippets(it.content, config)
}
}
if (log.isInfoEnabled) {
log.info("Type: ${config.type} , Gist: ${config.gistId} Pulled")
}
@@ -92,174 +77,6 @@ abstract class GitSyncer : Syncer {
return gistResponse
}
private fun decodeHosts(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
val hosts = hostManager.hosts().associateBy { it.id }
for (encryptedHost in encryptedHosts) {
val oldHost = hosts[encryptedHost.id]
// 如果一样,则无需配置
if (oldHost != null) {
if (oldHost.updateDate == encryptedHost.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(encryptedHost.id)
val host = Host(
id = encryptedHost.id,
name = encryptedHost.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
protocol = Protocol.valueOf(
encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
host = encryptedHost.host.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
port = encryptedHost.port.decodeBase64().aesCBCDecrypt(key, iv)
.decodeToString().toIntOrNull() ?: 0,
username = encryptedHost.username.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedHost.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
authentication = ohMyJson.decodeFromString(
encryptedHost.authentication.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
proxy = ohMyJson.decodeFromString(
encryptedHost.proxy.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
options = ohMyJson.decodeFromString(
encryptedHost.options.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
tunnelings = ohMyJson.decodeFromString(
encryptedHost.tunnelings.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
sort = encryptedHost.sort,
parentId = encryptedHost.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
ownerId = encryptedHost.ownerId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
createDate = encryptedHost.createDate,
updateDate = encryptedHost.updateDate,
deleted = encryptedHost.deleted
)
SwingUtilities.invokeLater { hostManager.addHost(host) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode host: ${encryptedHost.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode hosts: {}", text)
}
}
private fun decodeKeys(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
for (encryptedKey in encryptedKeys) {
try {
// aes iv
val iv = getIv(encryptedKey.id)
val keyPair = OhKeyPair(
id = encryptedKey.id,
publicKey = encryptedKey.publicKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
privateKey = encryptedKey.privateKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
type = encryptedKey.type.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
name = encryptedKey.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedKey.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
length = encryptedKey.length,
sort = encryptedKey.sort
)
SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode key: ${encryptedKey.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode keys: {}", text)
}
}
private fun decodeKeywordHighlights(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
for (e in encryptedKeywordHighlights) {
try {
// aes iv
val iv = getIv(e.id)
keywordHighlightManager.addKeywordHighlight(
e.copy(
keyword = e.keyword.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
description = e.description.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode KeywordHighlight: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode KeywordHighlight: {}", text)
}
}
private fun decodeMacros(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
for (e in encryptedMacros) {
try {
// aes iv
val iv = getIv(e.id)
macroManager.addMacro(
e.copy(
name = e.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
macro = e.macro.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode Macro: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode Macros: {}", text)
}
}
private fun decodeKeymaps(text: String, config: SyncConfig) {
for (keymap in ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }) {
keymapManager.addKeymap(keymap)
}
if (log.isDebugEnabled) {
log.debug("Decode Keymaps: {}", text)
}
}
private fun getKey(config: SyncConfig): ByteArray {
return ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
}
private fun getIv(id: String): ByteArray {
return ArrayUtils.subarray(id.padEnd(16, '0').toByteArray(), 0, 16)
}
override fun push(config: SyncConfig): GistResponse {
val gistFiles = mutableListOf<GistFile>()
@@ -268,62 +85,26 @@ abstract class GitSyncer : Syncer {
// Hosts
if (config.ranges.contains(SyncRange.Hosts)) {
val encryptedHosts = mutableListOf<EncryptedHost>()
for (host in hostManager.hosts()) {
// aes iv
val iv = ArrayUtils.subarray(host.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedHost = EncryptedHost()
encryptedHost.id = host.id
encryptedHost.name = host.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.protocol = host.protocol.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.host = host.host.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.port = "${host.port}".aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.username = host.username.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.remark = host.remark.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.authentication = ohMyJson.encodeToString(host.authentication)
.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.proxy = ohMyJson.encodeToString(host.proxy).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.options =
ohMyJson.encodeToString(host.options).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.tunnelings =
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.sort = host.sort
encryptedHost.deleted = host.deleted
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.createDate = host.createDate
encryptedHost.updateDate = host.updateDate
encryptedHosts.add(encryptedHost)
}
val hostsContent = ohMyJson.encodeToString(encryptedHosts)
val hostsContent = encodeHosts(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedHosts: {}", hostsContent)
}
gistFiles.add(GistFile("Hosts", hostsContent))
}
// Snippets
if (config.ranges.contains(SyncRange.Snippets)) {
val snippetsContent = encodeSnippets(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedSnippets: {}", snippetsContent)
}
gistFiles.add(GistFile("Snippets", snippetsContent))
}
// KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) {
val encryptedKeys = mutableListOf<OhKeyPair>()
for (keyPair in keyManager.getOhKeyPairs()) {
// aes iv
val iv = ArrayUtils.subarray(keyPair.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedKeyPair = OhKeyPair(
id = keyPair.id,
publicKey = keyPair.publicKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
privateKey = keyPair.privateKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
type = keyPair.type.aesCBCEncrypt(key, iv).encodeBase64String(),
name = keyPair.name.aesCBCEncrypt(key, iv).encodeBase64String(),
remark = keyPair.remark.aesCBCEncrypt(key, iv).encodeBase64String(),
length = keyPair.length,
sort = keyPair.sort
)
encryptedKeys.add(encryptedKeyPair)
}
val keysContent = ohMyJson.encodeToString(encryptedKeys)
val keysContent = encodeKeys(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedKeys: {}", keysContent)
}
@@ -332,17 +113,7 @@ abstract class GitSyncer : Syncer {
// Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlights = mutableListOf<KeywordHighlight>()
for (keywordHighlight in keywordHighlightManager.getKeywordHighlights()) {
// aes iv
val iv = getIv(keywordHighlight.id)
val encryptedKeyPair = keywordHighlight.copy(
keyword = keywordHighlight.keyword.aesCBCEncrypt(key, iv).encodeBase64String(),
description = keywordHighlight.description.aesCBCEncrypt(key, iv).encodeBase64String(),
)
keywordHighlights.add(encryptedKeyPair)
}
val keywordHighlightsContent = ohMyJson.encodeToString(keywordHighlights)
val keywordHighlightsContent = encodeKeywordHighlights(key)
if (log.isDebugEnabled) {
log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
}
@@ -351,17 +122,7 @@ abstract class GitSyncer : Syncer {
// Macros
if (config.ranges.contains(SyncRange.Macros)) {
val macros = mutableListOf<Macro>()
for (macro in macroManager.getMacros()) {
val iv = getIv(macro.id)
macros.add(
macro.copy(
name = macro.name.aesCBCEncrypt(key, iv).encodeBase64String(),
macro = macro.macro.aesCBCEncrypt(key, iv).encodeBase64String()
)
)
}
val macrosContent = ohMyJson.encodeToString(macros)
val macrosContent = encodeMacros(key)
if (log.isDebugEnabled) {
log.debug("Push macros: {}", macrosContent)
}
@@ -370,23 +131,12 @@ abstract class GitSyncer : Syncer {
// Keymap
if (config.ranges.contains(SyncRange.Keymap)) {
val keymaps = mutableListOf<JsonObject>()
for (keymap in keymapManager.getKeymaps()) {
// 只读的是内置的
if (keymap.isReadonly) {
continue
}
keymaps.add(keymap.toJSONObject())
}
if (keymaps.isNotEmpty()) {
val keymapsContent = ohMyJson.encodeToString(keymaps)
val keymapsContent = encodeKeymaps()
if (log.isDebugEnabled) {
log.debug("Push keymaps: {}", keymapsContent)
}
gistFiles.add(GistFile("Keymaps", keymapsContent))
}
}
if (gistFiles.isEmpty()) {
throw IllegalArgumentException("No gist files found")

View File

@@ -0,0 +1,356 @@
package app.termora.sync
import app.termora.*
import app.termora.AES.CBC.aesCBCDecrypt
import app.termora.AES.CBC.aesCBCEncrypt
import app.termora.AES.decodeBase64
import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager
import app.termora.snippet.Snippet
import app.termora.snippet.SnippetManager
import kotlinx.serialization.json.JsonObject
import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory
import javax.swing.SwingUtilities
abstract class SafetySyncer : Syncer {
companion object {
private val log = LoggerFactory.getLogger(SafetySyncer::class.java)
}
protected val description = "${Application.getName()} config"
protected val httpClient get() = Application.httpClient
protected val hostManager get() = HostManager.getInstance()
protected val keyManager get() = KeyManager.getInstance()
protected val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
protected val macroManager get() = MacroManager.getInstance()
protected val keymapManager get() = KeymapManager.getInstance()
protected val snippetManager get() = SnippetManager.getInstance()
protected fun decodeHosts(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
val hosts = hostManager.hosts().associateBy { it.id }
for (encryptedHost in encryptedHosts) {
val oldHost = hosts[encryptedHost.id]
// 如果一样,则无需配置
if (oldHost != null) {
if (oldHost.updateDate == encryptedHost.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(encryptedHost.id)
val host = Host(
id = encryptedHost.id,
name = encryptedHost.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
protocol = Protocol.valueOf(
encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
host = encryptedHost.host.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
port = encryptedHost.port.decodeBase64().aesCBCDecrypt(key, iv)
.decodeToString().toIntOrNull() ?: 0,
username = encryptedHost.username.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedHost.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
authentication = ohMyJson.decodeFromString(
encryptedHost.authentication.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
proxy = ohMyJson.decodeFromString(
encryptedHost.proxy.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
options = ohMyJson.decodeFromString(
encryptedHost.options.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
tunnelings = ohMyJson.decodeFromString(
encryptedHost.tunnelings.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
sort = encryptedHost.sort,
parentId = encryptedHost.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
ownerId = encryptedHost.ownerId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
createDate = encryptedHost.createDate,
updateDate = encryptedHost.updateDate,
deleted = encryptedHost.deleted
)
SwingUtilities.invokeLater { hostManager.addHost(host) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode host: ${encryptedHost.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode hosts: {}", text)
}
}
protected fun encodeHosts(key: ByteArray): String {
val encryptedHosts = mutableListOf<EncryptedHost>()
for (host in hostManager.hosts()) {
// aes iv
val iv = ArrayUtils.subarray(host.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedHost = EncryptedHost()
encryptedHost.id = host.id
encryptedHost.name = host.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.protocol = host.protocol.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.host = host.host.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.port = "${host.port}".aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.username = host.username.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.remark = host.remark.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.authentication = ohMyJson.encodeToString(host.authentication)
.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.proxy = ohMyJson.encodeToString(host.proxy).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.options =
ohMyJson.encodeToString(host.options).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.tunnelings =
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.sort = host.sort
encryptedHost.deleted = host.deleted
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.createDate = host.createDate
encryptedHost.updateDate = host.updateDate
encryptedHosts.add(encryptedHost)
}
return ohMyJson.encodeToString(encryptedHosts)
}
protected fun decodeSnippets(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedSnippets = ohMyJson.decodeFromString<List<Snippet>>(text)
val snippets = snippetManager.snippets().associateBy { it.id }
for (encryptedSnippet in encryptedSnippets) {
val oldHost = snippets[encryptedSnippet.id]
// 如果一样,则无需配置
if (oldHost != null) {
if (oldHost.updateDate == encryptedSnippet.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(encryptedSnippet.id)
val snippet = encryptedSnippet.copy(
name = encryptedSnippet.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
parentId = encryptedSnippet.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
snippet = encryptedSnippet.snippet.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
SwingUtilities.invokeLater { snippetManager.addSnippet(snippet) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode snippet: ${encryptedSnippet.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode hosts: {}", text)
}
}
protected fun encodeSnippets(key: ByteArray): String {
val snippets = mutableListOf<Snippet>()
for (snippet in snippetManager.snippets()) {
// aes iv
val iv = ArrayUtils.subarray(snippet.id.padEnd(16, '0').toByteArray(), 0, 16)
snippets.add(
snippet.copy(
name = snippet.name.aesCBCEncrypt(key, iv).encodeBase64String(),
snippet = snippet.snippet.aesCBCEncrypt(key, iv).encodeBase64String(),
parentId = snippet.parentId.aesCBCEncrypt(key, iv).encodeBase64String(),
)
)
}
return ohMyJson.encodeToString(snippets)
}
protected fun decodeKeys(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
for (encryptedKey in encryptedKeys) {
try {
// aes iv
val iv = getIv(encryptedKey.id)
val keyPair = OhKeyPair(
id = encryptedKey.id,
publicKey = encryptedKey.publicKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
privateKey = encryptedKey.privateKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
type = encryptedKey.type.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
name = encryptedKey.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedKey.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
length = encryptedKey.length,
sort = encryptedKey.sort
)
SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode key: ${encryptedKey.id} failed. error: {}", e.message, e)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode keys: {}", text)
}
}
protected fun encodeKeys(key: ByteArray): String {
val encryptedKeys = mutableListOf<OhKeyPair>()
for (keyPair in keyManager.getOhKeyPairs()) {
// aes iv
val iv = ArrayUtils.subarray(keyPair.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedKeyPair = OhKeyPair(
id = keyPair.id,
publicKey = keyPair.publicKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
privateKey = keyPair.privateKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
type = keyPair.type.aesCBCEncrypt(key, iv).encodeBase64String(),
name = keyPair.name.aesCBCEncrypt(key, iv).encodeBase64String(),
remark = keyPair.remark.aesCBCEncrypt(key, iv).encodeBase64String(),
length = keyPair.length,
sort = keyPair.sort
)
encryptedKeys.add(encryptedKeyPair)
}
return ohMyJson.encodeToString(encryptedKeys)
}
protected fun decodeKeywordHighlights(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
for (e in encryptedKeywordHighlights) {
try {
// aes iv
val iv = getIv(e.id)
keywordHighlightManager.addKeywordHighlight(
e.copy(
keyword = e.keyword.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
description = e.description.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode KeywordHighlight: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode KeywordHighlight: {}", text)
}
}
protected fun encodeKeywordHighlights(key: ByteArray): String {
val keywordHighlights = mutableListOf<KeywordHighlight>()
for (keywordHighlight in keywordHighlightManager.getKeywordHighlights()) {
// aes iv
val iv = getIv(keywordHighlight.id)
val encryptedKeyPair = keywordHighlight.copy(
keyword = keywordHighlight.keyword.aesCBCEncrypt(key, iv).encodeBase64String(),
description = keywordHighlight.description.aesCBCEncrypt(key, iv).encodeBase64String(),
)
keywordHighlights.add(encryptedKeyPair)
}
return ohMyJson.encodeToString(keywordHighlights)
}
protected fun decodeMacros(text: String, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
for (e in encryptedMacros) {
try {
// aes iv
val iv = getIv(e.id)
macroManager.addMacro(
e.copy(
name = e.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
macro = e.macro.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode Macro: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
if (log.isDebugEnabled) {
log.debug("Decode Macros: {}", text)
}
}
protected fun encodeMacros(key: ByteArray): String {
val macros = mutableListOf<Macro>()
for (macro in macroManager.getMacros()) {
val iv = getIv(macro.id)
macros.add(
macro.copy(
name = macro.name.aesCBCEncrypt(key, iv).encodeBase64String(),
macro = macro.macro.aesCBCEncrypt(key, iv).encodeBase64String()
)
)
}
return ohMyJson.encodeToString(macros)
}
protected fun decodeKeymaps(text: String, config: SyncConfig) {
for (keymap in ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }) {
keymapManager.addKeymap(keymap)
}
if (log.isDebugEnabled) {
log.debug("Decode Keymaps: {}", text)
}
}
protected fun encodeKeymaps(): String {
val keymaps = mutableListOf<JsonObject>()
for (keymap in keymapManager.getKeymaps()) {
// 只读的是内置的
if (keymap.isReadonly) {
continue
}
keymaps.add(keymap.toJSONObject())
}
return ohMyJson.encodeToString(keymaps)
}
protected open fun getKey(config: SyncConfig): ByteArray {
return ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
}
protected fun getIv(id: String): ByteArray {
return ArrayUtils.subarray(id.padEnd(16, '0').toByteArray(), 0, 16)
}
}

View File

@@ -4,6 +4,7 @@ enum class SyncType {
GitLab,
GitHub,
Gitee,
WebDAV,
}
enum class SyncRange {
@@ -12,6 +13,7 @@ enum class SyncRange {
KeywordHighlights,
Macros,
Keymap,
Snippets,
}
data class SyncConfig(

View File

@@ -15,6 +15,7 @@ class SyncerProvider private constructor() {
SyncType.GitHub -> GitHubSyncer.getInstance()
SyncType.Gitee -> GiteeSyncer.getInstance()
SyncType.GitLab -> GitLabSyncer.getInstance()
SyncType.WebDAV -> WebDAVSyncer.getInstance()
}
}
}

View File

@@ -0,0 +1,177 @@
package app.termora.sync
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.PBKDF2
import app.termora.ResponseException
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory
class WebDAVSyncer private constructor() : SafetySyncer() {
companion object {
private val log = LoggerFactory.getLogger(WebDAVSyncer::class.java)
fun getInstance(): WebDAVSyncer {
return ApplicationScope.forApplicationScope().getOrCreate(WebDAVSyncer::class) { WebDAVSyncer() }
}
}
override fun pull(config: SyncConfig): GistResponse {
val response = httpClient.newCall(newRequestBuilder(config).get().build()).execute()
if (!response.isSuccessful) {
throw ResponseException(response.code, response)
}
val text = response.use { resp -> resp.body?.use { it.string() } }
?: throw ResponseException(response.code, response)
val json = ohMyJson.decodeFromString<JsonObject>(text)
// decode hosts
if (config.ranges.contains(SyncRange.Hosts)) {
json["Hosts"]?.jsonPrimitive?.content?.let {
decodeHosts(it, config)
}
}
// decode KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) {
json["KeyPairs"]?.jsonPrimitive?.content?.let {
decodeKeys(it, config)
}
}
// decode Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
json["KeywordHighlights"]?.jsonPrimitive?.content?.let {
decodeKeywordHighlights(it, config)
}
}
// decode Macros
if (config.ranges.contains(SyncRange.Macros)) {
json["Macros"]?.jsonPrimitive?.content?.let {
decodeMacros(it, config)
}
}
// decode Keymaps
if (config.ranges.contains(SyncRange.Keymap)) {
json["Keymaps"]?.jsonPrimitive?.content?.let {
decodeKeymaps(it, config)
}
}
// decode Snippets
if (config.ranges.contains(SyncRange.Snippets)) {
json["Snippets"]?.jsonPrimitive?.content?.let {
decodeSnippets(it, config)
}
}
return GistResponse(config, emptyList())
}
override fun push(config: SyncConfig): GistResponse {
// aes key
val key = getKey(config)
val json = buildJsonObject {
// Hosts
if (config.ranges.contains(SyncRange.Hosts)) {
val hostsContent = encodeHosts(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedHosts: {}", hostsContent)
}
put("Hosts", hostsContent)
}
// Snippets
if (config.ranges.contains(SyncRange.Snippets)) {
val snippetsContent = encodeSnippets(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedSnippets: {}", snippetsContent)
}
put("Snippets", snippetsContent)
}
// KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) {
val keysContent = encodeKeys(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedKeys: {}", keysContent)
}
put("KeyPairs", keysContent)
}
// Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlightsContent = encodeKeywordHighlights(key)
if (log.isDebugEnabled) {
log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
}
put("KeywordHighlights", keywordHighlightsContent)
}
// Macros
if (config.ranges.contains(SyncRange.Macros)) {
val macrosContent = encodeMacros(key)
if (log.isDebugEnabled) {
log.debug("Push macros: {}", macrosContent)
}
put("Macros", macrosContent)
}
// Keymap
if (config.ranges.contains(SyncRange.Keymap)) {
val keymapsContent = encodeKeymaps()
if (log.isDebugEnabled) {
log.debug("Push keymaps: {}", keymapsContent)
}
put("Keymaps", keymapsContent)
}
}
val response = httpClient.newCall(
newRequestBuilder(config).put(
ohMyJson.encodeToString(json)
.toRequestBody("application/json".toMediaType())
).build()
).execute()
if (!response.isSuccessful) {
throw ResponseException(response.code, response)
}
return GistResponse(
config = config,
gists = emptyList()
)
}
private fun getWebDavFileUrl(config: SyncConfig): String {
return config.options["domain"] ?: throw IllegalStateException("domain is not defined")
}
override fun getKey(config: SyncConfig): ByteArray {
return PBKDF2.generateSecret(
config.gistId.toCharArray(),
config.token.toByteArray(),
10000, 128
)
}
private fun newRequestBuilder(config: SyncConfig): Request.Builder {
return Request.Builder()
.header("Authorization", Credentials.basic(config.gistId, config.token, Charsets.UTF_8))
.url(getWebDavFileUrl(config))
}
}

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