Compare commits

..

102 Commits
1.0.6 ... 1.0.9

Author SHA1 Message Date
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
131 changed files with 7540 additions and 1893 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-b825.69.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: jobs:
build: build:
runs-on: ubuntu-20.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# download jdk # download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b825.69.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b825.69.tar.gz
# appimagetool
- run: sudo apt install libfuse2
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -22,6 +25,15 @@ jobs:
java-version: '21.0.6' java-version: '21.0.6'
architecture: x64 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 # dist
- run: | - run: |
./gradlew dist --no-daemon ./gradlew dist --no-daemon
@@ -30,4 +42,6 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: termora-linux-x86-64 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: with:
fetch-depth: 0 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: github.ref_type == 'tag' && 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 # download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -23,13 +55,30 @@ jobs:
java-version: '21.0.6' java-version: '21.0.6'
architecture: aarch64 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 # 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: ${{ github.ref_type == 'tag' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
./gradlew dist --no-daemon ./gradlew dist --no-daemon
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: termora-osx-aarch64 name: termora-osx-aarch64
path: build/distributions/*.dmg path: |
build/distributions/*.zip
build/distributions/*.dmg

View File

@@ -10,8 +10,41 @@ jobs:
with: with:
fetch-depth: 0 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: github.ref_type == 'tag' && 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 # download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -23,12 +56,31 @@ jobs:
architecture: x64 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 # 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: ${{ github.ref_type == 'tag' && github.repository == 'TermoraDev/termora' }}
TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE: ${{ secrets.TERMORA_MAC_NOTARY_KEYCHAIN_PROFILE }}
run: |
./gradlew dist --no-daemon ./gradlew dist --no-daemon
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: termora-osx-x86-64 name: termora-osx-x86-64
path: build/distributions/*.dmg path: |
build/distributions/*.zip
build/distributions/*.dmg

View File

@@ -10,15 +10,34 @@ jobs:
with: with:
fetch-depth: 0 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 - 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: with:
distribution: 'jetbrains' path: |
java-version: '21' ~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# dist # dist
- run: | - run: |
.\gradlew.bat dist --no-daemon .\gradlew.bat dist --no-daemon
.\gradlew.bat --stop
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -27,3 +46,4 @@ jobs:
path: | path: |
build/distributions/*.zip build/distributions/*.zip
build/distributions/*.msi 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,12 +15,13 @@
## Features ## Features
- SSH and local terminal support - 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 - Compatible with Windows, macOS, and Linux
- Zmodem protocol support - Zmodem protocol support
- SSH port forwarding & Jump hosts - SSH port forwarding & Jump hosts
- Terminal log - Terminal log
- Configuration synchronization via [Gist](https://gist.github.com) - Configuration synchronization via [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- Macro support (record and replay scripts) - Macro support (record and replay scripts)
- Keyword highlighting - Keyword highlighting
- Key management - Key management
@@ -33,6 +34,7 @@
- [Latest release](https://github.com/TermoraDev/termora/releases/latest) - [Latest release](https://github.com/TermoraDev/termora/releases/latest)
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora` - [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 ## Development

View File

@@ -11,12 +11,13 @@
## 功能特性 ## 功能特性
- 支持 SSH 和本地终端 - 支持 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 平台 - 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议 - 支持 Zmodem 协议
- 支持 SSH 端口转发和跳板机 - 支持 SSH 端口转发和跳板机
- 终端日志记录 - 终端日志记录
- 支持配置同步到 [Gist](https://gist.github.com) - 支持配置同步到 [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- 支持宏(录制脚本并回放) - 支持宏(录制脚本并回放)
- 支持关键词高亮 - 支持关键词高亮
- 支持密钥管理器 - 支持密钥管理器
@@ -29,6 +30,7 @@
- [Latest release](https://github.com/TermoraDev/termora/releases/latest) - [Latest release](https://github.com/TermoraDev/termora/releases/latest)
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora` - [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 BSD 2-Clause "Simplified" License
https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt
commons-codec 1.17.1 commons-codec 1.18.0
Apache License 2.0 Apache License 2.0
https://github.com/apache/commons-codec/blob/master/LICENSE.txt https://github.com/apache/commons-codec/blob/master/LICENSE.txt
@@ -34,10 +34,18 @@ commons-net 3.11.1
Apache License 2.0 Apache License 2.0
https://github.com/apache/commons-net/blob/master/LICENSE.txt https://github.com/apache/commons-net/blob/master/LICENSE.txt
commons-text 1.12.0 commons-text 1.13.0
Apache License 2.0 Apache License 2.0
https://github.com/apache/commons-text/blob/master/LICENSE.txt https://github.com/apache/commons-text/blob/master/LICENSE.txt
commons-csv 1.13.0
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 eddsa 0.3.0
Creative Commons Zero v1.0 Universal Creative Commons Zero v1.0 Universal
https://github.com/str4d/ed25519-java/blob/master/LICENSE.txt https://github.com/str4d/ed25519-java/blob/master/LICENSE.txt
@@ -110,7 +118,7 @@ kotlin-logging 1.7.9
Apache License 2.0 Apache License 2.0
https://github.com/oshai/kotlin-logging/blob/master/LICENSE https://github.com/oshai/kotlin-logging/blob/master/LICENSE
kotlin-stdlib 2.1.0 kotlin-stdlib 2.1.10
Apache License 2.0 Apache License 2.0
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
@@ -126,6 +134,10 @@ kotlin-stdlib-jdk8 1.9.10
Apache License 2.0 Apache License 2.0
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
restart4j 0.0.1
Apache License 2.0
https://github.com/hstyi/restart4j/blob/main/LICENSE
kotlinx-coroutines-core-jvm 1.10.1 kotlinx-coroutines-core-jvm 1.10.1
Apache License 2.0 Apache License 2.0
https://www.apache.org/licenses/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 Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0 https://www.apache.org/licenses/LICENSE-2.0
kotlinx-serialization-core-jvm 1.7.3 kotlinx-serialization-core-jvm 1.8.0
Apache License 2.0 Apache License 2.0
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
kotlinx-serialization-json-jvm 1.7.3 kotlinx-serialization-json-jvm 1.8.0
Apache License 2.0 Apache License 2.0
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt

View File

@@ -3,11 +3,16 @@ import org.gradle.kotlin.dsl.support.uppercaseFirstChar
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils 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 org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
import java.io.FileNotFoundException
import java.nio.file.Files import java.nio.file.Files
import java.util.concurrent.Executors
import java.util.concurrent.Future
plugins { plugins {
java java
idea
application application
alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.kotlinx.serialization)
@@ -15,7 +20,7 @@ plugins {
group = "app.termora" group = "app.termora"
version = "1.0.6" version = "1.0.9"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture() val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
@@ -38,7 +43,7 @@ repositories {
dependencies { dependencies {
// 由于签名和公证macOS 不携带 natives // 由于签名和公证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(kotlin("test"))
testImplementation(libs.hutool) testImplementation(libs.hutool)
@@ -58,6 +63,7 @@ dependencies {
implementation(libs.commons.codec) implementation(libs.commons.codec)
implementation(libs.commons.io) implementation(libs.commons.io)
implementation(libs.commons.lang3) implementation(libs.commons.lang3)
implementation(libs.commons.csv)
implementation(libs.commons.net) implementation(libs.commons.net)
implementation(libs.commons.text) implementation(libs.commons.text)
implementation(libs.commons.compress) implementation(libs.commons.compress)
@@ -97,7 +103,7 @@ dependencies {
implementation(libs.sshd.core) implementation(libs.sshd.core)
implementation(libs.commonmark) implementation(libs.commonmark)
implementation(libs.jgit) implementation(libs.jgit)
implementation(libs.jgit.sshd) implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.jnafilechooser) implementation(libs.jnafilechooser)
implementation(libs.xodus.vfs) implementation(libs.xodus.vfs)
implementation(libs.xodus.openAPI) implementation(libs.xodus.openAPI)
@@ -106,6 +112,8 @@ dependencies {
implementation(libs.colorpicker) implementation(libs.colorpicker)
implementation(libs.mixpanel) implementation(libs.mixpanel)
implementation(libs.jSerialComm) implementation(libs.jSerialComm)
implementation(libs.ini4j)
implementation(libs.restart4j)
} }
application { application {
@@ -116,7 +124,6 @@ application {
"-XX:+ZUncommit", "-XX:+ZUncommit",
"-XX:+ZGenerational", "-XX:+ZGenerational",
"-XX:ZUncommitDelay=60", "-XX:ZUncommitDelay=60",
"-XX:SoftMaxHeapSize=64m"
) )
if (os.isMacOsX) { if (os.isMacOsX) {
@@ -142,16 +149,17 @@ tasks.test {
tasks.register<Copy>("copy-dependencies") { tasks.register<Copy>("copy-dependencies") {
val dir = layout.buildDirectory.dir("libs") val dir = layout.buildDirectory.dir("libs")
from(configurations.runtimeClasspath).into(dir) 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 的本地库提取 // 对 JNA 和 PTY4J 的本地库提取
// 提取出来是为了单独签名,不然无法通过公证 // 提取出来是为了单独签名,不然无法通过公证
if (os.isMacOsX && macOSSign) { if (os.isMacOsX && macOSSign) {
doLast { doLast {
val jna = libs.jna.asProvider().get() val archName = if (arch.isArm) "aarch64" else "x86_64"
val dylib = dir.get().dir("dylib").asFile val dylib = dir.get().dir("dylib").asFile
val pty4j = libs.pty4j.get()
val jSerialComm = libs.jSerialComm.get()
for (file in dir.get().asFile.listFiles() ?: emptyArray()) { for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) { if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
val targetDir = File(dylib, jna.name) val targetDir = File(dylib, jna.name)
@@ -177,7 +185,6 @@ tasks.register<Copy>("copy-dependencies") {
// 删除所有二进制类库 // 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "resources/*") } exec { commandLine("zip", "-d", file.absolutePath, "resources/*") }
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) { } else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
val archName = if (arch.isArm) "aarch64" else "x86_64"
val targetDir = FileUtils.getFile(dylib, jSerialComm.name, "OSX", archName) val targetDir = FileUtils.getFile(dylib, jSerialComm.name, "OSX", archName)
FileUtils.forceMkdir(targetDir) FileUtils.forceMkdir(targetDir)
// @formatter:off // @formatter:off
@@ -191,6 +198,24 @@ tasks.register<Copy>("copy-dependencies") {
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") } exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") } exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") } 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)
}
} }
} }
@@ -203,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/*") }
}
}
}
}
}
} }
} }
@@ -244,22 +336,24 @@ tasks.register<Exec>("jpackage") {
"-XX:+ZUncommit", "-XX:+ZUncommit",
"-XX:+ZGenerational", "-XX:+ZGenerational",
"-XX:ZUncommitDelay=60", "-XX:ZUncommitDelay=60",
"-XX:SoftMaxHeapSize=64m",
"-XX:+HeapDumpOnOutOfMemoryError", "-XX:+HeapDumpOnOutOfMemoryError",
"-Dlogger.console.level=off", "-Dlogger.console.level=off",
"-Dkotlinx.coroutines.debug=off", "-Dkotlinx.coroutines.debug=off",
"-Dapp-version=${project.version}", "-Dapp-version=${project.version}",
) )
options.add("-Dsun.java2d.metal=true")
if (os.isMacOsX) { if (os.isMacOsX) {
options.add("-Dsun.java2d.metal=true")
options.add("-Dapple.awt.application.appearance=system") options.add("-Dapple.awt.application.appearance=system")
options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED") options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
} else { }
if (os.isLinux) {
options.add("-Dsun.java2d.opengl=true") 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("--runtime-image", "${buildDir}/jlink"))
arguments.addAll(listOf("--name", project.name.uppercaseFirstChar())) arguments.addAll(listOf("--name", project.name.uppercaseFirstChar()))
arguments.addAll(listOf("--app-version", "${project.version}")) arguments.addAll(listOf("--app-version", "${project.version}"))
@@ -271,7 +365,17 @@ tasks.register<Exec>("jpackage") {
arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE))) arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE)))
arguments.addAll(listOf("--vendor", "TermoraDev")) arguments.addAll(listOf("--vendor", "TermoraDev"))
arguments.addAll(listOf("--copyright", "TermoraDev")) arguments.addAll(listOf("--copyright", "TermoraDev"))
arguments.addAll(listOf("--description", "A terminal emulator and SSH client."))
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) { if (os.isMacOsX) {
@@ -289,6 +393,10 @@ tasks.register<Exec>("jpackage") {
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.ico")) 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") arguments.add("--type")
if (os.isMacOsX) { if (os.isMacOsX) {
@@ -319,11 +427,8 @@ tasks.register("dist") {
throw GradleException("JVM: $vendor is not supported") 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 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") } exec { commandLine(gradlew, "clean") }
@@ -343,75 +448,8 @@ tasks.register("dist") {
// 打包 // 打包
exec { commandLine(gradlew, "jpackage") } exec { commandLine(gradlew, "jpackage") }
// pack // 根据不同的系统构建不同的二进制包
if (os.isWindows) { // zip and msi pack()
// 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,
)
}
}
}
} }
} }
@@ -449,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 对本地文件进行签名 * macOS 对本地文件进行签名
*/ */
@@ -468,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 { kotlin {
jvmToolchain { jvmToolchain {
@@ -476,3 +740,10 @@ kotlin {
vendor = JvmVendorSpec.JETBRAINS 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] [versions]
kotlin = "2.1.0" kotlin = "2.1.10"
slf4j = "2.0.16" slf4j = "2.0.16"
pty4j = "0.13.2" pty4j = "0.13.2"
tinylog = "2.7.0" tinylog = "2.7.0"
kotlinx-coroutines = "1.10.1" kotlinx-coroutines = "1.10.1"
flatlaf = "3.5.4" flatlaf = "3.5.4"
trove4j = "1.0.20200330" trove4j = "1.0.20200330"
kotlinx-serialization-json = "1.7.3" kotlinx-serialization-json = "1.8.0"
commons-codec = "1.17.1" commons-codec = "1.18.0"
commons-lang3 = "3.17.0" commons-lang3 = "3.17.0"
commons-csv = "1.13.0"
commons-net = "3.11.1" commons-net = "3.11.1"
commons-text = "1.12.0" commons-text = "1.13.0"
commons-compress = "1.27.1" commons-compress = "1.27.1"
koin-bom = "4.0.0" koin-bom = "4.0.0"
swingx = "1.6.5-1" swingx = "1.6.5-1"
@@ -41,7 +42,9 @@ rhino = "1.7.15"
delight-rhino-sandbox = "0.0.17" delight-rhino-sandbox = "0.0.17"
testcontainers = "1.20.4" testcontainers = "1.20.4"
mixpanel = "1.5.3" mixpanel = "1.5.3"
jSerialComm="2.11.0" jSerialComm = "2.11.0"
ini4j = "0.5.5-2"
restart4j = "0.0.1"
[libraries] [libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
@@ -53,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-codec = { group = "commons-codec", name = "commons-codec", version.ref = "commons-codec" }
commons-net = { group = "commons-net", name = "commons-net", version.ref = "commons-net" } 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-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-text = { group = "org.apache.commons", name = "commons-text", version.ref = "commons-text" }
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" } commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" }
pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" } pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" }
ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" }
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" } flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" } flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" }
trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" } trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" }
@@ -72,6 +77,7 @@ versioncompare = { module = "io.github.g00fy2:versioncompare", version.ref = "ve
jfa = { module = "de.jangassen:jfa", version.ref = "jfa" } jfa = { module = "de.jangassen:jfa", version.ref = "jfa" }
oshi-core = { module = "com.github.oshi:oshi-core", version.ref = "oshi" } oshi-core = { module = "com.github.oshi:oshi-core", version.ref = "oshi" }
commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" } 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" } jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" }
flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" } flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" }
leveldb = { module = "org.iq80.leveldb:leveldb", version.ref = "leveldb" } leveldb = { module = "org.iq80.leveldb:leveldb", version.ref = "leveldb" }

View File

@@ -15,6 +15,8 @@ import org.slf4j.LoggerFactory
import java.awt.Desktop import java.awt.Desktop
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration import java.time.Duration
import kotlin.math.ln import kotlin.math.ln
import kotlin.math.pow import kotlin.math.pow
@@ -60,6 +62,16 @@ object Application {
return "/bin/bash" 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 { fun getBaseDataDir(): File {
if (::baseDataDir.isInitialized) { if (::baseDataDir.isInitialized) {
return baseDataDir return baseDataDir
@@ -111,11 +123,18 @@ object Application {
return "Termora" return "Termora"
} }
@Suppress("OPT_IN_USAGE")
fun browse(uri: URI, async: Boolean = true) { fun browse(uri: URI, async: Boolean = true) {
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) Desktop.getDesktop().browse(uri)
} else if (async) { } else if (async) {
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) } GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
} else { } else {
tryBrowse(uri) tryBrowse(uri)

View File

@@ -4,6 +4,8 @@ import app.termora.actions.ActionManager
import app.termora.keymap.KeymapManager import app.termora.keymap.KeymapManager
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatSystemProperties import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatDesktop
import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse
import com.formdev.flatlaf.extras.FlatInspector import com.formdev.flatlaf.extras.FlatInspector
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import com.jthemedetecor.OsThemeDetector import com.jthemedetecor.OsThemeDetector
@@ -20,12 +22,14 @@ import org.apache.commons.lang3.SystemUtils
import org.json.JSONObject import org.json.JSONObject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.tinylog.configuration.Configuration import org.tinylog.configuration.Configuration
import java.awt.KeyboardFocusManager
import java.io.File import java.io.File
import java.nio.channels.FileChannel import java.nio.channels.FileChannel
import java.nio.channels.FileLock import java.nio.channels.FileLock
import java.nio.file.Paths import java.nio.file.Paths
import java.nio.file.StandardOpenOption import java.nio.file.StandardOpenOption
import java.util.* import java.util.*
import java.util.function.Consumer
import javax.swing.* import javax.swing.*
import kotlin.system.exitProcess import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@@ -73,6 +77,9 @@ class ApplicationRunner {
// 解密数据 // 解密数据
val openDoor = measureTimeMillis { openDoor() } val openDoor = measureTimeMillis { openDoor() }
// clear temporary
clearTemporary()
// 启动主窗口 // 启动主窗口
val startMainFrame = measureTimeMillis { startMainFrame() } 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() { private fun openDoor() {
if (Doorman.getInstance().isWorking()) { if (Doorman.getInstance().isWorking()) {
@@ -104,7 +119,37 @@ class ApplicationRunner {
} }
private fun startMainFrame() { private fun startMainFrame() {
TermoraFrameManager.getInstance().createWindow().isVisible = true 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() { private fun loadSettings() {
@@ -142,7 +187,7 @@ class ApplicationRunner {
themeManager.change(theme, true) themeManager.change(theme, true)
if (Application.isUnknownVersion()) if (Application.isUnknownVersion())
FlatInspector.install("ctrl shift alt X"); FlatInspector.install("ctrl shift alt X")
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true) UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false) UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)

View File

@@ -14,12 +14,10 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.File import java.io.File
import java.util.* import java.util.*
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@@ -55,6 +53,7 @@ class Database private constructor(private val env: Environment) : Disposable {
val safetyProperties by lazy { SafetyProperties("Setting.SafetyProperties") } val safetyProperties by lazy { SafetyProperties("Setting.SafetyProperties") }
val terminal by lazy { Terminal() } val terminal by lazy { Terminal() }
val appearance by lazy { Appearance() } val appearance by lazy { Appearance() }
val sftp by lazy { SFTP() }
val sync by lazy { Sync() } val sync by lazy { Sync() }
private val doorman get() = Doorman.getInstance() private val doorman get() = Doorman.getInstance()
@@ -401,10 +400,10 @@ class Database private constructor(private val env: Environment) : Disposable {
protected inner class CursorStylePropertyDelegate(defaultValue: CursorStyle) : protected inner class CursorStylePropertyDelegate(defaultValue: CursorStyle) :
PropertyDelegate<CursorStyle>(defaultValue) { PropertyDelegate<CursorStyle>(defaultValue) {
override fun convertValue(value: String): CursorStyle { override fun convertValue(value: String): CursorStyle {
try { return try {
return CursorStyle.valueOf(value) CursorStyle.valueOf(value)
} catch (e: Exception) { } catch (_: Exception) {
return initializer.invoke() initializer.invoke()
} }
} }
} }
@@ -454,6 +453,16 @@ class Database private constructor(private val env: Environment) : Disposable {
*/ */
var debug by BooleanPropertyDelegate(false) var debug by BooleanPropertyDelegate(false)
/**
* 蜂鸣声
*/
var beep by BooleanPropertyDelegate(true)
/**
* 光标闪烁
*/
var cursorBlink by BooleanPropertyDelegate(false)
/** /**
* 选中复制 * 选中复制
*/ */
@@ -463,6 +472,16 @@ class Database private constructor(private val env: Environment) : Disposable {
* 光标样式 * 光标样式
*/ */
var cursor by CursorStylePropertyDelegate(CursorStyle.Block) var cursor by CursorStylePropertyDelegate(CursorStyle.Block)
/**
* 终端断开连接时自动关闭Tab
*/
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
/**
* 是否显示悬浮工具栏
*/
var floatingToolbar by BooleanPropertyDelegate(true)
} }
/** /**
@@ -563,6 +582,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)
}
/** /**
* 同步配置 * 同步配置
*/ */

View File

@@ -54,7 +54,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
protected fun init() { protected fun init() {
defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE defaultCloseOperation = DISPOSE_ON_CLOSE
initTitleBar() initTitleBar()
initEvents() initEvents()
@@ -158,12 +158,14 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
openPopup = true openPopup = true
} }
val window = SwingUtilities.windowForComponent(c) val window = c as? Window ?: SwingUtilities.windowForComponent(c)
val windows = window.ownedWindows if (window != null) {
for (w in windows) { val windows = window.ownedWindows
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) { for (w in windows) {
openPopup = true if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
w.dispose() openPopup = true
w.dispose()
}
} }
} }

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.host.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.* 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 { fun UUID.toSimpleString(): String {
return toString().replace("-", StringUtils.EMPTY) return toString().replace("-", StringUtils.EMPTY)
} }
@@ -13,7 +24,13 @@ enum class Protocol {
Folder, Folder,
SSH, SSH,
Local, Local,
Serial Serial,
/**
* 交互式的 SFTP此协议只在系统内部交互不应该暴露给用户也不应该持久化
*/
@Transient
SFTPPty
} }
@@ -243,7 +260,7 @@ data class Host(
val tunnelings: List<Tunneling> = emptyList(), val tunnelings: List<Tunneling> = emptyList(),
/** /**
* 排序 * 排序,越小越靠前
*/ */
val sort: Long = 0, val sort: Long = 0,
/** /**
@@ -290,4 +307,8 @@ data class Host(
result = 31 * result + ownerId.hashCode() result = 31 * result + ownerId.hashCode()
return result return result
} }
override fun toString(): String {
return name
}
} }

View File

@@ -1,13 +1,5 @@
package app.termora package app.termora
import java.util.*
interface HostListener : EventListener {
fun hostAdded(host: Host) {}
fun hostRemoved(id: String) {}
fun hostsChanged() {}
}
class HostManager private constructor() { class HostManager private constructor() {
companion object { companion object {
@@ -17,39 +9,38 @@ class HostManager private constructor() {
} }
private val database get() = Database.getDatabase() 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() assertEventDispatchThread()
database.addHost(host) database.addHost(host)
if (notify) listeners.forEach { it.hostAdded(host) } if (host.deleted) {
} hosts.entries.removeIf { it.value.id == host.id || it.value.parentId == host.id }
} else {
fun removeHost(id: String) { hosts[host.id] = host
assertEventDispatchThread() }
database.removeHost(id)
listeners.forEach { it.hostRemoved(id) }
} }
/**
* 第一次调用从数据库中获取,后续从缓存中获取
*/
fun hosts(): List<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 }) .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

@@ -1134,16 +1134,16 @@ open class HostOptionsPane : OptionsPane() {
private fun initEvents() { private fun initEvents() {
addBtn.addActionListener(object : AbstractAction() { addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent?) { override fun actionPerformed(e: ActionEvent?) {
val dialog = HostTreeDialog(owner) { host -> val dialog = NewHostTreeDialog(owner)
jumpHosts.none { it.id == host.id } && filter.invoke(host) dialog.setFilter { node -> jumpHosts.none { it.id == node.host.id } && filter.invoke(node.host) }
} dialog.setTreeName("HostOptionsPane.JumpHostsOption.Tree")
dialog.setLocationRelativeTo(owner) dialog.setLocationRelativeTo(owner)
dialog.isVisible = true dialog.isVisible = true
val hosts = dialog.hosts val hosts = dialog.hosts
if (hosts.isEmpty()) { if (hosts.isEmpty()) {
return return
} }
hosts.forEach { hosts.forEach {
val rowCount = model.rowCount val rowCount = model.rowCount
jumpHosts.add(it) jumpHosts.add(it)

View File

@@ -1,5 +1,7 @@
package app.termora package app.termora
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders
import app.termora.terminal.* import app.termora.terminal.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -12,7 +14,7 @@ abstract class HostTerminalTab(
val windowScope: WindowScope, val windowScope: WindowScope,
val host: Host, val host: Host,
protected val terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal() protected val terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal()
) : PropertyTerminalTab() { ) : PropertyTerminalTab(), DataProvider {
companion object { companion object {
val Host = DataKey(app.termora.Host::class) val Host = DataKey(app.termora.Host::class)
} }
@@ -69,4 +71,11 @@ abstract class HostTerminalTab(
unread = false 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,585 +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
} else if (host.protocol == Protocol.Serial) {
icon = if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin
}
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,97 @@
package app.termora
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.TreeNode
class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
companion object {
private val hostManager get() = HostManager.getInstance()
}
/**
* 如果要重新赋值,记得修改 [Host.updateDate] 否则下次取出时可能时缓存的
*/
var host: 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)
val folderCount
get() = children().toList().count { if (it is HostTreeNode) it.host.protocol == Protocol.Folder else false }
override fun getParent(): HostTreeNode? {
return super.getParent() as HostTreeNode?
}
fun getAllChildren(): List<HostTreeNode> {
val children = mutableListOf<HostTreeNode>()
for (child in children()) {
if (child is HostTreeNode) {
children.add(child)
children.addAll(child.getAllChildren())
}
}
return children
}
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.host.protocol)) continue
val newChildNode = child.clone() as HostTreeNode
deepClone(newChildNode, child, scopes)
newNode.add(newChildNode)
}
}
override fun clone(): Any {
val newNode = HostTreeNode(host)
newNode.children = null
newNode.parent = null
return newNode
}
override fun isNodeChild(aNode: TreeNode?): Boolean {
if (aNode is HostTreeNode) {
for (node in childrenNode()) {
if (node.host == aNode.host) {
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 host == other.host
}
override fun hashCode(): Int {
return host.hashCode()
}
}

View File

@@ -3,11 +3,15 @@ package app.termora
object Icons { object Icons {
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") } 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 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 plugin by lazy { DynamicIcon("icons/plugin.svg", "icons/plugin_dark.svg") }
val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_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 down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") } val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") } val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
val 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 searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") } val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") } val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
@@ -24,6 +28,9 @@ object Icons {
val empty by lazy { DynamicIcon("icons/empty.svg") } val empty by lazy { DynamicIcon("icons/empty.svg") }
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.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 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 errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") } val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") } val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
@@ -48,6 +55,7 @@ object Icons {
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") } val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") } val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") }
val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_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 azure by lazy { DynamicIcon("icons/azure.svg", "icons/azure_dark.svg") }
val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") } val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") }
val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") } val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") }
@@ -68,6 +76,7 @@ object Icons {
val network by lazy { DynamicIcon("icons/network.svg", "icons/network_dark.svg") } 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 server by lazy { DynamicIcon("icons/server.svg", "icons/server_dark.svg") }
val runAnything by lazy { DynamicIcon("icons/runAnything.svg", "icons/runAnything_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 uiForm by lazy { DynamicIcon("icons/uiForm.svg", "icons/uiForm_dark.svg") }
val cloud by lazy { DynamicIcon("icons/cloud.svg", "icons/cloud_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") } val externalLink by lazy { DynamicIcon("icons/externalLink.svg", "icons/externalLink_dark.svg") }
@@ -110,5 +119,6 @@ object Icons {
val listKey by lazy { DynamicIcon("icons/listKey.svg", "icons/listKey_dark.svg") } 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 forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") }
val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_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 org.apache.commons.io.Charsets
import java.nio.charset.StandardCharsets 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 { override suspend fun openPtyConnector(): PtyConnector {
val winSize = terminalPanel.winSize() val winSize = terminalPanel.winSize()

View File

@@ -12,6 +12,10 @@ fun main() {
setupNativeLibraries() setupNativeLibraries()
} }
if (SystemUtils.IS_OS_MAC_OSX) {
System.setProperty("apple.awt.application.name", Application.getName())
}
ApplicationRunner().run() ApplicationRunner().run()
} }
@@ -46,4 +50,9 @@ private fun setupNativeLibraries() {
if (jSerialComm.exists()) { if (jSerialComm.exists()) {
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath) 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.Terminal
import app.termora.terminal.TerminalColor import app.termora.terminal.TerminalColor
import app.termora.terminal.TextStyle import app.termora.terminal.TextStyle
import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalDisplay import app.termora.terminal.panel.TerminalDisplay
import app.termora.terminal.panel.TerminalPaintListener import app.termora.terminal.panel.TerminalPaintListener
import app.termora.terminal.panel.TerminalPanel import app.termora.terminal.panel.TerminalPanel
@@ -32,13 +33,25 @@ class MultipleTerminalListener : TerminalPaintListener {
// 正在搜索那么需要下移 // 正在搜索那么需要下移
val finding = terminal.getTerminalModel().getData(TerminalPanel.Finding, false) 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.font = font
g.color = Color(colorPalette.getColor(TerminalColor.Normal.RED)) g.color = Color(colorPalette.getColor(TerminalColor.Normal.RED))
g.drawString( g.drawString(
text, text,
terminalDisplay.width - width - terminalPanel.getAverageCharWidth() / 2, terminalDisplay.width - width - terminalPanel.getAverageCharWidth() / 2,
g.fontMetrics.ascent + if (finding) y
g.fontMetrics.height + g.fontMetrics.ascent / 2 else 0
) )
g.font = oldFont g.font = oldFont
} }

View File

@@ -1,6 +1,7 @@
package app.termora package app.termora
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
@@ -13,11 +14,13 @@ import kotlin.math.abs
class MyTabbedPane : FlatTabbedPane() { class MyTabbedPane : FlatTabbedPane() {
private val owner: Window get() = SwingUtilities.getWindowAncestor(this)
private val dragMouseAdaptor = DragMouseAdaptor() private val dragMouseAdaptor = DragMouseAdaptor()
private val terminalTabbedManager private val terminalTabbedManager
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
.getData(DataProviders.TerminalTabbedManager) .getData(DataProviders.TerminalTabbedManager)
private val owner
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
.getData(DataProviders.TermoraFrame) as TermoraFrame
init { init {
initEvents() initEvents()
@@ -75,10 +78,13 @@ class MyTabbedPane : FlatTabbedPane() {
private var terminalTab: TerminalTab? = null private var terminalTab: TerminalTab? = null
private var isDragging = false private var isDragging = false
private var lastVisitTabIndex = -1 private var lastVisitTabIndex = -1
private var releasedPoint = Point()
override fun mousePressed(e: MouseEvent) { override fun mousePressed(e: MouseEvent) {
val index = indexAtLocation(e.x, e.y) val index = indexAtLocation(e.x, e.y)
if (index < 0 || !isTabClosable(index)) { if (index < 0 || !isTabClosable(index)) {
tabIndex = -1
mousePressedPoint = Point()
return return
} }
tabIndex = index tabIndex = index
@@ -137,9 +143,16 @@ class MyTabbedPane : FlatTabbedPane() {
} }
// 如果是取消,那么不需要移动到其它窗口 // 如果是取消,那么不需要移动到其它窗口
val c = if (cancelled) null else getTopMostWindowUnderMouse() val c = if (cancelled) owner else getTopMostWindowUnderMouse()
if (c != owner && c is TermoraFrame) {
dragToAnotherWindow(c) // 如果等于 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 { } else {
val tab = this.terminalTab val tab = this.terminalTab
val terminalTabbedManager = terminalTabbedManager val terminalTabbedManager = terminalTabbedManager
@@ -161,6 +174,7 @@ class MyTabbedPane : FlatTabbedPane() {
} }
override fun mouseReleased(e: MouseEvent) { override fun mouseReleased(e: MouseEvent) {
releasedPoint = e.point
stopDrag() stopDrag()
} }
@@ -213,20 +227,29 @@ class MyTabbedPane : FlatTabbedPane() {
} }
private fun dragToAnotherWindow(frame: TermoraFrame) { private fun dragToAnotherWindow(oldFrame: TermoraFrame, frame: TermoraFrame) {
val tab = this.terminalTab ?: return val tab = this.terminalTab ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return
val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return
val windowScope = frame.getData(DataProviders.WindowScope) ?: return
val oldWindowScope = oldFrame.getData(DataProviders.WindowScope) ?: return
val location = Point(MouseInfo.getPointerInfo().location) val location = Point(MouseInfo.getPointerInfo().location)
SwingUtilities.convertPointFromScreen(location, tabbedPane) SwingUtilities.convertPointFromScreen(location, tabbedPane)
val index = tabbedPane.indexAtLocation(location.x, location.y) val index = tabbedPane.indexAtLocation(location.x, location.y)
moveTab( moveTab(
tabbedManager, tabbedManager,
tab, tab,
index index
) )
TerminalPanelFactory.getInstance(oldWindowScope).removeTerminalPanel(terminalPanel)
TerminalPanelFactory.getInstance(windowScope).addTerminalPanel(terminalPanel)
if (frame.hasFocus()) { if (frame.hasFocus()) {
return return
} }

File diff suppressed because it is too large Load Diff

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.getSelectionHostTreeNodes(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,83 @@
package app.termora
import org.apache.commons.lang3.StringUtils
import javax.swing.tree.DefaultTreeModel
import javax.swing.tree.MutableTreeNode
import javax.swing.tree.TreeNode
class NewHostTreeModel : DefaultTreeModel(
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 com.jetbrains.JBR
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXLabel
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
import java.awt.Desktop import java.awt.Desktop
@@ -57,6 +55,7 @@ object OptionPane {
pane.selectInitialValue() pane.selectInitialValue()
} }
}) })
dialog.setLocationRelativeTo(parentComponent)
dialog.isVisible = true dialog.isVisible = true
dialog.dispose() dialog.dispose()
val selectedValue = pane.value val selectedValue = pane.value

View File

@@ -1,7 +1,11 @@
package app.termora 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.PropertyChangeEvent
import java.beans.PropertyChangeListener import java.beans.PropertyChangeListener
import java.util.*
abstract class PropertyTerminalTab : TerminalTab { abstract class PropertyTerminalTab : TerminalTab {
protected val listeners = mutableListOf<PropertyChangeListener>() protected val listeners = mutableListOf<PropertyChangeListener>()
@@ -26,6 +30,10 @@ abstract class PropertyTerminalTab : TerminalTab {
override fun onLostFocus() { override fun onLostFocus() {
hasFocus = false 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, rows: Int = 24, cols: Int = 80,
env: Map<String, String> = emptyMap(), env: Map<String, String> = emptyMap(),
charset: Charset = StandardCharsets.UTF_8 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 { ): PtyConnector {
val envs = mutableMapOf<String, String>() val envs = mutableMapOf<String, String>()
envs.putAll(System.getenv()) 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) { if (log.isDebugEnabled) {
log.debug("command: {} , envs: {}", commands.joinToString(" "), envs) log.debug("command: {} , envs: {}", commands.joinToString(" "), envs)
} }
val ptyProcess = PtyProcessBuilder(commands.toTypedArray()) val ptyProcess = PtyProcessBuilder(commands)
.setEnvironment(envs) .setEnvironment(envs)
.setInitialRows(rows) .setInitialRows(rows)
.setInitialColumns(cols) .setInitialColumns(cols)

View File

@@ -1,5 +1,6 @@
package app.termora package app.termora
import app.termora.actions.DataProviders
import app.termora.terminal.* import app.termora.terminal.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
@@ -23,8 +24,9 @@ abstract class PtyHostTerminalTab(
private var readerJob: Job? = null private var readerJob: Job? = null
private val ptyConnectorDelegate = PtyConnectorDelegate() private val ptyConnectorDelegate = PtyConnectorDelegate()
protected val terminalPanel = private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
TerminalPanelFactory.getInstance(windowScope).createTerminalPanel(terminal, ptyConnectorDelegate) protected val terminalPanel = terminalPanelFactory.createTerminalPanel(terminal, ptyConnectorDelegate)
.apply { Disposer.register(this@PtyHostTerminalTab, this) }
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance(windowScope) protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance(windowScope)
init { init {
@@ -49,7 +51,7 @@ abstract class PtyHostTerminalTab(
startPtyConnectorReader() startPtyConnectorReader()
// 启动命令 // 启动命令
if (host.options.startupCommand.isNotBlank()) { if (host.options.startupCommand.isNotBlank() && host.protocol != Protocol.SFTPPty) {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
delay(250.milliseconds) delay(250.milliseconds)
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
@@ -120,6 +122,7 @@ abstract class PtyHostTerminalTab(
override fun dispose() { override fun dispose() {
stop() stop()
terminalPanel
super.dispose() super.dispose()
@@ -133,4 +136,12 @@ abstract class PtyHostTerminalTab(
} }
abstract suspend fun openPtyConnector(): PtyConnector 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 package app.termora
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import app.termora.transport.TransportDataProviders import app.termora.transport.TransportDataProviders
import app.termora.transport.TransportPanel import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener import java.beans.PropertyChangeListener
@@ -8,12 +10,13 @@ import javax.swing.JComponent
import javax.swing.JOptionPane import javax.swing.JOptionPane
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
class SFTPTerminalTab : Disposable, TerminalTab { class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
private val transportPanel by lazy { private val sftp get() = Database.getDatabase().sftp
TransportPanel().apply { private val transportPanel = TransportPanel()
Disposer.register(this@SFTPTerminalTab, this)
} init {
Disposer.register(this, transportPanel)
} }
override fun getTitle(): String { override fun getTitle(): String {
@@ -41,6 +44,11 @@ class SFTPTerminalTab : Disposable, TerminalTab {
override fun canClose(): Boolean { override fun canClose(): Boolean {
assertEventDispatchThread() assertEventDispatchThread()
if (sftp.pinTab) {
return false
}
val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true
if (transportManager.getTransports().isEmpty()) { if (transportManager.getTransports().isEmpty()) {
return true return true
@@ -54,4 +62,12 @@ class SFTPTerminalTab : Disposable, TerminalTab {
) == JOptionPane.OK_OPTION ) == JOptionPane.OK_OPTION
} }
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == TransportDataProviders.TransportPanel) {
return transportPanel as T
}
return null
}
} }

View File

@@ -1,5 +1,7 @@
package app.termora package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.actions.TabReconnectAction import app.termora.actions.TabReconnectAction
import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor
import app.termora.keyboardinteractive.TerminalUserInteraction import app.termora.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.Session
import org.apache.sshd.common.session.SessionListener import org.apache.sshd.common.session.SessionListener
import org.apache.sshd.common.session.SessionListener.Event import org.apache.sshd.common.session.SessionListener.Event
import org.apache.sshd.common.util.net.SshdSocketAddress
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.*
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) { class SSHTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
companion object { 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 mutex = Mutex()
private val tab = this
private var sshClient: SshClient? = null private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null private var sshSession: ClientSession? = null
private var sshChannelShell: ChannelShell? = null private var sshChannelShell: ChannelShell? = null
private val terminalTabbedManager
get() = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
.getData(DataProviders.TerminalTabbedManager)
init { init {
terminalPanel.dropFiles = false terminalPanel.dropFiles = false
@@ -80,9 +89,34 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
terminal.write("SSH client is opening...\r\n") terminal.write("SSH client is opening...\r\n")
} }
var host =
this.host.copy(authentication = this.host.authentication.copy(), updateDate = System.currentTimeMillis())
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
val client = SshClients.openClient(host).also { sshClient = it } val client = SshClients.openClient(host).also { sshClient = it }
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// keyboard interactive // 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 sessionListener = MySessionListener()
val channelListener = MyChannelListener() val channelListener = MyChannelListener()
@@ -119,13 +153,25 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(
override fun channelClosed(channel: Channel, reason: Throwable?) { override fun channelClosed(channel: Channel, reason: Throwable?) {
coroutineScope.launch(Dispatchers.Swing) { coroutineScope.launch(Dispatchers.Swing) {
terminal.write("\r\n\r\n${ControlCharacters.ESC}[31m") 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) { 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("\r\n")
terminal.write("${ControlCharacters.ESC}[0m") terminal.write("${ControlCharacters.ESC}[0m")
terminalModel.setData(DataKey.ShowCursor, false) 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) { for (tunneling in host.tunnelings) {
if (tunneling.type == TunnelingType.Local) { try {
session.startLocalPortForwarding( SshClients.openTunneling(session, host, tunneling)
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort), withContext(Dispatchers.Swing) {
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort) terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
) }
} else if (tunneling.type == TunnelingType.Remote) { } catch (e: Exception) {
session.startRemotePortForwarding( if (log.isErrorEnabled) {
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort), log.error("Start [${tunneling.name}] port forwarding failed: {}", e.message, e)
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort), }
) withContext(Dispatchers.Swing) {
} else if (tunneling.type == TunnelingType.Dynamic) { terminal.write("Start [${tunneling.name}] port forwarding failed: ${e.message}\r\n")
session.startDynamicPortForwarding( }
SshdSocketAddress(
tunneling.sourceHost,
tunneling.sourcePort
)
)
} }
if (log.isInfoEnabled) {
log.info("SSH [{}] started {} port forwarding.", host.name, tunneling.name)
}
withContext(Dispatchers.Swing) {
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
}
} }
} }
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == SSHSession) {
return sshSession as T?
}
return super.getData(dataKey)
}
override fun stop() { override fun stop() {
if (mutex.tryLock()) { 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

@@ -5,7 +5,8 @@ import org.apache.commons.io.Charsets
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import javax.swing.Icon import javax.swing.Icon
class SerialTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) { class SerialTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
override suspend fun openPtyConnector(): PtyConnector { override suspend fun openPtyConnector(): PtyConnector {
val serialPort = Serials.openPort(host) val serialPort = Serials.openPort(host)
return SerialPortPtyConnector( return SerialPortPtyConnector(

View File

@@ -4,6 +4,7 @@ import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson import app.termora.Application.ohMyJson
import app.termora.actions.AnAction import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.highlight.KeywordHighlight import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap import app.termora.keymap.Keymap
@@ -20,8 +21,11 @@ import app.termora.sync.SyncType
import app.termora.sync.SyncerProvider import app.termora.sync.SyncerProvider
import app.termora.terminal.CursorStyle import app.termora.terminal.CursorStyle
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalPanel import app.termora.terminal.panel.TerminalPanel
import app.termora.transport.SFTPAction
import cash.z.ecc.android.bip39.Mnemonics import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.* import com.formdev.flatlaf.extras.components.*
import com.formdev.flatlaf.util.FontUtils import com.formdev.flatlaf.util.FontUtils
@@ -32,17 +36,20 @@ import com.jthemedetecor.OsThemeDetector
import com.sun.jna.LastErrorException import com.sun.jna.LastErrorException
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import org.apache.commons.codec.binary.Base64
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils import org.apache.commons.lang3.time.DateFormatUtils
import org.jdesktop.swingx.JXEditorPane import org.jdesktop.swingx.JXEditorPane
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
import java.awt.Dimension
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import java.awt.event.ItemEvent import java.awt.event.ItemEvent
import java.io.File import java.io.File
@@ -62,6 +69,7 @@ class SettingsOptionsPane : OptionsPane() {
private val hostManager get() = HostManager.getInstance() private val hostManager get() = HostManager.getInstance()
private val keymapManager get() = KeymapManager.getInstance() private val keymapManager get() = KeymapManager.getInstance()
private val macroManager get() = MacroManager.getInstance() private val macroManager get() = MacroManager.getInstance()
private val actionManager get() = ActionManager.getInstance()
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance() private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
private val keyManager get() = KeyManager.getInstance() private val keyManager get() = KeyManager.getInstance()
@@ -107,6 +115,7 @@ class SettingsOptionsPane : OptionsPane() {
addOption(AppearanceOption()) addOption(AppearanceOption())
addOption(TerminalOption()) addOption(TerminalOption())
addOption(KeyShortcutsOption()) addOption(KeyShortcutsOption())
addOption(SFTPOption())
addOption(CloudSyncOption()) addOption(CloudSyncOption())
addOption(DoormanOption()) addOption(DoormanOption())
addOption(AboutOption()) addOption(AboutOption())
@@ -189,12 +198,7 @@ class SettingsOptionsPane : OptionsPane() {
if (it.stateChange == ItemEvent.SELECTED) { if (it.stateChange == ItemEvent.SELECTED) {
appearance.language = languageComboBox.selectedItem as String appearance.language = languageComboBox.selectedItem as String
SwingUtilities.invokeLater { SwingUtilities.invokeLater {
OptionPane.showMessageDialog( TermoraRestarter.getInstance().scheduleRestart(owner)
owner,
I18n.getString("termora.settings.restart.message"),
I18n.getString("termora.settings.restart.title"),
messageType = JOptionPane.INFORMATION_MESSAGE,
)
} }
} }
} }
@@ -298,12 +302,16 @@ class SettingsOptionsPane : OptionsPane() {
private inner class TerminalOption : JPanel(BorderLayout()), Option { private inner class TerminalOption : JPanel(BorderLayout()), Option {
private val cursorStyleComboBox = FlatComboBox<CursorStyle>() private val cursorStyleComboBox = FlatComboBox<CursorStyle>()
private val debugComboBox = YesOrNoComboBox() private val debugComboBox = YesOrNoComboBox()
private val beepComboBox = YesOrNoComboBox()
private val cursorBlinkComboBox = YesOrNoComboBox()
private val fontComboBox = FlatComboBox<String>() private val fontComboBox = FlatComboBox<String>()
private val shellComboBox = FlatComboBox<String>() private val shellComboBox = FlatComboBox<String>()
private val maxRowsTextField = IntSpinner(0, 0) private val maxRowsTextField = IntSpinner(0, 0)
private val fontSizeTextField = IntSpinner(0, 9, 99) private val fontSizeTextField = IntSpinner(0, 9, 99)
private val terminalSetting get() = Database.getDatabase().terminal private val terminalSetting get() = Database.getDatabase().terminal
private val selectCopyComboBox = YesOrNoComboBox() private val selectCopyComboBox = YesOrNoComboBox()
private val autoCloseTabComboBox = YesOrNoComboBox()
private val floatingToolbarComboBox = YesOrNoComboBox()
init { init {
initView() initView()
@@ -319,6 +327,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 -> selectCopyComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) { if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean
@@ -355,6 +383,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 { shellComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) { if (it.stateChange == ItemEvent.SELECTED) {
terminalSetting.localShell = shellComboBox.selectedItem as String terminalSetting.localShell = shellComboBox.selectedItem as String
@@ -390,6 +431,11 @@ class SettingsOptionsPane : OptionsPane() {
} }
fontComboBox.renderer = object : DefaultListCellRenderer() { fontComboBox.renderer = object : DefaultListCellRenderer() {
init {
preferredSize = Dimension(preferredSize.width, fontComboBox.preferredSize.height - 2)
maximumSize = Dimension(preferredSize.width, preferredSize.height)
}
override fun getListCellRendererComponent( override fun getListCellRendererComponent(
list: JList<*>?, list: JList<*>?,
value: Any?, value: Any?,
@@ -423,28 +469,11 @@ class SettingsOptionsPane : OptionsPane() {
shellComboBox.selectedItem = terminalSetting.localShell shellComboBox.selectedItem = terminalSetting.localShell
val fonts = linkedSetOf( val fonts = linkedSetOf<String>("JetBrains Mono", "Source Code Pro", "Monospaced")
"JetBrains Mono", FontUtils.getAllFonts().forEach {
"Source Code Pro", if (!fonts.contains(it.family)) {
"Monospaced", fonts.addLast(it.family)
"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
} }
fonts.remove(font.family)
} }
for (font in fonts) { for (font in fonts) {
@@ -453,8 +482,12 @@ class SettingsOptionsPane : OptionsPane() {
fontComboBox.selectedItem = terminalSetting.font fontComboBox.selectedItem = terminalSetting.font
debugComboBox.selectedItem = terminalSetting.debug debugComboBox.selectedItem = terminalSetting.debug
beepComboBox.selectedItem = terminalSetting.beep
cursorBlinkComboBox.selectedItem = terminalSetting.cursorBlink
cursorStyleComboBox.selectedItem = terminalSetting.cursor cursorStyleComboBox.selectedItem = terminalSetting.cursor
selectCopyComboBox.selectedItem = terminalSetting.selectCopy selectCopyComboBox.selectedItem = terminalSetting.selectCopy
autoCloseTabComboBox.selectedItem = terminalSetting.autoCloseTabWhenDisconnected
floatingToolbarComboBox.selectedItem = terminalSetting.floatingToolbar
} }
override fun getIcon(isSelected: Boolean): Icon { override fun getIcon(isSelected: Boolean): Icon {
@@ -472,9 +505,14 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent { private fun getCenterComponent(): JComponent {
val layout = FormLayout( val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow", "left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" "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 var rows = 1
val step = 2 val step = 2
val panel = FormBuilder.create().layout(layout) val panel = FormBuilder.create().layout(layout)
@@ -487,10 +525,19 @@ class SettingsOptionsPane : OptionsPane() {
.add(maxRowsTextField).xy(3, rows).apply { rows += step } .add(maxRowsTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows)
.add(debugComboBox).xy(3, rows).apply { rows += step } .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("${I18n.getString("termora.settings.terminal.select-copy")}:").xy(1, rows)
.add(selectCopyComboBox).xy(3, rows).apply { rows += step } .add(selectCopyComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows)
.add(cursorStyleComboBox).xy(3, rows).apply { rows += step } .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("${I18n.getString("termora.settings.terminal.local-shell")}:").xy(1, rows)
.add(shellComboBox).xyw(3, rows, 5) .add(shellComboBox).xyw(3, rows, 5)
.build() .build()
@@ -550,12 +597,6 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
if (typeComboBox.selectedItem == SyncType.Gitee) {
gistTextField.trailingComponent = null
} else {
gistTextField.trailingComponent = visitGistBtn
}
removeAll() removeAll()
add(getCenterComponent(), BorderLayout.CENTER) add(getCenterComponent(), BorderLayout.CENTER)
revalidate() revalidate()
@@ -642,13 +683,40 @@ class SettingsOptionsPane : OptionsPane() {
private fun export() { 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() val fileChooser = FileChooser()
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
fileChooser.win32Filters.add(Pair("All Files", listOf("*"))) fileChooser.win32Filters.add(Pair("All Files", listOf("*")))
fileChooser.win32Filters.add(Pair("JSON files", listOf("json"))) fileChooser.win32Filters.add(Pair("JSON files", listOf("json")))
fileChooser.showSaveDialog(owner, "${Application.getName()}.json").thenAccept { file -> fileChooser.showSaveDialog(owner, "${Application.getName()}.json").thenAccept { file ->
if (file != null) { if (file != null) {
SwingUtilities.invokeLater { exportText(file) } SwingUtilities.invokeLater { exportText(file, password) }
} }
} }
} }
@@ -665,6 +733,7 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
@Suppress("DuplicatedCode")
private fun importFromFile(file: File) { private fun importFromFile(file: File) {
if (!file.exists()) { if (!file.exists()) {
return return
@@ -695,7 +764,79 @@ class SettingsOptionsPane : OptionsPane() {
return 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)) { if (ranges.contains(SyncRange.Hosts)) {
val hosts = json["hosts"] val hosts = json["hosts"]
if (hosts is JsonArray) { if (hosts is JsonArray) {
@@ -756,9 +897,9 @@ class SettingsOptionsPane : OptionsPane() {
) )
} }
private fun exportText(file: File) { private fun exportText(file: File, password: String) {
val syncConfig = getSyncConfig() val syncConfig = getSyncConfig()
val text = ohMyJson.encodeToString(buildJsonObject { var text = ohMyJson.encodeToString(buildJsonObject {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
put("exporter", SystemUtils.USER_NAME) put("exporter", SystemUtils.USER_NAME)
put("version", Application.getVersion()) put("version", Application.getVersion())
@@ -797,6 +938,19 @@ class SettingsOptionsPane : OptionsPane() {
put("terminal", ohMyJson.encodeToJsonElement(database.terminal.getProperties())) 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 { file.outputStream().use {
IOUtils.write(text, it, StandardCharsets.UTF_8) IOUtils.write(text, it, StandardCharsets.UTF_8)
OptionPane.openFileInFolder( OptionPane.openFileInFolder(
@@ -987,6 +1141,7 @@ class SettingsOptionsPane : OptionsPane() {
typeComboBox.addItem(SyncType.GitHub) typeComboBox.addItem(SyncType.GitHub)
typeComboBox.addItem(SyncType.GitLab) typeComboBox.addItem(SyncType.GitLab)
typeComboBox.addItem(SyncType.Gitee) typeComboBox.addItem(SyncType.Gitee)
typeComboBox.addItem(SyncType.WebDAV)
hostsCheckBox.isFocusable = false hostsCheckBox.isFocusable = false
keysCheckBox.isFocusable = false keysCheckBox.isFocusable = false
@@ -1005,7 +1160,31 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.text = sync.token tokenTextField.text = sync.token
domainTextField.trailingComponent = JButton(Icons.externalLink).apply { domainTextField.trailingComponent = JButton(Icons.externalLink).apply {
addActionListener { addActionListener {
Application.browse(URI.create("https://docs.gitlab.com/ee/api/snippets.html")) 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 +1194,15 @@ class SettingsOptionsPane : OptionsPane() {
tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null
if (typeComboBox.selectedItem == SyncType.GitLab) { if (domainTextField.text.isBlank()) {
if (domainTextField.text.isBlank()) { if (typeComboBox.selectedItem == SyncType.GitLab) {
domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api") domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api")
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
domainTextField.text = sync.domain
} }
} }
val lastSyncTime = sync.lastSyncTime val lastSyncTime = sync.lastSyncTime
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${ lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${
if (lastSyncTime > 0) DateFormatUtils.format( if (lastSyncTime > 0) DateFormatUtils.format(
@@ -1069,17 +1251,37 @@ class SettingsOptionsPane : OptionsPane() {
val builder = FormBuilder.create().layout(layout).debug(false) val builder = FormBuilder.create().layout(layout).debug(false)
val box = Box.createHorizontalBox() val box = Box.createHorizontalBox()
box.add(typeComboBox) box.add(typeComboBox)
if (typeComboBox.selectedItem == SyncType.GitLab) { if (typeComboBox.selectedItem == SyncType.GitLab || typeComboBox.selectedItem == SyncType.WebDAV) {
box.add(Box.createHorizontalStrut(4)) box.add(Box.createHorizontalStrut(4))
box.add(domainTextField) box.add(domainTextField)
} }
builder.add("${I18n.getString("termora.settings.sync.type")}:").xy(1, rows) builder.add("${I18n.getString("termora.settings.sync.type")}:").xy(1, rows)
.add(box).xy(3, rows).apply { rows += step } .add(box).xy(3, rows).apply { rows += step }
builder.add("${I18n.getString("termora.settings.sync.token")}:").xy(1, rows) val isWebDAV = typeComboBox.selectedItem == SyncType.WebDAV
.add(tokenTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.gist")}:").xy(1, rows) val tokenText = if (isWebDAV) {
.add(gistTextField).xy(3, rows).apply { rows += step } 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("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows)
.add(rangeBox).xy(3, rows).apply { rows += step } .add(rangeBox).xy(3, rows).apply { rows += step }
// Sync buttons // Sync buttons
@@ -1100,6 +1302,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 { private inner class AboutOption : JPanel(BorderLayout()), Option {
init { init {
@@ -1317,7 +1618,7 @@ class SettingsOptionsPane : OptionsPane() {
val key = doorman.work(passwordTextField.password) val key = doorman.work(passwordTextField.password)
hosts.forEach { hostManager.addHost(it, false) } hosts.forEach { hostManager.addHost(it) }
keyPairs.forEach { keyManager.addOhKeyPair(it) } keyPairs.forEach { keyManager.addOhKeyPair(it) }
for (e in properties) { for (e in properties) {
for ((k, v) in e.second) { for ((k, v) in e.second) {

View File

@@ -2,28 +2,49 @@ package app.termora
import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize 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.ClientBuilder
import org.apache.sshd.client.SshClient import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ChannelShell import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.channel.ClientChannelEvent
import org.apache.sshd.client.config.hosts.HostConfigEntry
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver 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.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.client.session.ClientSession
import org.apache.sshd.common.SshException import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.PtyChannelConfiguration 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.global.KeepAliveHandler
import org.apache.sshd.common.kex.BuiltinDHFactories import org.apache.sshd.common.kex.BuiltinDHFactories
import org.apache.sshd.common.keyprovider.KeyIdentityProvider
import org.apache.sshd.common.util.net.SshdSocketAddress import org.apache.sshd.common.util.net.SshdSocketAddress
import org.apache.sshd.core.CoreModuleProperties import org.apache.sshd.core.CoreModuleProperties
import org.apache.sshd.server.forward.AcceptAllForwardingFilter import org.apache.sshd.server.forward.AcceptAllForwardingFilter
import org.apache.sshd.server.forward.RejectAllForwardingFilter import org.apache.sshd.server.forward.RejectAllForwardingFilter
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.eclipse.jgit.internal.transport.sshd.JGitSshClient import org.eclipse.jgit.internal.transport.sshd.JGitSshClient
import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
import org.eclipse.jgit.transport.sshd.ProxyData import org.eclipse.jgit.transport.sshd.ProxyData
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.Window
import java.io.ByteArrayOutputStream
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy 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.time.Duration
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import kotlin.math.max import kotlin.math.max
object SshClients { 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>() val sessions = mutableListOf<ClientSession>()
for (i in 0 until jumpHosts.size) { for (i in 0 until jumpHosts.size) {
val currentHost = jumpHosts[i] val currentHost = jumpHosts[i]
sessions.add(doOpenSession(currentHost, client)) sessions.add(doOpenSession(currentHost, client, i != 0))
// 如果有下一跳 // 如果有下一跳
if (i < jumpHosts.size - 1) { 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}") log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
} }
// 映射完毕之后修改Host和端口 // 映射完毕之后修改Host和端口
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port) jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
} }
} }
return sessions.last() return sessions.last()
} }
private fun doOpenSession(host: Host, client: SshClient): ClientSession { fun isMiddleware(session: ClientSession): Boolean {
val session = client.connect(host.username, host.host, host.port) 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 .verify(timeout).session
if (host.authentication.type == AuthenticationType.Password) { if (host.authentication.type == AuthenticationType.Password) {
session.addPasswordIdentity(host.authentication.password) session.addPasswordIdentity(host.authentication.password)
@@ -126,6 +194,41 @@ object SshClients {
return session 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) builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY)
val sshClient = builder.build() as JGitSshClient 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) val heartbeatInterval = max(host.options.heartbeatInterval, 3)
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong())) CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong()))
CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true) CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true)
@@ -186,3 +294,93 @@ object SshClients {
return sshClient 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 logger listener
terminal.getTerminalModel().addDataListener(TerminalLoggerDataListener(terminal)) terminal.getTerminalModel().addDataListener(TerminalLoggerDataListener(terminal))
terminal.addTerminalListener(object : TerminalListener {
override fun onClose(terminal: Terminal) {
terminals.remove(terminal)
}
})
terminals.add(terminal) terminals.add(terminal)
return terminal return terminal
@@ -51,6 +56,11 @@ class TerminalFactory private constructor() : Disposable {
return colorPalette return colorPalette
} }
override fun bell() {
if (config.beep) {
super.bell()
}
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(key: DataKey<T>): T { override fun <T : Any> getData(key: DataKey<T>): T {

View File

@@ -1,39 +1,67 @@
package app.termora package app.termora
import app.termora.highlight.KeywordHighlightPaintListener import app.termora.highlight.KeywordHighlightPaintListener
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector import app.termora.terminal.PtyConnector
import app.termora.terminal.Terminal import app.termora.terminal.Terminal
import app.termora.terminal.panel.TerminalHyperlinkPaintListener import app.termora.terminal.panel.TerminalHyperlinkPaintListener
import app.termora.terminal.panel.TerminalPanel import app.termora.terminal.panel.TerminalPanel
import kotlinx.coroutines.*
import java.awt.event.ComponentEvent import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener import java.awt.event.ComponentListener
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import kotlin.time.Duration.Companion.milliseconds
class TerminalPanelFactory { class TerminalPanelFactory : Disposable {
private val terminalPanels = mutableListOf<TerminalPanel>() private val terminalPanels = mutableListOf<TerminalPanel>()
companion object { companion object {
private val Factory = DataKey(TerminalPanelFactory::class)
fun getInstance(scope: Scope): TerminalPanelFactory { fun getInstance(scope: Scope): TerminalPanelFactory {
return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() } return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
} }
fun getAllTerminalPanel(): Array<TerminalPanel> {
return ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }
.flatMap { it.terminalPanels }.toTypedArray()
}
} }
init {
// repaint
Painter.getInstance()
}
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel { fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
val terminalPanel = TerminalPanel(terminal, ptyConnector) val terminalPanel = TerminalPanel(terminal, ptyConnector)
terminalPanel.addTerminalPaintListener(MultipleTerminalListener()) terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance()) terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.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 return terminalPanel
} }
fun getTerminalPanels(): List<TerminalPanel> { fun getTerminalPanels(): Array<TerminalPanel> {
return terminalPanels return terminalPanels.toTypedArray()
} }
fun repaintAll() { fun repaintAll() {
if (SwingUtilities.isEventDispatchThread()) { if (SwingUtilities.isEventDispatchThread()) {
terminalPanels.forEach { it.repaintImmediate() } getTerminalPanels().forEach { it.repaintImmediate() }
} else { } else {
SwingUtilities.invokeLater { repaintAll() } 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

@@ -18,11 +18,8 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.beans.PropertyChangeListener import java.beans.PropertyChangeListener
import java.util.* import java.util.*
import javax.swing.Icon import javax.swing.*
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
import javax.swing.SwingUtilities
import kotlin.math.min import kotlin.math.min
class TerminalTabbed( class TerminalTabbed(
@@ -76,12 +73,17 @@ class TerminalTabbed(
tabbedPane.addPropertyChangeListener("selectedIndex") { evt -> tabbedPane.addPropertyChangeListener("selectedIndex") { evt ->
val oldIndex = evt.oldValue as Int val oldIndex = evt.oldValue as Int
val newIndex = evt.newValue as Int val newIndex = evt.newValue as Int
if (oldIndex >= 0 && tabs.size > newIndex) { if (oldIndex >= 0 && tabs.size > newIndex) {
tabs[oldIndex].onLostFocus() tabs[oldIndex].onLostFocus()
} }
if (newIndex >= 0 && tabs.size > newIndex) { if (newIndex >= 0 && tabs.size > newIndex) {
tabs[newIndex].onGrabFocus() tabs[newIndex].onGrabFocus()
} }
SwingUtilities.invokeLater { tabbedPane.getComponentAt(newIndex).requestFocusInWindow() }
} }
// 选择变动 // 选择变动
@@ -177,6 +179,9 @@ class TerminalTabbed(
// 新的获取到焦点 // 新的获取到焦点
tabs[tabbedPane.selectedIndex].onGrabFocus() tabs[tabbedPane.selectedIndex].onGrabFocus()
// 新的真正获取焦点
tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow()
if (disposable) { if (disposable) {
Disposer.dispose(tab) Disposer.dispose(tab)
} }
@@ -238,6 +243,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() popupMenu.addSeparator()
// 关闭 // 关闭
@@ -264,7 +280,7 @@ class TerminalTabbed(
} }
close.isEnabled = c !is WelcomePanel close.isEnabled = tab.canClose()
rename.isEnabled = close.isEnabled rename.isEnabled = close.isEnabled
clone.isEnabled = close.isEnabled clone.isEnabled = close.isEnabled
openInNewWindow.isEnabled = close.isEnabled openInNewWindow.isEnabled = close.isEnabled
@@ -290,7 +306,7 @@ class TerminalTabbed(
} }
private fun addTab(index: Int, tab: TerminalTab) { private fun addTab(index: Int, tab: TerminalTab, selected: Boolean) {
val c = tab.getJComponent() val c = tab.getJComponent()
val title = (c.getClientProperty(titleProperty) ?: tab.getTitle()).toString() val title = (c.getClientProperty(titleProperty) ?: tab.getTitle()).toString()
@@ -301,16 +317,53 @@ class TerminalTabbed(
StringUtils.EMPTY, StringUtils.EMPTY,
index index
) )
c.putClientProperty(titleProperty, title)
// 设置标题
c.putClientProperty(titleProperty, title)
// 监听 icons 变化 // 监听 icons 变化
tab.addPropertyChangeListener(iconListener) tab.addPropertyChangeListener(iconListener)
tabs.add(index, tab) tabs.add(index, tab)
tabbedPane.selectedIndex = index
if (selected) {
tabbedPane.selectedIndex = index
}
tabbedPane.setTabClosable(index, tab.canClose())
Disposer.register(this, tab) 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 右键 * 对着 ToolBar 右键
*/ */
@@ -399,12 +452,12 @@ class TerminalTabbed(
override fun dispose() { override fun dispose() {
} }
override fun addTerminalTab(tab: TerminalTab) { override fun addTerminalTab(tab: TerminalTab, selected: Boolean) {
addTab(tabs.size, tab) addTab(tabs.size, tab, selected)
} }
override fun addTerminalTab(index: Int, tab: TerminalTab) { override fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean) {
addTab(index, tab) addTab(index, tab, selected)
} }
override fun getSelectedTerminalTab(): TerminalTab? { override fun getSelectedTerminalTab(): TerminalTab? {

View File

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

View File

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

View File

@@ -4,7 +4,8 @@ import com.formdev.flatlaf.util.SystemInfo
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.event.WindowAdapter import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent import java.awt.event.WindowEvent
import javax.swing.WindowConstants.DISPOSE_ON_CLOSE import javax.swing.JOptionPane
import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
import kotlin.system.exitProcess import kotlin.system.exitProcess
class TermoraFrameManager { class TermoraFrameManager {
@@ -18,23 +19,35 @@ class TermoraFrameManager {
} }
} }
private val frames = mutableListOf<TermoraFrame>()
fun createWindow(): TermoraFrame { fun createWindow(): TermoraFrame {
val frame = TermoraFrame() val frame = TermoraFrame()
registerCloseCallback(frame) registerCloseCallback(frame)
frame.title = if (SystemInfo.isLinux) null else Application.getName() frame.title = if (SystemInfo.isLinux) null else Application.getName()
frame.defaultCloseOperation = DISPOSE_ON_CLOSE frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
frame.setSize(1280, 800) frame.setSize(1280, 800)
frame.setLocationRelativeTo(null) frame.setLocationRelativeTo(null)
frames.add(frame)
return frame return frame
} }
fun getWindows(): Array<TermoraFrame> {
return frames.toTypedArray()
}
private fun registerCloseCallback(window: TermoraFrame) { private fun registerCloseCallback(window: TermoraFrame) {
window.addWindowListener(object : WindowAdapter() { window.addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) { override fun windowClosed(e: WindowEvent) {
// 删除
frames.remove(window)
// dispose windowScope // 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() val windowScopes = ApplicationScope.windowScopes()
@@ -43,6 +56,21 @@ class TermoraFrameManager {
this@TermoraFrameManager.dispose() 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,7 +80,9 @@ class TermoraFrameManager {
try { try {
Disposer.getTree().assertIsEmpty(true) Disposer.getTree().assertIsEmpty(true)
} catch (e: Exception) { } catch (e: Exception) {
log.error(e.message) if (log.isErrorEnabled) {
log.error(e.message, e)
}
} }
exitProcess(0) exitProcess(0)

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

@@ -109,6 +109,10 @@ class TermoraToolBar(
toolbar.add(Box.createHorizontalGlue()) toolbar.add(Box.createHorizontalGlue())
if (SystemInfo.isLinux || SystemInfo.isWindows) {
toolbar.add(Box.createHorizontalStrut(16))
}
// update btn // update btn
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE)) val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))

View File

@@ -4,6 +4,7 @@ import app.termora.Application.ohMyJson
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import okhttp3.Request import okhttp3.Request
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.commonmark.node.BulletList import org.commonmark.node.BulletList
import org.commonmark.node.Heading import org.commonmark.node.Heading
import org.commonmark.node.Paragraph import org.commonmark.node.Paragraph
@@ -69,6 +70,9 @@ class UpdaterManager private constructor() {
.build() .build()
val response = Application.httpClient.newCall(request).execute() val response = Application.httpClient.newCall(request).execute()
if (!response.isSuccessful) { if (!response.isSuccessful) {
if (log.isErrorEnabled) {
log.error("Failed to fetch latest version, response was ${response.code}")
}
return LatestVersion.self return LatestVersion.self
} }
@@ -97,7 +101,14 @@ class UpdaterManager private constructor() {
} }
val parser = Parser.builder().build() 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() val renderer = HtmlRenderer.builder()
.attributeProviderFactory { .attributeProviderFactory {
AttributeProvider { node, _, attributes -> AttributeProvider { node, _, attributes ->
@@ -106,7 +117,7 @@ class UpdaterManager private constructor() {
attributes["style"] = "margin: 5px 0;" attributes["style"] = "margin: 5px 0;"
} else if (node is BulletList) { } else if (node is BulletList) {
attributes["style"] = "margin: 0 20px;" attributes["style"] = "margin: 0 20px;"
}else if(node is Paragraph){ } else if (node is Paragraph) {
attributes["style"] = "margin: 0;" attributes["style"] = "margin: 0;"
} }
} }
@@ -143,8 +154,4 @@ class UpdaterManager private constructor() {
fun ignore(version: String) { fun ignore(version: String) {
properties.putString("ignored.version.$version", "true") 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.actions.*
import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProvider import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult import app.termora.findeverywhere.FindEverywhereResult
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
@@ -27,11 +26,15 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
private val properties get() = Database.getDatabase().properties private val properties get() = Database.getDatabase().properties
private val rootPanel = JPanel(BorderLayout()) private val rootPanel = JPanel(BorderLayout())
private val searchTextField = FlatTextField() private val searchTextField = FlatTextField()
private val hostTree = HostTree() private val hostTree = NewHostTree()
private val bannerPanel = BannerPanel() private val bannerPanel = BannerPanel()
private val toggle = FlatButton() private val toggle = FlatButton()
private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean() private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean()
private val dataProviderSupport = DataProviderSupport() private val dataProviderSupport = DataProviderSupport()
private val hostTreeModel = hostTree.model as NewHostTreeModel
private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) {
searchTextField.text.isBlank()
}
init { init {
initView() initView()
@@ -126,8 +129,6 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
}) })
hostTree.showsRootHandles = true hostTree.showsRootHandles = true
Disposer.register(this, hostTree)
val scrollPane = JScrollPane(hostTree) val scrollPane = JScrollPane(hostTree)
scrollPane.verticalScrollBar.maximumSize = Dimension(0, 0) scrollPane.verticalScrollBar.maximumSize = Dimension(0, 0)
scrollPane.verticalScrollBar.preferredSize = Dimension(0, 0) scrollPane.verticalScrollBar.preferredSize = Dimension(0, 0)
@@ -138,6 +139,11 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
panel.add(scrollPane, BorderLayout.CENTER) panel.add(scrollPane, BorderLayout.CENTER)
panel.border = BorderFactory.createEmptyBorder(10, 0, 0, 0) panel.border = BorderFactory.createEmptyBorder(10, 0, 0, 0)
hostTree.model = filterableHostTreeModel
TreeUtils.loadExpansionState(
hostTree,
properties.getString("Welcome.HostTree.state", StringUtils.EMPTY)
)
return panel return panel
} }
@@ -163,37 +169,49 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
}) })
FindEverywhereProvider.getFindEverywhereProviders(windowScope) FindEverywhereProvider.getFindEverywhereProviders(windowScope).add(object : FindEverywhereProvider {
.add(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider { override fun find(pattern: String): List<FindEverywhereResult> {
override fun find(pattern: String): List<FindEverywhereResult> { var filter = hostTreeModel.root.getAllChildren()
return TreeUtils.children(hostTree.model, hostTree.model.root) .map { it.host }
.filterIsInstance<Host>() .filter { it.protocol != Protocol.Folder }
.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)
}
}
} }
override fun group(): String { return filter.map { HostFindEverywhereResult(it) }
return I18n.getString("termora.find-everywhere.groups.open-new-hosts") }
}
override fun order(): Int { override fun group(): String {
return Integer.MIN_VALUE + 2 return I18n.getString("termora.find-everywhere.groups.open-new-hosts")
} }
}))
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() { searchTextField.document.addDocumentListener(object : DocumentAdaptor() {
private var state = StringUtils.EMPTY
override fun changedUpdate(e: DocumentEvent) { override fun changedUpdate(e: DocumentEvent) {
val text = searchTextField.text val text = searchTextField.text
if (text.isBlank()) { filterableHostTreeModel.refresh()
hostTree.setModel(hostTree.model) if (text.isNotBlank()) {
TreeUtils.loadExpansionState(hostTree, state) hostTree.expandAll()
state = String()
} else {
if (state.isBlank()) state = TreeUtils.saveExpansionState(hostTree)
hostTree.setModel(hostTree.searchableModel)
hostTree.searchableModel.search(text)
TreeUtils.expandAll(hostTree)
} }
} }
}) })
@@ -241,11 +259,13 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
} }
override fun dispose() { override fun dispose() {
hostTree.setModel(null)
properties.putString("WelcomeFullContent", fullContent.toString()) properties.putString("WelcomeFullContent", fullContent.toString())
properties.putString("Welcome.HostTree.state", TreeUtils.saveExpansionState(hostTree))
} }
private class HostFindEverywhereResult(val host: Host) : FindEverywhereResult { private inner class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
private val showMoreInfo get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) {
ActionManager.getInstance() ActionManager.getInstance()
.getAction(OpenHostAction.OPEN_HOST) .getAction(OpenHostAction.OPEN_HOST)
@@ -261,7 +281,18 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
return Icons.terminal 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 return host.name
} }
} }

View File

@@ -29,10 +29,11 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() {
addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction()) addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction())
addAction(Actions.MULTIPLE, MultipleAction()) addAction(Actions.MULTIPLE, MultipleAction())
addAction(Actions.APP_UPDATE, AppUpdateAction()) addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance())
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction()) addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction()) addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
addAction(Actions.SFTP, SFTPAction()) addAction(Actions.SFTP, SFTPAction())
addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction())
addAction(Actions.MACRO, MacroAction()) addAction(Actions.MACRO, MacroAction())
addAction(Actions.KEY_MANAGER, KeyManagerAction()) addAction(Actions.KEY_MANAGER, KeyManagerAction())

View File

@@ -1,14 +1,28 @@
package app.termora.actions package app.termora.actions
import app.termora.* 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 io.github.g00fy2.versioncompare.Version
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing 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.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXEditorPane import org.jdesktop.swingx.JXEditorPane
import org.slf4j.LoggerFactory
import java.awt.Dimension import java.awt.Dimension
import java.awt.KeyboardFocusManager import java.awt.KeyboardFocusManager
import java.io.File
import java.net.ProxySelector
import java.net.URI import java.net.URI
import java.util.*
import java.util.concurrent.TimeUnit
import javax.swing.BorderFactory import javax.swing.BorderFactory
import javax.swing.JOptionPane import javax.swing.JOptionPane
import javax.swing.JScrollPane import javax.swing.JScrollPane
@@ -18,11 +32,20 @@ import kotlin.concurrent.fixedRateTimer
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
class AppUpdateAction : AnAction( class AppUpdateAction private constructor() : AnAction(
StringUtils.EMPTY, StringUtils.EMPTY,
Icons.ideUpdate 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 val updaterManager get() = UpdaterManager.getInstance()
init { init {
@@ -63,11 +86,75 @@ class AppUpdateAction : AnAction(
return return
} }
withContext(Dispatchers.Swing) { try {
ActionManager.getInstance() downloadLatestPkg(latestVersion)
.setEnabled(Actions.APP_UPDATE, true) } 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
}
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() { private fun showUpdateDialog() {
@@ -106,12 +193,59 @@ class AppUpdateAction : AnAction(
if (option == JOptionPane.CANCEL_OPTION) { if (option == JOptionPane.CANCEL_OPTION) {
return return
} else if (option == JOptionPane.NO_OPTION) { } else if (option == JOptionPane.NO_OPTION) {
ActionManager.getInstance().setEnabled(Actions.APP_UPDATE, false) isEnabled = false
updaterManager.ignore(updaterManager.lastVersion.version) updaterManager.ignore(lastVersion.version)
} else if (option == JOptionPane.YES_OPTION) { } else if (option == JOptionPane.YES_OPTION) {
ActionManager.getInstance() updateSelf(lastVersion)
.setEnabled(Actions.APP_UPDATE, false) }
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${lastVersion.version}")) }
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,6 +17,6 @@ object DataProviders {
object Welcome { 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 package app.termora.actions
import app.termora.Host import app.termora.*
import app.termora.HostDialog
import app.termora.HostManager
import app.termora.Protocol
import javax.swing.tree.TreePath import javax.swing.tree.TreePath
class NewHostAction : AnAction() { class NewHostAction : AnAction() {
@@ -20,27 +17,27 @@ class NewHostAction : AnAction() {
override fun actionPerformed(evt: AnActionEvent) { override fun actionPerformed(evt: AnActionEvent) {
val tree = evt.getData(DataProviders.Welcome.HostTree) ?: return val tree = evt.getData(DataProviders.Welcome.HostTree) ?: return
val model = tree.model var lastNode = (tree.lastSelectedPathComponent ?: tree.model.root) as? HostTreeNode ?: return
var lastHost = tree.lastSelectedPathComponent ?: model.root if (lastNode.host.protocol != Protocol.Folder) {
if (lastHost !is Host) { lastNode = lastNode.parent ?: return
return
}
if (lastHost.protocol != Protocol.Folder) {
val p = model.getParent(lastHost) ?: return
lastHost = p
} }
val lastHost = lastNode.host
val dialog = HostDialog(evt.window) val dialog = HostDialog(evt.window)
dialog.setLocationRelativeTo(evt.window) dialog.setLocationRelativeTo(evt.window)
dialog.isVisible = true dialog.isVisible = true
val host = (dialog.host ?: return).copy(parentId = lastHost.id) val host = (dialog.host ?: return).copy(parentId = lastHost.id)
hostManager.addHost(host) 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

@@ -15,9 +15,17 @@ class OpenHostAction : AnAction() {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val windowScope = evt.getData(DataProviders.WindowScope) ?: return val windowScope = evt.getData(DataProviders.WindowScope) ?: return
// 如果不支持 SFTP 那么不处理这个响应
if (evt.host.protocol == Protocol.SFTPPty) {
if (!SFTPPtyTerminalTab.canSupports) {
return
}
}
val tab = when (evt.host.protocol) { val tab = when (evt.host.protocol) {
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host) Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host) Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
Protocol.SFTPPty -> SFTPPtyTerminalTab(windowScope, evt.host)
else -> LocalTerminalTab(windowScope, evt.host) else -> LocalTerminalTab(windowScope, evt.host)
} }

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 package app.termora.actions
import app.termora.I18n
class TerminalClearScreenAction : AnAction() { class TerminalClearScreenAction : AnAction() {
companion object { companion object {
const val CLEAR_SCREEN = "ClearScreen" const val CLEAR_SCREEN = "ClearScreen"
} }
init { init {
putValue(SHORT_DESCRIPTION, "Clear Terminal Buffer") putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.clear-screen"))
putValue(ACTION_COMMAND_KEY, CLEAR_SCREEN) putValue(ACTION_COMMAND_KEY, CLEAR_SCREEN)
} }

View File

@@ -8,5 +8,6 @@ interface FindEverywhereResult : ActionListener {
fun getIcon(isSelected: Boolean): Icon = Icons.empty 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) label.font = font.deriveFont(font.size - 2f)
val box = Box.createHorizontalBox() val box = Box.createHorizontalBox()
box.add(label) 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 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) { if (isSelected) {
background = UIManager.getColor("List.selectionBackground") background = UIManager.getColor("List.selectionBackground")
foreground = UIManager.getColor("List.selectionForeground") foreground = UIManager.getColor("List.selectionForeground")

View File

@@ -28,6 +28,7 @@ class TerminalUserInteraction(
prompt[i], prompt[i],
true true
) )
dialog.setLocationRelativeTo(owner)
dialog.title = instruction ?: name ?: StringUtils.EMPTY dialog.title = instruction ?: name ?: StringUtils.EMPTY
passwords[i] = dialog.getText() passwords[i] = dialog.getText()
if (passwords[i].isBlank()) { 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)) 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 // switch map
for (i in KeyEvent.VK_1..KeyEvent.VK_9) { for (i in KeyEvent.VK_1..KeyEvent.VK_9) {

View File

@@ -1,21 +1,18 @@
package app.termora.keymap package app.termora.keymap
import app.termora.ApplicationScope import app.termora.*
import app.termora.Database
import app.termora.DialogWrapper
import app.termora.Disposable
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.findeverywhere.FindEverywhereAction
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.Container
import java.awt.KeyEventDispatcher import java.awt.KeyEventDispatcher
import java.awt.KeyboardFocusManager import java.awt.KeyboardFocusManager
import java.awt.event.KeyEvent import java.awt.event.KeyEvent
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.JDialog import javax.swing.JDialog
import javax.swing.JPopupMenu
import javax.swing.KeyStroke import javax.swing.KeyStroke
class KeymapManager private constructor() : Disposable { class KeymapManager private constructor() : Disposable {
@@ -30,15 +27,14 @@ class KeymapManager private constructor() : Disposable {
} }
private val keymapKeyEventDispatcher = KeymapKeyEventDispatcher() private val keymapKeyEventDispatcher = KeymapKeyEventDispatcher()
private val myKeyEventDispatcher = MyKeyEventDispatcher()
private val database get() = Database.getDatabase() private val database get() = Database.getDatabase()
private val properties get() = database.properties
private val keymaps = linkedMapOf<String, Keymap>() 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() } private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() }
init { init {
keyboardFocusManager.addKeyEventDispatcher(keymapKeyEventDispatcher) keyboardFocusManager.addKeyEventDispatcher(keymapKeyEventDispatcher)
keyboardFocusManager.addKeyEventDispatcher(myKeyEventDispatcher)
try { try {
for (keymap in database.getKeymaps()) { for (keymap in database.getKeymaps()) {
@@ -128,6 +124,17 @@ class KeymapManager private constructor() : Disposable {
return false 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) val evt = AnActionEvent(e.source, StringUtils.EMPTY, e)
for (actionId in actionIds) { 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() { override fun dispose() {
keyboardFocusManager.removeKeyEventDispatcher(keymapKeyEventDispatcher) keyboardFocusManager.removeKeyEventDispatcher(keymapKeyEventDispatcher)
keyboardFocusManager.removeKeyEventDispatcher(myKeyEventDispatcher)
} }
} }

View File

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

View File

@@ -2,6 +2,9 @@ package app.termora.keymgr
import app.termora.* import app.termora.*
import app.termora.AES.decodeBase64 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 app.termora.native.FileChooser
import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatTable import com.formdev.flatlaf.extras.components.FlatTable
@@ -48,6 +51,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
private val exportBtn = JButton(I18n.getString("termora.keymgr.export")) private val exportBtn = JButton(I18n.getString("termora.keymgr.export"))
private val editBtn = JButton(I18n.getString("termora.keymgr.edit")) private val editBtn = JButton(I18n.getString("termora.keymgr.edit"))
private val deleteBtn = JButton(I18n.getString("termora.remove")) private val deleteBtn = JButton(I18n.getString("termora.remove"))
private val sshCopyIdBtn = JButton("ssh-copy-id")
init { init {
initView() initView()
@@ -59,6 +63,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
exportBtn.isEnabled = false exportBtn.isEnabled = false
editBtn.isEnabled = false editBtn.isEnabled = false
sshCopyIdBtn.isEnabled = false
deleteBtn.isEnabled = false deleteBtn.isEnabled = false
keyPairTableModel.addColumn(I18n.getString("termora.keymgr.table.name")) keyPairTableModel.addColumn(I18n.getString("termora.keymgr.table.name"))
@@ -75,7 +80,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
val formMargin = "4dlu" val formMargin = "4dlu"
val layout = FormLayout( val layout = FormLayout(
"default:grow", "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 var rows = 1
@@ -91,6 +96,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
.add(importBtn).xy(1, rows).apply { rows += step } .add(importBtn).xy(1, rows).apply { rows += step }
.add(exportBtn).xy(1, rows).apply { rows += step } .add(exportBtn).xy(1, rows).apply { rows += step }
.add(deleteBtn).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) .build(), BorderLayout.EAST)
border = BorderFactory.createEmptyBorder(if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0, 12, 12, 12) 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 { keyPairTable.selectionModel.addListSelectionListener {
exportBtn.isEnabled = keyPairTable.selectedRowCount > 0 exportBtn.isEnabled = keyPairTable.selectedRowCount > 0
editBtn.isEnabled = exportBtn.isEnabled editBtn.isEnabled = exportBtn.isEnabled
deleteBtn.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>) { private fun exportKeyPairs(file: File, keyPairs: List<OhKeyPair>) {
file.outputStream().use { fis -> file.outputStream().use { fis ->
val names = mutableMapOf<String, Int>() 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

@@ -1,42 +1,19 @@
package app.termora.sync 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.Application.ohMyJson
import app.termora.highlight.KeywordHighlight import app.termora.ResponseException
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 kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.apache.commons.lang3.ArrayUtils import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import javax.swing.SwingUtilities
abstract class GitSyncer : Syncer { abstract class GitSyncer : SafetySyncer() {
companion object { companion object {
private val log = LoggerFactory.getLogger(GitSyncer::class.java) 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 { override fun pull(config: SyncConfig): GistResponse {
if (log.isInfoEnabled) { if (log.isInfoEnabled) {
@@ -92,174 +69,6 @@ abstract class GitSyncer : Syncer {
return gistResponse 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 { override fun push(config: SyncConfig): GistResponse {
val gistFiles = mutableListOf<GistFile>() val gistFiles = mutableListOf<GistFile>()
@@ -268,62 +77,16 @@ abstract class GitSyncer : Syncer {
// Hosts // Hosts
if (config.ranges.contains(SyncRange.Hosts)) { if (config.ranges.contains(SyncRange.Hosts)) {
val encryptedHosts = mutableListOf<EncryptedHost>() val hostsContent = encodeHosts(key)
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)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Push encryptedHosts: {}", hostsContent) log.debug("Push encryptedHosts: {}", hostsContent)
} }
gistFiles.add(GistFile("Hosts", hostsContent)) gistFiles.add(GistFile("Hosts", hostsContent))
} }
// KeyPairs // KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) { if (config.ranges.contains(SyncRange.KeyPairs)) {
val encryptedKeys = mutableListOf<OhKeyPair>() val keysContent = encodeKeys(key)
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)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Push encryptedKeys: {}", keysContent) log.debug("Push encryptedKeys: {}", keysContent)
} }
@@ -332,17 +95,7 @@ abstract class GitSyncer : Syncer {
// Highlights // Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) { if (config.ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlights = mutableListOf<KeywordHighlight>() val keywordHighlightsContent = encodeKeywordHighlights(key)
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)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Push keywordHighlights: {}", keywordHighlightsContent) log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
} }
@@ -351,17 +104,7 @@ abstract class GitSyncer : Syncer {
// Macros // Macros
if (config.ranges.contains(SyncRange.Macros)) { if (config.ranges.contains(SyncRange.Macros)) {
val macros = mutableListOf<Macro>() val macrosContent = encodeMacros(key)
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)
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug("Push macros: {}", macrosContent) log.debug("Push macros: {}", macrosContent)
} }
@@ -370,22 +113,11 @@ abstract class GitSyncer : Syncer {
// Keymap // Keymap
if (config.ranges.contains(SyncRange.Keymap)) { if (config.ranges.contains(SyncRange.Keymap)) {
val keymaps = mutableListOf<JsonObject>() val keymapsContent = encodeKeymaps()
for (keymap in keymapManager.getKeymaps()) { if (log.isDebugEnabled) {
// 只读的是内置的 log.debug("Push keymaps: {}", keymapsContent)
if (keymap.isReadonly) {
continue
}
keymaps.add(keymap.toJSONObject())
}
if (keymaps.isNotEmpty()) {
val keymapsContent = ohMyJson.encodeToString(keymaps)
if (log.isDebugEnabled) {
log.debug("Push keymaps: {}", keymapsContent)
}
gistFiles.add(GistFile("Keymaps", keymapsContent))
} }
gistFiles.add(GistFile("Keymaps", keymapsContent))
} }
if (gistFiles.isEmpty()) { if (gistFiles.isEmpty()) {

View File

@@ -0,0 +1,299 @@
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 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 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 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, GitLab,
GitHub, GitHub,
Gitee, Gitee,
WebDAV,
} }
enum class SyncRange { enum class SyncRange {

View File

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

View File

@@ -0,0 +1,152 @@
package app.termora.sync
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.PBKDF2
import app.termora.ResponseException
import kotlinx.serialization.encodeToString
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
json["Hosts"]?.jsonPrimitive?.content?.let {
decodeHosts(it, config)
}
// decode KeyPairs
json["KeyPairs"]?.jsonPrimitive?.content?.let {
decodeKeys(it, config)
}
// decode Highlights
json["KeywordHighlights"]?.jsonPrimitive?.content?.let {
decodeKeywordHighlights(it, config)
}
// decode Macros
json["Macros"]?.jsonPrimitive?.content?.let {
decodeMacros(it, config)
}
// decode Keymaps
json["Keymaps"]?.jsonPrimitive?.content?.let {
decodeKeymaps(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)
}
// 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))
}
}

View File

@@ -691,6 +691,13 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
1049 -> { 1049 -> {
// Save cursor
if (enable) {
CursorStoreStores.store(terminal)
} else {
CursorStoreStores.restore(terminal)
}
// 如果是关闭 清屏 // 如果是关闭 清屏
if (!enable) { if (!enable) {
terminal.getDocument().eraseInDisplay(2) terminal.getDocument().eraseInDisplay(2)
@@ -924,7 +931,7 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
else -> { else -> {
if (log.isWarnEnabled) { if (log.isWarnEnabled) {
log.warn("xterm-256 foreground color, code: $code") log.warn("xterm-256 background color, code: $code")
} }
} }
} }

View File

@@ -13,9 +13,11 @@ data class CursorStore(
*/ */
val textStyle: TextStyle, val textStyle: TextStyle,
/** /**
* 如果为 null 表示没有设置
*
* @see [DataKey.AutoWrapMode] * @see [DataKey.AutoWrapMode]
*/ */
val autoWarpMode: Boolean, val autoWarpMode: Boolean?,
/** /**
* @see [DataKey.OriginMode] * @see [DataKey.OriginMode]
*/ */

View File

@@ -0,0 +1,68 @@
package app.termora.terminal
import org.slf4j.LoggerFactory
object CursorStoreStores {
private val log = LoggerFactory.getLogger(CursorStoreStores::class.java)
fun restore(terminal: Terminal) {
val terminalModel = terminal.getTerminalModel()
val cursorStore = if (terminalModel.hasData(DataKey.SaveCursor)) {
terminalModel.getData(DataKey.SaveCursor)
} else {
CursorStore(
position = Position(1, 1),
textStyle = TextStyle.Default,
autoWarpMode = false,
originMode = false,
graphicCharacterSet = GraphicCharacterSet()
)
}
terminalModel.setData(DataKey.OriginMode, cursorStore.originMode)
terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle)
if (cursorStore.autoWarpMode != null) {
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
}
terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet)
val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion()
else ScrollingRegion(top = 1, bottom = terminalModel.getRows())
var y = cursorStore.position.y
if (y < region.top) {
y = 1
} else if (y > region.bottom) {
y = region.bottom
}
terminal.getCursorModel().move(row = y, col = cursorStore.position.x)
if (log.isDebugEnabled) {
log.debug("Restore Cursor (DECRC). $cursorStore")
}
}
fun store(terminal: Terminal) {
val terminalModel = terminal.getTerminalModel()
val graphicCharacterSet = terminalModel.getData(DataKey.GraphicCharacterSet)
// 避免引用
val characterSets = mutableMapOf<Graphic, CharacterSet>()
characterSets.putAll(graphicCharacterSet.characterSets)
val cursorStore = CursorStore(
position = terminal.getCursorModel().getPosition(),
textStyle = terminalModel.getData(DataKey.TextStyle),
autoWarpMode = if (terminalModel.hasData(DataKey.AutoWrapMode)) terminalModel.getData(DataKey.AutoWrapMode) else null,
originMode = terminalModel.isOriginMode(),
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
)
terminalModel.setData(DataKey.SaveCursor, cursorStore)
if (log.isDebugEnabled) {
log.debug("Save Cursor (DECSC). $cursorStore")
}
}
}

View File

@@ -74,6 +74,13 @@ class DataKey<T : Any>(val clazz: KClass<T>) {
*/ */
val Workdir = DataKey(String::class) val Workdir = DataKey(String::class)
/**
* OSC 1337 CurrentDir
*
* https://iterm2.com/documentation-escape-codes.html
*/
val CurrentDir = DataKey(String::class)
/** /**
* true: alternate keypad. * true: alternate keypad.
* false: Normal Keypad (DECKPNM) * false: Normal Keypad (DECKPNM)

View File

@@ -333,59 +333,12 @@ class EscapeSequenceProcessor(terminal: Terminal, reader: TerminalReader) : Abst
// ESC 7 Save Cursor (DECSC), VT100. // ESC 7 Save Cursor (DECSC), VT100.
'7' -> { '7' -> {
val graphicCharacterSet = terminalModel.getData(DataKey.GraphicCharacterSet) CursorStoreStores.store(terminal)
// 避免引用
val characterSets = mutableMapOf<Graphic, CharacterSet>()
characterSets.putAll(graphicCharacterSet.characterSets)
val cursorStore = CursorStore(
position = terminal.getCursorModel().getPosition(),
textStyle = terminalModel.getData(DataKey.TextStyle),
autoWarpMode = terminalModel.getData(DataKey.AutoWrapMode, false),
originMode = terminalModel.isOriginMode(),
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
)
terminalModel.setData(DataKey.SaveCursor, cursorStore)
if (log.isDebugEnabled) {
log.debug("Save Cursor (DECSC). $cursorStore")
}
} }
// Restore Cursor (DECRC), VT100. // Restore Cursor (DECRC), VT100.
'8' -> { '8' -> {
val cursorStore = if (terminalModel.hasData(DataKey.SaveCursor)) { CursorStoreStores.restore(terminal)
terminalModel.getData(DataKey.SaveCursor)
} else {
CursorStore(
position = Position(1, 1),
textStyle = TextStyle.Default,
autoWarpMode = false,
originMode = false,
graphicCharacterSet = GraphicCharacterSet()
)
}
terminalModel.setData(DataKey.OriginMode, cursorStore.originMode)
terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle)
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet)
val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion()
else ScrollingRegion(top = 1, bottom = terminalModel.getRows())
var y = cursorStore.position.y
if (y < region.top) {
y = 1
} else if (y > region.bottom) {
y = region.bottom
}
terminal.getCursorModel().move(row = y, col = cursorStore.position.x)
if (log.isDebugEnabled) {
log.debug("Restore Cursor (DECRC). $cursorStore")
}
} }

View File

@@ -4,6 +4,8 @@ import org.apache.commons.codec.binary.Base64
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import java.io.StringReader
import java.util.*
class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader) : class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader) :
AbstractProcessor(terminal, reader) { AbstractProcessor(terminal, reader) {
@@ -85,6 +87,19 @@ class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader
} }
} }
// https://iterm2.com/documentation-escape-codes.html
1337 -> {
val properties = Properties()
properties.load(StringReader(suffix))
if (properties.containsKey("CurrentDir")) {
val currentDir = properties.getProperty("CurrentDir")
terminal.getTerminalModel().setData(DataKey.CurrentDir, currentDir)
if (log.isDebugEnabled) {
log.debug("CurrentDir: $currentDir")
}
}
}
// 11: background color // 11: background color
// 10: foreground color // 10: foreground color
11, 10 -> { 11, 10 -> {

View File

@@ -0,0 +1,226 @@
package app.termora.terminal.panel
import app.termora.*
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders
import app.termora.terminal.DataKey
import app.termora.terminal.panel.vw.NvidiaSMIVisualWindow
import app.termora.terminal.panel.vw.SystemInformationVisualWindow
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.ui.FlatRoundBorder
import org.apache.commons.lang3.StringUtils
import java.awt.event.ActionListener
import javax.swing.JButton
class FloatingToolbarPanel : FlatToolBar(), Disposable {
private val floatingToolbarEnable get() = Database.getDatabase().terminal.floatingToolbar
private var closed = false
companion object {
val FloatingToolbar = DataKey(FloatingToolbarPanel::class)
val isPined get() = pinAction.isSelected
private val pinAction by lazy {
object : AnAction() {
private val properties get() = Database.getDatabase().properties
private val key = "FloatingToolbar.pined"
init {
setStateAction()
isSelected = properties.getString(key, StringUtils.EMPTY).toBoolean()
}
override fun actionPerformed(evt: AnActionEvent) {
isSelected = !isSelected
properties.putString(key, isSelected.toString())
actionListeners.forEach { it.actionPerformed(evt) }
if (isSelected) {
TerminalPanelFactory.getAllTerminalPanel().forEach {
it.getData(FloatingToolbar)?.triggerShow()
}
} else {
// 触发者的不隐藏
val c = evt.getData(FloatingToolbar)
TerminalPanelFactory.getAllTerminalPanel().forEach {
val e = it.getData(FloatingToolbar)
if (c != e) {
e?.triggerHide()
}
}
}
}
}
}
}
init {
border = FlatRoundBorder()
isOpaque = false
isFocusable = false
isFloatable = false
isVisible = false
if (floatingToolbarEnable) {
if (pinAction.isSelected) {
isVisible = true
}
}
initActions()
}
override fun updateUI() {
super.updateUI()
border = FlatRoundBorder()
}
fun triggerShow() {
if (!floatingToolbarEnable || closed) {
return
}
if (isVisible == false) {
isVisible = true
firePropertyChange("visible", false, true)
}
}
fun triggerHide() {
if (floatingToolbarEnable && !closed) {
if (pinAction.isSelected) {
return
}
}
if (isVisible == true) {
isVisible = false
firePropertyChange("visible", true, false)
}
}
private fun initActions() {
// Pin
add(initPinActionButton())
// 服务器信息
add(initServerInfoActionButton())
// Nvidia 显卡信息
add(initNvidiaSMIActionButton())
// 重连
add(initReconnectActionButton())
// 关闭
add(initCloseActionButton())
}
private fun initServerInfoActionButton(): JButton {
val btn = JButton(Icons.infoOutline)
btn.toolTipText = I18n.getString("termora.visual-window.system-information")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = evt.getData(DataProviders.TerminalTab) ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
if (tab !is SSHTerminalTab) {
terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported"))
return
}
for (window in terminalPanel.getVisualWindows()) {
if (window is SystemInformationVisualWindow) {
terminalPanel.moveToFront(window)
return
}
}
val visualWindowPanel = SystemInformationVisualWindow(tab, terminalPanel)
terminalPanel.addVisualWindow(visualWindowPanel)
}
})
return btn
}
private fun initNvidiaSMIActionButton(): JButton {
val btn = JButton(Icons.nvidia)
btn.toolTipText = I18n.getString("termora.visual-window.nvidia-smi")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = evt.getData(DataProviders.TerminalTab) ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
if (tab !is SSHTerminalTab) {
terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported"))
return
}
for (window in terminalPanel.getVisualWindows()) {
if (window is NvidiaSMIVisualWindow) {
terminalPanel.moveToFront(window)
return
}
}
val visualWindowPanel = NvidiaSMIVisualWindow(tab, terminalPanel)
terminalPanel.addVisualWindow(visualWindowPanel)
}
})
return btn
}
private fun initPinActionButton(): JButton {
val btn = JButton(Icons.pin)
btn.isSelected = pinAction.isSelected
val actionListener = ActionListener { btn.isSelected = pinAction.isSelected }
pinAction.addActionListener(actionListener)
btn.addActionListener(pinAction)
Disposer.register(this, object : Disposable {
override fun dispose() {
btn.removeActionListener(pinAction)
pinAction.removeActionListener(actionListener)
}
})
return btn
}
private fun initCloseActionButton(): JButton {
val btn = JButton(Icons.closeSmall)
btn.pressedIcon = Icons.closeSmallHovered
btn.rolloverIcon = Icons.closeSmallHovered
btn.addActionListener {
closed = true
triggerHide()
}
return btn
}
private fun initReconnectActionButton(): JButton {
val btn = JButton(Icons.refresh)
btn.toolTipText = I18n.getString("termora.tabbed.contextmenu.reconnect")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = evt.getData(DataProviders.TerminalTab) ?: return
if (tab.canReconnect()) {
tab.reconnect()
}
}
})
return btn
}
override fun dispose() {
}
}

View File

@@ -0,0 +1,119 @@
package app.termora.terminal.panel
import app.termora.ApplicationScope
import app.termora.Database
import app.termora.Disposable
import app.termora.terminal.*
import kotlinx.coroutines.*
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.milliseconds
class TerminalBlink(terminal: Terminal) : Disposable {
private var cursorBlinkJob: Job? = null
private val terminalSettings get() = Database.getDatabase().terminal
private val isDisposed = AtomicBoolean(false)
private val globalBlink get() = GlobalBlink.getInstance()
private val coroutineScope get() = globalBlink.coroutineScope
/**
* 返回 true 表示可以显示某些内容 [TextStyle.blink]
*/
val blink get() = globalBlink.blink
/**
* 这个与 [blink] 不同的是它是控制光标的
*/
@Volatile
var cursorBlink = true
private set
init {
reset()
// 如果有写入,那么显示光标 N 秒
terminal.getTerminalModel().addDataListener(object : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
// 写入后,重置光标
if (key == VisualTerminal.Written) {
reset()
} else if (key == TerminalPanel.Focused) {
// 获取焦点的一瞬间则立即重置
if (data == true) {
reset()
}
}
}
})
}
private fun reset() {
if (isDisposed.get()) {
return
}
cursorBlink = true
cursorBlinkJob?.cancel()
cursorBlinkJob = coroutineScope.launch {
while (coroutineScope.isActive) {
delay(500.milliseconds)
if (isDisposed.get()) {
break
}
// 如果开启了光标闪烁才闪速
cursorBlink = if (terminalSettings.cursorBlink) {
!cursorBlink
} else {
true
}
}
}
}
override fun dispose() {
if (isDisposed.compareAndSet(false, true)) {
cursorBlinkJob?.cancel()
}
}
private class GlobalBlink : Disposable {
companion object {
fun getInstance(): GlobalBlink {
return ApplicationScope.forApplicationScope()
.getOrCreate(GlobalBlink::class) { GlobalBlink() }
}
}
val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }
/**
* 返回 true 表示可以显示某些内容 [TextStyle.blink]
*/
@Volatile
var blink = true
private set
init {
coroutineScope.launch {
while (coroutineScope.isActive) {
delay(500)
blink = !blink
}
}
}
override fun dispose() {
coroutineScope.cancel()
}
}
}

View File

@@ -17,6 +17,7 @@ import kotlin.time.Duration
class TerminalDisplay( class TerminalDisplay(
private val terminalPanel: TerminalPanel, private val terminalPanel: TerminalPanel,
private val terminal: Terminal, private val terminal: Terminal,
private val terminalBlink: TerminalBlink
) : JComponent() { ) : JComponent() {
companion object { companion object {
@@ -132,31 +133,30 @@ class TerminalDisplay(
g.fillRect(0, 0, width, height) g.fillRect(0, 0, width, height)
} }
private fun drawCursor(g: Graphics, xOffset: Int, width: Int) { private fun drawCursor(g: Graphics, y: Int, xOffset: Int, width: Int) {
val lineHeight = getLineHeight() val lineHeight = getLineHeight()
val position = terminal.getCursorModel().getPosition()
val row = position.y
val style = if (inputMethodData.isNoTyping) val style = if (inputMethodData.isNoTyping)
terminal.getTerminalModel().getData(DataKey.CursorStyle) else CursorStyle.Bar terminal.getTerminalModel().getData(DataKey.CursorStyle) else CursorStyle.Bar
val hasFocus = terminal.getTerminalModel().getData(TerminalPanel.Focused, false)
// background // background
g.color = Color(colorPalette.getColor(TerminalColor.Cursor.BACKGROUND)) g.color = Color(colorPalette.getColor(TerminalColor.Cursor.BACKGROUND))
if (style == CursorStyle.Block) { if (style == CursorStyle.Block) {
if (terminalPanel.hasFocus()) { if (hasFocus) {
g.fillRect(xOffset, (row - 1) * lineHeight, width, lineHeight) g.fillRect(xOffset, (y - 1) * lineHeight, width, lineHeight)
} else { } else {
g.drawRect(xOffset, (row - 1) * lineHeight, width, lineHeight) g.drawRect(xOffset, (y - 1) * lineHeight, width, lineHeight)
} }
} else if (style == CursorStyle.Underline) { } else if (style == CursorStyle.Underline) {
val h = ceil(lineHeight / 10.0).toInt() val h = ceil(lineHeight / 10.0).toInt()
g.fillRect(xOffset, row * lineHeight - h / 2, width, h) g.fillRect(xOffset, y * lineHeight - h / 2, width, h)
} else if (style == CursorStyle.Bar) { } else if (style == CursorStyle.Bar) {
if (inputMethodData.isTyping) { if (inputMethodData.isTyping) {
val w = ceil(width / 3.5).toInt() val w = ceil(width / 3.5).toInt()
g.fillRect(xOffset, (row - 1) * lineHeight, w, lineHeight) g.fillRect(xOffset, (y - 1) * lineHeight, w, lineHeight)
} else { } else {
g.drawLine(xOffset, row * lineHeight - lineHeight, xOffset, row * lineHeight) g.drawLine(xOffset, y * lineHeight - lineHeight, xOffset, y * lineHeight)
} }
} }
} }
@@ -219,19 +219,23 @@ class TerminalDisplay(
} }
private fun drawCharacters(g: Graphics2D) { private fun drawCharacters(g: Graphics2D) {
val reverseVideo = terminal.getTerminalModel().getData(DataKey.ReverseVideo, false) val terminalModel = terminal.getTerminalModel()
val rows = terminal.getTerminalModel().getRows() val reverseVideo = terminalModel.getData(DataKey.ReverseVideo, false)
val cols = terminal.getTerminalModel().getCols() val rows = terminalModel.getRows()
val buffer = terminal.getDocument().getCurrentTerminalLineBuffer() val cols = terminalModel.getCols()
val triple = Triple(Char.Space.toString(), TextStyle.Default, 1) val triple = Triple(Char.Space.toString(), TextStyle.Default, 1)
val cursorPosition = terminal.getCursorModel().getPosition() val cursorPosition = terminal.getCursorModel().getPosition()
val averageCharWidth = getAverageCharWidth() val averageCharWidth = getAverageCharWidth()
val maxVerticalScrollOffset = terminal.getScrollingModel().getMaxVerticalScrollOffset()
val verticalScrollOffset = terminal.getScrollingModel().getVerticalScrollOffset() val verticalScrollOffset = terminal.getScrollingModel().getVerticalScrollOffset()
val selectionModel = terminal.getSelectionModel() val selectionModel = terminal.getSelectionModel()
val cursorStyle = terminal.getTerminalModel().getData(DataKey.CursorStyle) val cursorStyle = terminalModel.getData(DataKey.CursorStyle)
val showCursor = terminal.getTerminalModel().getData(DataKey.ShowCursor) val showCursor = terminalModel.getData(DataKey.ShowCursor)
val markupModel = terminal.getMarkupModel() val markupModel = terminal.getMarkupModel()
val lineHeight = getLineHeight() val lineHeight = getLineHeight()
val blink = terminalBlink.blink
val cursorBlink = terminalBlink.cursorBlink
val hasFocus = terminalModel.getData(TerminalPanel.Focused, false)
for (i in 1..rows) { for (i in 1..rows) {
@@ -242,7 +246,7 @@ class TerminalDisplay(
while (j <= cols) { while (j <= cols) {
val position = Position(row + 1, j) val position = Position(row + 1, j)
val caret = showCursor && j == cursorPosition.x + inputMethodData.offset val caret = showCursor && j == cursorPosition.x + inputMethodData.offset
&& row + 1 == cursorPosition.y + buffer.getBufferCount() && i == cursorPosition.y + (maxVerticalScrollOffset - verticalScrollOffset)
val (text, style, length) = if (characters.hasNext()) characters.next() else triple val (text, style, length) = if (characters.hasNext()) characters.next() else triple
var textStyle = style var textStyle = style
@@ -271,6 +275,13 @@ class TerminalDisplay(
background = colorPalette.getColor(TerminalColor.Basic.SELECTION_BACKGROUND) background = colorPalette.getColor(TerminalColor.Basic.SELECTION_BACKGROUND)
} }
// 如果启用了闪烁
if (textStyle.blink) {
if (!blink) {
continue
}
}
// 设置字体 // 设置字体
g.font = getDisplayFont(text, textStyle) g.font = getDisplayFont(text, textStyle)
val charWidth = min( val charWidth = min(
@@ -312,12 +323,15 @@ class TerminalDisplay(
// 渲染光标 // 渲染光标
if (caret) { if (caret) {
drawCursor(g, xOffset, charWidth) // 这几种情况光标才会渲染:输入中、闪烁中、没有焦点
// 如果是获取焦点状态,那么颜色互换 if (inputMethodData.isTyping || cursorBlink || !hasFocus) {
if (terminalPanel.hasFocus() && cursorStyle == CursorStyle.Block && inputMethodData.isNoTyping) { drawCursor(g, i, xOffset, charWidth)
g.color = Color(colorPalette.getColor(TerminalColor.Basic.BACKGROUND)) // 如果是获取焦点状态,那么颜色互换
} else { if (hasFocus && cursorStyle == CursorStyle.Block && inputMethodData.isNoTyping) {
g.color = Color(foreground) g.color = Color(colorPalette.getColor(TerminalColor.Basic.BACKGROUND))
} else {
g.color = Color(foreground)
}
} }
} }

View File

@@ -1,10 +1,15 @@
package app.termora.terminal.panel package app.termora.terminal.panel
import app.termora.Disposable
import app.termora.Disposer
import app.termora.actions.DataProvider import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
import app.termora.terminal.* import app.termora.terminal.*
import app.termora.terminal.panel.vw.VisualWindow
import app.termora.terminal.panel.vw.VisualWindowManager
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.lang3.ArrayUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import java.awt.* import java.awt.*
@@ -30,18 +35,33 @@ import kotlin.time.Duration.Companion.milliseconds
class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnector) : class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnector) :
JPanel(BorderLayout()), DataProvider { JPanel(BorderLayout()), DataProvider, Disposable, VisualWindowManager {
companion object { companion object {
val Debug = DataKey(Boolean::class) val Debug = DataKey(Boolean::class)
val Finding = DataKey(Boolean::class) val Finding = DataKey(Boolean::class)
val Focused = DataKey(Boolean::class)
val SelectCopy = DataKey(Boolean::class) val SelectCopy = DataKey(Boolean::class)
} }
private val terminalBlink = TerminalBlink(terminal)
private val terminalFindPanel = TerminalFindPanel(this, terminal) private val terminalFindPanel = TerminalFindPanel(this, terminal)
private val terminalDisplay = TerminalDisplay(this, terminal) private val floatingToolbar = FloatingToolbarPanel()
val scrollBar = TerminalScrollBar(this@TerminalPanel, terminalFindPanel, terminal) private val terminalDisplay = TerminalDisplay(this, terminal, terminalBlink)
private val dataProviderSupport = DataProviderSupport() private val dataProviderSupport = DataProviderSupport()
private val layeredPane = TerminalLayeredPane()
private var visualWindows = emptyArray<VisualWindow>()
val scrollBar = TerminalScrollBar(this@TerminalPanel, terminalFindPanel, terminal)
var enableFloatingToolbar = true
set(value) {
field = value
if (value) {
layeredPane.add(floatingToolbar, JLayeredPane.POPUP_LAYER as Any)
} else {
layeredPane.remove(floatingToolbar)
}
}
/** /**
@@ -112,10 +132,11 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
scrollBar.blockIncrement = 1 scrollBar.blockIncrement = 1
background = Color.black background = Color.black
val layeredPane = TerminalLayeredPane()
layeredPane.add(terminalDisplay, JLayeredPane.DEFAULT_LAYER as Any) layeredPane.add(terminalDisplay, JLayeredPane.DEFAULT_LAYER as Any)
layeredPane.add(terminalFindPanel, JLayeredPane.POPUP_LAYER as Any) layeredPane.add(terminalFindPanel, JLayeredPane.POPUP_LAYER as Any)
if (enableFloatingToolbar) {
layeredPane.add(floatingToolbar, JLayeredPane.POPUP_LAYER as Any)
}
add(layeredPane, BorderLayout.CENTER) add(layeredPane, BorderLayout.CENTER)
add(scrollBar, BorderLayout.EAST) add(scrollBar, BorderLayout.EAST)
@@ -126,6 +147,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
dataProviderSupport.addData(DataProviders.TerminalPanel, this) dataProviderSupport.addData(DataProviders.TerminalPanel, this)
dataProviderSupport.addData(DataProviders.Terminal, terminal) dataProviderSupport.addData(DataProviders.Terminal, terminal)
dataProviderSupport.addData(DataProviders.PtyConnector, ptyConnector) dataProviderSupport.addData(DataProviders.PtyConnector, ptyConnector)
dataProviderSupport.addData(FloatingToolbarPanel.FloatingToolbar, floatingToolbar)
} }
private fun initEvents() { private fun initEvents() {
@@ -134,10 +156,12 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
this.addFocusListener(object : FocusAdapter() { this.addFocusListener(object : FocusAdapter() {
override fun focusLost(e: FocusEvent) { override fun focusLost(e: FocusEvent) {
terminal.getTerminalModel().setData(Focused, false)
repaintImmediate() repaintImmediate()
} }
override fun focusGained(e: FocusEvent) { override fun focusGained(e: FocusEvent) {
terminal.getTerminalModel().setData(Focused, true)
repaintImmediate() repaintImmediate()
} }
}) })
@@ -157,6 +181,11 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
this.addMouseListener(trackingAdapter) this.addMouseListener(trackingAdapter)
this.addMouseWheelListener(trackingAdapter) this.addMouseWheelListener(trackingAdapter)
// 悬浮工具栏
val floatingToolBarAdapter = TerminalPanelMouseFloatingToolBarAdapter(this, terminalDisplay)
this.addMouseMotionListener(floatingToolBarAdapter)
this.addMouseListener(floatingToolBarAdapter)
// 滚动相关 // 滚动相关
this.addMouseWheelListener(object : MouseWheelListener { this.addMouseWheelListener(object : MouseWheelListener {
override fun mouseWheelMoved(e: MouseWheelEvent) { override fun mouseWheelMoved(e: MouseWheelEvent) {
@@ -196,6 +225,8 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
// 开启拖拽 // 开启拖拽
enableDropTarget() enableDropTarget()
// 监听悬浮工具栏变化,然后重新渲染
floatingToolbar.addPropertyChangeListener { repaintImmediate() }
} }
@@ -372,6 +403,10 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
} }
override fun dispose() {
Disposer.dispose(terminalBlink)
Disposer.dispose(floatingToolbar)
}
fun getAverageCharWidth(): Int { fun getAverageCharWidth(): Int {
return terminalDisplay.getAverageCharWidth() return terminalDisplay.getAverageCharWidth()
@@ -397,22 +432,20 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
* 执行粘贴操作 * 执行粘贴操作
*/ */
fun paste(text: String) { fun paste(text: String) {
val content = if (SystemInfo.isWindows) { var content = text
text.replace("${ControlCharacters.CR}${ControlCharacters.LF}", "${ControlCharacters.LF}") if (!SystemInfo.isWindows) {
} else { content = content.replace("\r\n", "\n")
text.replace(ControlCharacters.LF, ControlCharacters.CR)
} }
content = content.replace('\n', '\r')
if (terminal.getTerminalModel().getData(DataKey.BracketedPasteMode, false)) { if (terminal.getTerminalModel().getData(DataKey.BracketedPasteMode, false)) {
val bytes = ptyConnector.getCharset() ptyConnector.write(
.encode("${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~") "${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~".toByteArray(
.array() ptyConnector.getCharset()
ptyConnector.write(bytes) )
)
} else { } else {
val bytes = ptyConnector.getCharset() ptyConnector.write(content.toByteArray(ptyConnector.getCharset()))
.encode(content)
.array()
ptyConnector.write(bytes)
} }
terminal.getScrollingModel().scrollToRow( terminal.getScrollingModel().scrollToRow(
@@ -451,6 +484,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
synchronized(treeLock) { synchronized(treeLock) {
val w = width val w = width
val h = height val h = height
val findPanelHeight = max(terminalFindPanel.preferredSize.height, terminalFindPanel.height)
for (c in components) { for (c in components) {
when (c) { when (c) {
terminalDisplay -> { terminalDisplay -> {
@@ -468,7 +502,36 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
w - width, w - width,
0, 0,
width, width,
max(terminalFindPanel.preferredSize.height, terminalFindPanel.height) findPanelHeight
)
}
floatingToolbar -> {
val width = floatingToolbar.preferredSize.width
val height = floatingToolbar.preferredSize.height
val y = 4
c.setBounds(
w - width,
if (terminalFindPanel.isVisible) findPanelHeight + y else y,
width,
height
)
}
is VisualWindow -> {
var location = c.location
val dimension = getDimension()
if (location.x > dimension.width) {
location = Point(dimension.width - c.preferredSize.width, location.y)
}
if (location.y > dimension.height) {
location = Point(location.x, dimension.height - c.preferredSize.height)
}
c.setBounds(
location.x,
location.y,
c.width,
c.height
) )
} }
} }
@@ -480,4 +543,41 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
override fun <T : Any> getData(dataKey: DataKey<T>): T? { override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey) return dataProviderSupport.getData(dataKey)
} }
override fun moveToFront(visualWindow: VisualWindow) {
if (visualWindow.isWindow()) {
visualWindow.getWindow()?.requestFocus()
return
}
layeredPane.moveToFront(visualWindow.getJComponent())
}
override fun getVisualWindows(): Array<VisualWindow> {
return visualWindows
}
override fun addVisualWindow(visualWindow: VisualWindow) {
visualWindows = ArrayUtils.add(visualWindows, visualWindow)
layeredPane.add(visualWindow.getJComponent(), JLayeredPane.DRAG_LAYER as Any)
layeredPane.moveToFront(visualWindow.getJComponent())
}
override fun removeVisualWindow(visualWindow: VisualWindow) {
rebaseVisualWindow(visualWindow)
visualWindows = ArrayUtils.removeElement(visualWindows, visualWindow)
}
override fun rebaseVisualWindow(visualWindow: VisualWindow) {
layeredPane.remove(visualWindow.getJComponent())
layeredPane.revalidate()
layeredPane.repaint()
requestFocusInWindow()
}
override fun getDimension(): Dimension {
return Dimension(
terminalDisplay.size.width + padding.left + padding.right,
terminalDisplay.size.height + padding.bottom + padding.top
)
}
} }

View File

@@ -0,0 +1,49 @@
package app.termora.terminal.panel
import app.termora.Database
import java.awt.Rectangle
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
class TerminalPanelMouseFloatingToolBarAdapter(
private val terminalPanel: TerminalPanel,
private val terminalDisplay: TerminalDisplay
) : MouseAdapter() {
private val floatingToolbarEnable get() = Database.getDatabase().terminal.floatingToolbar
override fun mouseMoved(e: MouseEvent) {
if (!floatingToolbarEnable) {
return
}
val floatingToolbar = terminalPanel.getData(FloatingToolbarPanel.FloatingToolbar) ?: return
val width = terminalPanel.width
val height = terminalPanel.height
val widthDiff = (width * 0.25).toInt()
val heightDiff = (height * 0.25).toInt()
if (e.x in width - widthDiff..width && e.y in 0..heightDiff) {
floatingToolbar.triggerShow()
} else {
floatingToolbar.triggerHide()
}
}
override fun mouseExited(e: MouseEvent) {
val floatingToolbar = terminalPanel.getData(FloatingToolbarPanel.FloatingToolbar) ?: return
if (terminalDisplay.isShowing) {
val rectangle = Rectangle(terminalDisplay.locationOnScreen, terminalDisplay.size)
// 如果鼠标指针还在 terminalDisplay 中,那么就不需要隐藏
if (rectangle.contains(e.locationOnScreen)) {
return
}
}
floatingToolbar.triggerHide()
}
}

View File

@@ -42,6 +42,8 @@ class TerminalPanelMouseSelectionAdapter(private val terminalPanel: TerminalPane
override fun mousePressed(e: MouseEvent) { override fun mousePressed(e: MouseEvent) {
terminalPanel.requestFocusInWindow()
if (isMouseTracking) { if (isMouseTracking) {
return return
} }
@@ -77,7 +79,6 @@ class TerminalPanelMouseSelectionAdapter(private val terminalPanel: TerminalPane
mousePressedPoint.y = e.y mousePressedPoint.y = e.y
} }
terminalPanel.requestFocusInWindow()
// 如果只有 Shift 键按下,那么应该追加选中 // 如果只有 Shift 键按下,那么应该追加选中
if (selectionModel.hasSelection() && SwingUtilities.isLeftMouseButton(e) && e.modifiersEx == 1088) { if (selectionModel.hasSelection() && SwingUtilities.isLeftMouseButton(e) && e.modifiersEx == 1088) {

View File

@@ -0,0 +1,42 @@
package app.termora.terminal.panel.vw
import app.termora.Disposable
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import javax.swing.JPanel
import kotlin.time.Duration.Companion.milliseconds
abstract class AutoRefreshPanel : JPanel(), Disposable {
companion object {
private val log = LoggerFactory.getLogger(AutoRefreshPanel::class.java)
}
protected val coroutineScope = CoroutineScope(Dispatchers.IO)
protected abstract suspend fun refresh(isFirst: Boolean)
init {
coroutineScope.launch {
var isFirst = true
while (coroutineScope.isActive) {
try {
refresh(isFirst)
isFirst = false
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
if (isFirst) {
break
}
} finally {
delay(1000.milliseconds)
}
}
}
}
override fun dispose() {
coroutineScope.cancel()
}
}

View File

@@ -0,0 +1,386 @@
package app.termora.terminal.panel.vw
import app.termora.*
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXBusyLabel
import org.slf4j.LoggerFactory
import org.xml.sax.EntityResolver
import org.xml.sax.InputSource
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.Dimension
import java.awt.GridLayout
import java.io.StringReader
import javax.swing.*
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathFactory
class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "NVIDIA-SMI", visualWindowManager) {
companion object {
private val log = LoggerFactory.getLogger(NvidiaSMIVisualWindow::class.java)
}
private val nvidiaSMIPanel by lazy { NvidiaSMIPanel() }
private val busyLabel = JXBusyLabel()
private val errorPanel = FormBuilder.create().layout(FormLayout("pref:grow", "20dlu, pref, 5dlu, pref"))
.add(JLabel(FlatSVGIcon(Icons.warningDialog.name, 60, 60))).xy(1, 2, "center, fill")
.add(JLabel("Not supported")).xy(1, 4, "center, fill")
.build()
private val loadingPanel = FormBuilder.create().layout(FormLayout("pref:grow", "20dlu, pref"))
.add(busyLabel).xy(1, 2, "center, fill")
.build()
private val cardLayout = CardLayout()
private val rootPanel = JPanel(cardLayout)
private var isPercentage
get() = properties.getString("VisualWindow.${id}.isPercentage", "false").toBoolean()
set(value) = properties.putString("VisualWindow.${id}.isPercentage", value.toString())
private val percentageBtn by lazy { JButton(if (isPercentage) Icons.text else Icons.percentage) }
init {
initViews()
initEvents()
initVisualWindowPanel()
}
override fun toolbarButtons(): List<JButton> {
return listOf(percentageBtn)
}
private fun initViews() {
title = I18n.getString("termora.visual-window.nvidia-smi")
busyLabel.isBusy = true
rootPanel.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
rootPanel.add(errorPanel, "ErrorPanel")
rootPanel.add(loadingPanel, "LoadingPanel")
rootPanel.add(nvidiaSMIPanel, "NvidiaSMIPanel")
add(rootPanel, BorderLayout.CENTER)
cardLayout.show(rootPanel, "LoadingPanel")
}
private fun initEvents() {
percentageBtn.addActionListener {
isPercentage = !isPercentage
percentageBtn.icon = if (isPercentage) Icons.text else Icons.percentage
nvidiaSMIPanel.refreshPanel()
}
Disposer.register(this, nvidiaSMIPanel)
}
private data class GPU(
/**
* 名称 product_name
*/
val productName: String = StringUtils.EMPTY,
/**
* 序号 minor_number
*/
val minorNumber: Int = 0,
/**
* 温度 temperature.gpu_temp
*
* 单位C
*/
var temp: Double = 0.0,
var tempText: String = StringUtils.EMPTY,
/**
* 使用的功率 gpu_power_readings.power_draw
*
* 单位W
*/
var powerUsage: Double = 0.0,
var powerUsageText: String = StringUtils.EMPTY,
/**
* 功率大小 gpu_power_readings.max_power_limit
*
* 单位W
*/
var powerCap: Double = 0.0,
var powerCapText: String = StringUtils.EMPTY,
/**
* 使用的显存 fb_memory_usage.used
*
* 单位Mib
*/
var memoryUsage: Double = 0.0,
var memoryUsageText: String = StringUtils.EMPTY,
/**
* 显存大小 fb_memory_usage.total
*
* 单位Mib
*/
var memoryCap: Double = 0.0,
var memoryCapText: String = StringUtils.EMPTY,
/**
* GPU 利用率 utilization.gpu_util
*
* 单位:%
*/
var gpu: Double = 0.0
)
private class NvidiaSMI(
val driverVersion: String = StringUtils.EMPTY,
val cudaVersion: String = StringUtils.EMPTY,
val gpus: MutableList<GPU> = mutableListOf<GPU>(),
)
private class GPUPanel(val minorNumber: Int, title: String) : JPanel(BorderLayout()) {
val gpuProgressBar = SmartProgressBar()
val tempProgressBar = SmartProgressBar()
val memProgressBar = SmartProgressBar()
val powerProgressBar = SmartProgressBar()
init {
val formMargin = "4dlu"
var rows = 1
val step = 2
val p = FormBuilder.create().debug(false)
.layout(
FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
)
.border(
BorderFactory.createCompoundBorder(
BorderFactory.createTitledBorder(title),
BorderFactory.createEmptyBorder(4, 4, 4, 4),
)
)
.add("GPU: ").xy(1, rows)
.add(gpuProgressBar).xy(3, rows).apply { rows += step }
.add("Temp: ").xy(1, rows)
.add(tempProgressBar).xy(3, rows).apply { rows += step }
.add("Mem: ").xy(1, rows)
.add(memProgressBar).xy(3, rows).apply { rows += step }
.add("Power: ").xy(1, rows)
.add(powerProgressBar).xy(3, rows).apply { rows += step }
.build()
add(p, BorderLayout.CENTER)
}
}
private inner class NvidiaSMIPanel : AutoRefreshPanel() {
private val xPath by lazy { XPathFactory.newInstance().newXPath() }
private val db by lazy {
val factory = DocumentBuilderFactory.newInstance()
factory.isValidating = false
factory.isXIncludeAware = false
factory.isNamespaceAware = false
val db = factory.newDocumentBuilder()
db.setEntityResolver(object : EntityResolver {
override fun resolveEntity(
publicId: String?,
systemId: String?
): InputSource? {
return if (StringUtils.contains(systemId, ".dtd")) {
InputSource(StringReader(StringUtils.EMPTY))
} else {
null
}
}
})
db
}
private var nvidiaSMI = NvidiaSMI()
private val gpuRootPanel = JPanel()
private val driverVersionLabel = JLabel()
private val cudaVersionLabel = JLabel()
private val gpusLabel = JLabel()
init {
initViews()
initEvents()
}
private fun initViews() {
layout = BorderLayout()
add(
FormBuilder.create().debug(false)
.layout(
FormLayout(
"default:grow, pref, default:grow, 4dlu, pref, default:grow, 4dlu, pref, default:grow, default:grow",
"pref, 4dlu"
)
)
.add(Box.createHorizontalGlue()).xy(1, 1)
.add("Driver: ").xy(2, 1)
.add(driverVersionLabel).xy(3, 1)
.add("CUDA: ").xy(5, 1)
.add(cudaVersionLabel).xy(6, 1)
.add("GPUS: ").xy(8, 1)
.add(gpusLabel).xy(9, 1)
.add(Box.createHorizontalGlue()).xy(10, 1)
.build(), BorderLayout.NORTH
)
add(JScrollPane(gpuRootPanel).apply {
verticalScrollBar.maximumSize = Dimension(0, 0)
verticalScrollBar.preferredSize = Dimension(0, 0)
verticalScrollBar.minimumSize = Dimension(0, 0)
border = BorderFactory.createEmptyBorder()
}, BorderLayout.CENTER)
}
private fun initEvents() {
}
override suspend fun refresh(isFirst: Boolean) {
val session = tab.getData(SSHTerminalTab.SSHSession) ?: return
val doc = try {
val (code, text) = SshClients.execChannel(session, "nvidia-smi -x -q")
if (StringUtils.isNotBlank(text)) {
db.parse(InputSource(StringReader(text)))
} else {
throw IllegalStateException("exit code: $code")
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
null
}
if (doc == null) {
if (isFirst) {
withContext(Dispatchers.Swing) {
cardLayout.show(rootPanel, "ErrorPanel")
}
// 直接取消轮训
SwingUtilities.invokeLater { coroutineScope.cancel() }
}
return
}
nvidiaSMI = NvidiaSMI(
driverVersion = xPath.compile("/nvidia_smi_log/driver_version/text()").evaluate(doc),
cudaVersion = xPath.compile("/nvidia_smi_log/cuda_version/text()").evaluate(doc),
)
val attachedGPUs = xPath.compile("/nvidia_smi_log/attached_gpus/text()").evaluate(doc).toIntOrNull() ?: 0
for (i in 1..attachedGPUs) {
val gpu = GPU(
productName = xPath.compile("/nvidia_smi_log/gpu[${i}]/product_name/text()").evaluate(doc),
minorNumber = xPath.compile("/nvidia_smi_log/gpu[${i}]/minor_number/text()").evaluate(doc)
.toIntOrNull() ?: 0,
tempText = xPath.compile("/nvidia_smi_log/gpu[${i}]/temperature/gpu_temp/text()").evaluate(doc),
powerUsageText = xPath.compile("/nvidia_smi_log/gpu[${i}]/gpu_power_readings/power_draw/text()")
.evaluate(doc),
powerCapText = xPath.compile("/nvidia_smi_log/gpu[${i}]/gpu_power_readings/max_power_limit/text()")
.evaluate(doc),
memoryUsageText = xPath.compile("/nvidia_smi_log/gpu[${i}]/fb_memory_usage/used/text()")
.evaluate(doc),
memoryCapText = xPath.compile("/nvidia_smi_log/gpu[${i}]/fb_memory_usage/total/text()")
.evaluate(doc),
gpu = xPath.compile("/nvidia_smi_log/gpu[${i}]/utilization/gpu_util/text()")
.evaluate(doc).split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
)
nvidiaSMI.gpus.add(
gpu.copy(
temp = gpu.tempText.split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
powerUsage = gpu.powerUsageText.split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
powerCap = gpu.powerCapText.split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
memoryUsage = gpu.memoryUsageText.split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
memoryCap = gpu.memoryCapText.split(StringUtils.SPACE).first().toDoubleOrNull() ?: 0.0,
)
)
}
withContext(Dispatchers.Swing) {
if (isFirst) {
initPanel()
cardLayout.show(rootPanel, "NvidiaSMIPanel")
}
refreshPanel()
}
}
private fun initPanel() {
gpuRootPanel.layout = GridLayout(
if (nvidiaSMI.gpus.size % 2 == 0) nvidiaSMI.gpus.size / 2 else nvidiaSMI.gpus.size / 2 + 1,
2, 4, 4
)
for (e in nvidiaSMI.gpus) {
gpuRootPanel.add(GPUPanel(e.minorNumber, "${e.minorNumber} ${e.productName}"))
}
}
fun refreshPanel() {
cudaVersionLabel.text = nvidiaSMI.cudaVersion
driverVersionLabel.text = nvidiaSMI.driverVersion
gpusLabel.text = nvidiaSMI.gpus.size.toString()
for (c in gpuRootPanel.components) {
if (c is GPUPanel) {
for (g in nvidiaSMI.gpus) {
if (c.minorNumber == g.minorNumber) {
refreshGPUPanel(g, c)
break
}
}
}
}
}
private fun refreshGPUPanel(gpu: GPU, g: GPUPanel) {
g.gpuProgressBar.value = gpu.gpu.toInt()
g.tempProgressBar.value = gpu.temp.toInt()
g.tempProgressBar.string = if (isPercentage) "${g.tempProgressBar.value}%" else gpu.tempText
g.powerProgressBar.value = (gpu.powerUsage / gpu.powerCap * 100.0).toInt()
g.powerProgressBar.string = if (isPercentage) "${g.powerProgressBar.value}%"
else "${gpu.powerUsageText}/${gpu.powerCapText}"
g.memProgressBar.value = (gpu.memoryUsage / gpu.memoryCap * 100.0).toInt()
g.memProgressBar.string = if (isPercentage) "${g.memProgressBar.value}%"
else "${gpu.memoryUsageText}/${gpu.memoryCapText}"
}
}
override fun dispose() {
busyLabel.isBusy = false
super.dispose()
}
}

View File

@@ -0,0 +1,35 @@
package app.termora.terminal.panel.vw
import app.termora.Disposer
import app.termora.SSHTerminalTab
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import org.apache.commons.lang3.StringUtils
import java.util.*
abstract class SSHVisualWindow(
protected val tab: SSHTerminalTab,
id: String,
visualWindowManager: VisualWindowManager
) : VisualWindowPanel(id, visualWindowManager) {
init {
Disposer.register(tab, this)
}
override fun toggleWindow() {
val evt = AnActionEvent(tab.getJComponent(), StringUtils.EMPTY, EventObject(this))
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
super.toggleWindow()
if (!isWindow()) {
terminalTabbedManager.setSelectedTerminalTab(tab)
}
}
override fun getWindowTitle(): String {
return tab.getTitle() + " - " + title
}
}

View File

@@ -0,0 +1,31 @@
package app.termora.terminal.panel.vw
import com.formdev.flatlaf.extras.components.FlatProgressBar
import java.awt.Dimension
import javax.swing.UIManager
class SmartProgressBar : FlatProgressBar() {
init {
preferredSize = Dimension(-1, UIManager.getInt("Table.rowHeight") - 6)
isStringPainted = true
maximum = 100
minimum = 0
}
override fun setValue(n: Int) {
super.setValue(n)
foreground = if (value < 60) {
UIManager.getColor("Component.accentColor")
} else if (value < 85) {
UIManager.getColor("Component.warning.focusedBorderColor")
} else {
UIManager.getColor("Component.error.focusedBorderColor")
}
}
override fun updateUI() {
super.updateUI()
value = value
}
}

View File

@@ -0,0 +1,403 @@
package app.termora.terminal.panel.vw
import app.termora.*
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.session.ClientSession
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel
class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "SystemInformation", visualWindowManager) {
companion object {
private val log = LoggerFactory.getLogger(SystemInformationVisualWindow::class.java)
}
private val systemInformationPanel by lazy { SystemInformationPanel() }
init {
initViews()
initEvents()
initVisualWindowPanel()
}
private fun initViews() {
title = I18n.getString("termora.visual-window.system-information")
add(systemInformationPanel, BorderLayout.CENTER)
}
private fun initEvents() {
Disposer.register(this, systemInformationPanel)
}
private inner class SystemInformationPanel : AutoRefreshPanel() {
private val cpuProgressBar = SmartProgressBar()
private val memoryProgressBar = SmartProgressBar()
private val swapProgressBar = SmartProgressBar()
private val mem = Mem()
private val cpu = CPU()
private val swap = Swap()
private val tableModel = object : DefaultTableModel() {
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
}
init {
initViews()
initEvents()
}
private fun initViews() {
layout = BorderLayout()
add(createPanel(), BorderLayout.CENTER)
}
private fun createPanel(): JComponent {
val formMargin = "4dlu"
var rows = 1
val step = 2
val p = JPanel(BorderLayout())
val n = FormBuilder.create().debug(false).layout(
FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin"
)
)
.add("CPU: ").xy(1, rows)
.add(cpuProgressBar).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.visual-window.system-information.mem")}: ").xy(1, rows)
.add(memoryProgressBar).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.visual-window.system-information.swap")}: ").xy(1, rows)
.add(swapProgressBar).xy(3, rows).apply { rows += step }
.build()
val table = JTable(tableModel)
table.tableHeader.isEnabled = false
table.showVerticalLines = true
table.showHorizontalLines = true
table.fillsViewportHeight = true
tableModel.addColumn(I18n.getString("termora.visual-window.system-information.filesystem"))
tableModel.addColumn(I18n.getString("termora.visual-window.system-information.used-total"))
val centerRenderer = DefaultTableCellRenderer()
centerRenderer.setHorizontalAlignment(JLabel.CENTER)
table.columnModel.getColumn(1).cellRenderer = centerRenderer
p.add(n, BorderLayout.NORTH)
p.add(JScrollPane(table).apply {
border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
}, BorderLayout.CENTER)
p.border = BorderFactory.createEmptyBorder(6, 6, 6, 6)
return p
}
private fun initEvents() {
}
override suspend fun refresh(isFirst: Boolean) {
val session = tab.getData(SSHTerminalTab.SSHSession) ?: return
try {
// 刷新 CPU 和 内存
refreshCPUAndMem(session)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error("refreshCPUAndMem", e)
}
}
try {
// 刷新磁盘
refreshDisk(session)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error("refreshDisk", e)
}
}
}
private suspend fun refreshCPUAndMem(session: ClientSession) {
// top
var pair = SshClients.execChannel(session, "top -bn1")
if (pair.first != 0) {
return
}
val regex = """\d+\.?\d*""".toRegex()
val lines = pair.second.split(StringUtils.LF)
for (line in lines) {
val isCPU = line.startsWith("%Cpu(s):", true)
val isMibMem = line.startsWith("MiB Mem :", true)
val isKibMem = line.startsWith("KiB Mem :", true)
val isMibSwap = line.startsWith("MiB Swap:", true)
val isKibSwap = line.startsWith("KiB Swap:", true)
val unit = if (isKibSwap || isKibMem) 'K' else 'M'
if (isCPU) {
val parts = StringUtils.removeStartIgnoreCase(line, "%Cpu(s):").split(",").map { it.trim() }
for (part in parts) {
val value = regex.find(part)?.value?.toDoubleOrNull() ?: 0.0
if (part.contains("us")) {
cpu.us = value
} else if (part.contains("sy")) {
cpu.sy = value
} else if (part.contains("ni")) {
cpu.ni = value
} else if (part.contains("id")) {
cpu.id = value
} else if (part.contains("wa")) {
cpu.wa = value
} else if (part.contains("hi")) {
cpu.hi = value
} else if (part.contains("si")) {
cpu.si = value
} else if (part.contains("st")) {
cpu.st = value
}
}
} else if (isMibMem || isKibMem) {
val parts = StringUtils.removeStartIgnoreCase(line, "${unit}iB Mem :")
.split(",")
.map { it.trim() }
for (part in parts) {
val value = regex.find(part)?.value?.toDoubleOrNull() ?: 0.0
if (part.contains("total")) {
mem.total = value
} else if (part.contains("free")) {
mem.free = value
} else if (part.contains("used")) {
mem.used = value
} else if (part.contains("buff/cache")) {
mem.buffCache = value
}
}
if (isKibMem) {
mem.total = mem.total / 1024.0
mem.free = mem.free / 1024.0
mem.used = mem.used / 1024.0
mem.buffCache = mem.buffCache / 1024.0
}
} else if (isMibSwap || isKibSwap) {
val parts = StringUtils.removeStartIgnoreCase(line, "${unit}iB Swap:")
.split(",")
.map { it.trim() }
for (part in parts) {
val value = regex.find(part)?.value?.toDoubleOrNull() ?: 0.0
if (part.contains("total")) {
swap.total = value
} else if (part.contains("free")) {
swap.free = value
} else if (part.contains("used.")) {
swap.used = value
}
}
if (isKibSwap) {
swap.total = swap.total / 1024.0
swap.free = swap.free / 1024.0
swap.used = swap.used / 1024.0
}
}
}
withContext(Dispatchers.Swing) {
cpuProgressBar.value = (100.0 - cpu.id).toInt()
memoryProgressBar.value = (mem.used / mem.total * 100.0).toInt()
memoryProgressBar.string =
"${formatBytes((mem.used * 1024 * 1024).toLong())} / ${formatBytes((mem.total * 1024 * 1024).toLong())}"
swapProgressBar.value = (swap.used / swap.total * 100.0).toInt()
swapProgressBar.string =
"${formatBytes((swap.used * 1024 * 1024).toLong())} / ${formatBytes((swap.total * 1024 * 1024).toLong())}"
}
}
private suspend fun refreshDisk(session: ClientSession) {
// df -h
var pair = SshClients.execChannel(session, "df -B1")
if (pair.first != 0) {
return
}
val disks = mutableListOf<Disk>()
val lines = pair.second.split(StringUtils.LF)
for (line in lines) {
if (!line.startsWith("/dev/")) {
continue
}
val parts = line.split("\\s+".toRegex())
if (parts.size < 6) {
continue
}
disks.add(
Disk(
filesystem = parts[0],
size = parts[1].toLong(),
used = parts[2].toLong(),
avail = parts[3].toLong(),
usePercentage = StringUtils.removeEnd(parts[4], "%").toIntOrNull() ?: 0,
mountedOn = parts[5],
)
)
}
withContext(Dispatchers.Swing) {
while (tableModel.rowCount > 0) {
tableModel.removeRow(0)
}
for (disk in disks) {
tableModel.addRow(
arrayOf(
" ${disk.filesystem}",
formatBytes(disk.used) + " / " + formatBytes(disk.size),
)
)
}
}
}
}
private data class Mem(
/**
* 总内存
*/
var total: Double = 0.0,
/**
* 空闲内存
*/
var free: Double = 0.0,
/**
* 已用内存
*/
var used: Double = 0.0,
/**
* 缓存和缓冲区占用的内存
*/
var buffCache: Double = 0.0,
)
private data class Swap(
/**
* 交换空间的总大小
*/
var total: Double = 0.0,
/**
* 已使用的交换空间
*/
var free: Double = 0.0,
/**
* 未使用的交换空间
*/
var used: Double = 0.0,
)
private data class CPU(
/**
* 用户空间 CPU 占用时间百分比。
* 该值表示 CPU 用于执行用户进程的时间比例。
* 示例:如果系统中 CPU 用于执行用户程序的时间占总 CPU 时间的 40%,则该值为 40.0。
*/
var us: Double = 0.0,
/**
* 系统空间 CPU 占用时间百分比。
* 该值表示 CPU 用于执行内核进程的时间比例。
* 示例:如果内核进程占用 CPU 时间的 20%,则该值为 20.0。
*/
var sy: Double = 0.0,
/**
* 优先级调整过的进程Nice占用的 CPU 时间百分比。
* 该值表示 CPU 用于执行“优先级较低的”进程的时间比例。
* 示例:如果优先级调整过的进程占用 CPU 时间的 10%,则该值为 10.0。
*/
var ni: Double = 0.0,
/**
* CPU 空闲时间百分比。
* 该值表示 CPU 在空闲状态下没有执行任何任务的时间比例。
* 示例:如果 CPU 95% 处于空闲状态,该值为 95.0。
*/
var id: Double = 0.0,
/**
* I/O 等待时间百分比。
* 该值表示 CPU 正在等待 I/O 操作完成的时间比例。
* 示例:如果 CPU 由于 I/O 操作等待占用 5% 的时间,则该值为 5.0。
*/
var wa: Double = 0.0,
/**
* 硬件中断处理时间百分比。
* 该值表示 CPU 用于处理中断请求的时间比例,通常由硬件触发。
* 示例:如果 CPU 处理硬件中断占用 2% 的时间,则该值为 2.0。
*/
var hi: Double = 0.0,
/**
* 软件中断处理时间百分比。
* 该值表示 CPU 用于处理由软件触发的中断的时间比例。
* 示例:如果 CPU 处理软件中断占用 3% 的时间,则该值为 3.0。
*/
var si: Double = 0.0,
/**
* 虚拟化环境中的 CPU 抢占时间百分比。
* 该值表示 CPU 在虚拟化环境中被其他虚拟机抢占的时间比例。
* 示例:如果虚拟化环境中的 CPU 抢占占用 0.5% 的时间,则该值为 0.5。
*/
var st: Double = 0.0,
)
private data class Disk(
var filesystem: String = StringUtils.EMPTY,
/**
* 总大小
*/
var size: Long = 0L,
/**
* 已经使用的空间
*/
var used: Long = 0L,
/**
* 可用空间
*/
var avail: Long = 0L,
/**
* 已经使用的百分比
*/
var usePercentage: Int = 0,
/**
* 挂载点
*/
var mountedOn: String = StringUtils.EMPTY
)
}

View File

@@ -0,0 +1,31 @@
package app.termora.terminal.panel.vw
import app.termora.Disposable
import java.awt.Window
import javax.swing.JComponent
/**
* 虚拟窗口
*/
interface VisualWindow : Disposable {
/**
* 虚拟窗口内容
*/
fun getJComponent(): JComponent
/**
* 是否是独立窗口(独立成一个 Window
*/
fun isWindow(): Boolean
/**
* 如果是独立窗口,那么可以返回
*/
fun getWindow(): Window? = null
/**
* 切换独立模式
*/
fun toggleWindow()
}

View File

@@ -0,0 +1,36 @@
package app.termora.terminal.panel.vw
import java.awt.Dimension
interface VisualWindowManager {
/**
* 将窗口移动到最前面
*/
fun moveToFront(visualWindow: VisualWindow)
/**
* 添加虚拟窗口
*/
fun addVisualWindow(visualWindow: VisualWindow)
/**
* 移除虚拟窗口
*/
fun removeVisualWindow(visualWindow: VisualWindow)
/**
* 变基,仅仅从 LayeredPane 移除,但是不从 [getVisualWindows] 中移除
*/
fun rebaseVisualWindow(visualWindow: VisualWindow)
/**
* 获取管理的所有窗口
*/
fun getVisualWindows(): Array<VisualWindow>
/**
* 获取管理器的宽高
*/
fun getDimension(): Dimension
}

View File

@@ -0,0 +1,300 @@
package app.termora.terminal.panel.vw
import app.termora.*
import com.formdev.flatlaf.extras.components.FlatToolBar
import java.awt.*
import java.awt.event.*
import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener
import javax.swing.*
import kotlin.math.max
import kotlin.math.min
open class VisualWindowPanel(protected val id: String, protected val visualWindowManager: VisualWindowManager) :
JPanel(BorderLayout()), VisualWindow {
protected val properties get() = Database.getDatabase().properties
private val titleLabel = JLabel()
private val toolbar = FlatToolBar()
private val visualWindow = this
private val resizer = VisualWindowResizer(this) { !isWindow }
private var isWindow = false
set(value) {
val oldValue = field
field = value
firePropertyChange("isWindow", oldValue, value)
}
private var dialog: VisualWindowDialog? = null
private var oldBounds = Rectangle()
private var toggleWindowBtn = JButton(Icons.openInNewWindow)
private var isAlwaysTop
get() = properties.getString("VisualWindow.${id}.dialog.isAlwaysTop", "false").toBoolean()
set(value) = properties.putString("VisualWindow.${id}.dialog.isAlwaysTop", value.toString())
private val alwaysTopBtn = JButton(Icons.moveUp)
private val closeWindowListener = object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
close()
}
}
var title: String
set(value) {
titleLabel.text = value
}
get() = titleLabel.text
protected fun initVisualWindowPanel() {
initViews()
initEvents()
initToolBar()
}
private fun initViews() {
border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
val x = properties.getString("VisualWindow.${id}.location.x", "-1").toIntOrNull() ?: -1
val y = properties.getString("VisualWindow.${id}.location.y", "-1").toIntOrNull() ?: -1
val w = properties.getString("VisualWindow.${id}.location.width", "-1").toIntOrNull() ?: -1
val h = properties.getString("VisualWindow.${id}.location.height", "-1").toIntOrNull() ?: -1
if (x >= 0 && y >= 0) {
setLocation(x, y)
} else {
setLocation(200, 200)
}
if (w > 0 && h > 0) setSize(w, h) else setSize(400, 200)
alwaysTopBtn.isSelected = isAlwaysTop
alwaysTopBtn.isVisible = false
}
protected open fun toolbarButtons(): List<JButton> {
return emptyList()
}
private fun initEvents() {
val dragListener = DragListener()
toolbar.addMouseListener(dragListener)
toolbar.addMouseMotionListener(dragListener)
// 监听全局事件
Toolkit.getDefaultToolkit().addAWTEventListener(object : AWTEventListener {
override fun eventDispatched(event: AWTEvent) {
if (event is MouseEvent) {
if (event.id == MouseEvent.MOUSE_PRESSED) {
val c = event.component ?: return
if (SwingUtilities.isDescendingFrom(c, visualWindow)) {
visualWindowManager.moveToFront(visualWindow)
}
}
}
}
}, MouseEvent.MOUSE_EVENT_MASK)
// 阻止事件穿透
addMouseListener(object : MouseAdapter() {})
toggleWindowBtn.addActionListener { toggleWindow() }
addPropertyChangeListener("isWindow", object : PropertyChangeListener {
override fun propertyChange(evt: PropertyChangeEvent) {
if (isWindow) {
border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
toggleWindowBtn.icon = Icons.openInToolWindow
} else {
border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
toggleWindowBtn.icon = Icons.openInNewWindow
}
}
})
alwaysTopBtn.addActionListener {
isAlwaysTop = !isAlwaysTop
alwaysTopBtn.isSelected = isAlwaysTop
if (isWindow()) {
dialog?.isAlwaysOnTop = isAlwaysTop
}
}
}
private fun initToolBar() {
val btns = toolbarButtons()
val count = 2 + btns.size
toolbar.add(alwaysTopBtn)
toolbar.add(Box.createHorizontalStrut(count * 26))
toolbar.add(JLabel(Icons.empty))
toolbar.add(Box.createHorizontalGlue())
toolbar.add(titleLabel)
toolbar.add(Box.createHorizontalGlue())
btns.forEach { toolbar.add(it) }
toolbar.add(toggleWindowBtn)
toolbar.add(JButton(Icons.close).apply { addActionListener { Disposer.dispose(visualWindow) } })
toolbar.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
add(toolbar, BorderLayout.NORTH)
}
override fun dispose() {
val bounds = if (isWindow) oldBounds else bounds
properties.putString("VisualWindow.${id}.location.x", bounds.x.toString())
properties.putString("VisualWindow.${id}.location.y", bounds.y.toString())
properties.putString("VisualWindow.${id}.location.width", bounds.width.toString())
properties.putString("VisualWindow.${id}.location.height", bounds.height.toString())
resizer.uninstall()
this.close()
}
final override fun getJComponent(): JComponent {
return this
}
override fun isWindow(): Boolean {
return isWindow
}
override fun getWindow(): Window? {
return dialog
}
protected open fun getWindowTitle(): String {
return id
}
override fun toggleWindow() {
if (isWindow) {
// 提前移除 dialog 的关闭事件
dialog?.removeWindowListener(closeWindowListener)
}
isWindow = !isWindow
dialog?.dispose()
dialog = null
alwaysTopBtn.isVisible = isWindow
if (isWindow) {
oldBounds = bounds
// 变基
visualWindowManager.rebaseVisualWindow(this)
val dialog = VisualWindowDialog().apply { dialog = this }
dialog.addWindowListener(closeWindowListener)
dialog.isVisible = true
} else {
bounds = oldBounds
visualWindowManager.removeVisualWindow(visualWindow)
visualWindowManager.addVisualWindow(visualWindow)
}
}
private inner class DragListener() : MouseAdapter() {
private var startPoint: Point? = null
override fun mousePressed(e: MouseEvent) {
if (isWindow) {
startPoint = null
return
}
startPoint = SwingUtilities.convertPoint(visualWindow, e.getPoint(), visualWindow.getParent())
}
override fun mouseDragged(e: MouseEvent) {
val startPoint = this.startPoint ?: return
val newPoint = SwingUtilities.convertPoint(visualWindow, e.getPoint(), visualWindow.getParent())
val dimension = visualWindowManager.getDimension()
val x = min(
visualWindow.getX() + (newPoint.x - startPoint.x),
dimension.width - visualWindow.width
)
val y = min(
visualWindow.getY() + (newPoint.y - startPoint.y),
dimension.height - visualWindow.height
)
visualWindow.setBounds(max(x, 0), max(y, 0), visualWindow.getWidth(), visualWindow.getHeight())
this.startPoint = newPoint
}
override fun mouseReleased(e: MouseEvent) {
visualWindowManager.moveToFront(visualWindow)
}
}
protected open fun close() {
if (isWindow()) {
dialog?.dispose()
dialog = null
}
visualWindowManager.removeVisualWindow(visualWindow)
}
private inner class VisualWindowDialog : DialogWrapper(null) {
init {
isModal = false
controlsVisible = false
isResizable = true
title = getWindowTitle()
isAlwaysOnTop = isAlwaysTop
initEvents()
init()
val x = properties.getString("VisualWindow.${id}.dialog.location.x", "-1").toIntOrNull() ?: -1
val y = properties.getString("VisualWindow.${id}.dialog.location.y", "-1").toIntOrNull() ?: -1
val w = properties.getString("VisualWindow.${id}.dialog.location.width", "-1").toIntOrNull() ?: -1
val h = properties.getString("VisualWindow.${id}.dialog.location.height", "-1").toIntOrNull() ?: -1
if (w > 0 && h > 0) setSize(w, h) else pack()
if (x >= 0 && y >= 0) {
setLocation(x, y)
} else {
setLocationRelativeTo(null)
}
}
private fun initEvents() {
addWindowListener(object : WindowAdapter() {
override fun windowClosed(e: WindowEvent) {
properties.putString("VisualWindow.${id}.dialog.location.x", x.toString())
properties.putString("VisualWindow.${id}.dialog.location.y", y.toString())
properties.putString("VisualWindow.${id}.dialog.location.width", width.toString())
properties.putString("VisualWindow.${id}.dialog.location.height", height.toString())
}
})
}
override fun createCenterPanel(): JComponent {
return getJComponent()
}
override fun createSouthPanel(): JComponent? {
return null
}
}
}

View File

@@ -0,0 +1,48 @@
package app.termora.terminal.panel.vw
import com.formdev.flatlaf.ui.FlatWindowResizer
import java.awt.Dimension
import java.awt.Rectangle
import javax.swing.JComponent
class VisualWindowResizer(resizeComp: JComponent, private val windowResizable: () -> Boolean = { true }) :
FlatWindowResizer(resizeComp) {
override fun isWindowResizable(): Boolean {
return windowResizable.invoke()
}
override fun getWindowBounds(): Rectangle {
return resizeComp.bounds
}
override fun setWindowBounds(r: Rectangle) {
resizeComp.bounds = r
resizeComp.revalidate()
resizeComp.repaint()
}
override fun limitToParentBounds(): Boolean {
return true
}
override fun getParentBounds(): Rectangle {
return resizeComp.getParent().bounds
}
override fun honorMinimumSizeOnResize(): Boolean {
return true
}
override fun honorMaximumSizeOnResize(): Boolean {
return true
}
override fun getWindowMinimumSize(): Dimension {
return resizeComp.minimumSize
}
override fun getWindowMaximumSize(): Dimension {
return resizeComp.maximumSize
}
}

View File

@@ -9,7 +9,10 @@ import java.io.FileNotFoundException
import java.nio.file.Files import java.nio.file.Files
import javax.swing.Icon import javax.swing.Icon
class LogViewerTerminalTab(windowScope: WindowScope, private val file: File) : PtyHostTerminalTab( class LogViewerTerminalTab(
windowScope: WindowScope,
private val file: File,
) : PtyHostTerminalTab(
windowScope, windowScope,
Host( Host(
name = file.name, name = file.name,

View File

@@ -2,6 +2,7 @@ package app.termora.transport
import app.termora.* import app.termora.*
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatToolBar import com.formdev.flatlaf.extras.components.FlatToolBar
@@ -12,6 +13,7 @@ import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.io.file.PathUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
@@ -19,25 +21,30 @@ import org.apache.sshd.sftp.client.SftpClient
import org.apache.sshd.sftp.client.fs.SftpFileSystem import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath import org.apache.sshd.sftp.client.fs.SftpPath
import org.jdesktop.swingx.JXBusyLabel import org.jdesktop.swingx.JXBusyLabel
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
import java.awt.Desktop import java.awt.Desktop
import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import java.awt.dnd.DnDConstants import java.awt.datatransfer.Transferable
import java.awt.dnd.DropTarget import java.awt.datatransfer.UnsupportedFlavorException
import java.awt.dnd.DropTargetDropEvent
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.io.File import java.io.File
import java.nio.file.* import java.nio.file.*
import java.text.MessageFormat
import java.util.* import java.util.*
import java.util.regex.Pattern
import javax.swing.* import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableCellRenderer
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists import kotlin.io.path.exists
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.isDirectory import kotlin.io.path.isDirectory
import kotlin.time.Duration.Companion.milliseconds
/** /**
@@ -45,9 +52,8 @@ import kotlin.io.path.isDirectory
*/ */
class FileSystemPanel( class FileSystemPanel(
private val fileSystem: FileSystem, private val fileSystem: FileSystem,
private val transportManager: TransportManager,
private val host: Host private val host: Host
) : JPanel(BorderLayout()), Disposable, FileSystemTransportListener.Provider { ) : JPanel(BorderLayout()), Disposable {
companion object { companion object {
private val log = LoggerFactory.getLogger(FileSystemPanel::class.java) private val log = LoggerFactory.getLogger(FileSystemPanel::class.java)
@@ -65,6 +71,14 @@ class FileSystemPanel(
private val showHiddenFilesBtn = JButton(Icons.eyeClose) private val showHiddenFilesBtn = JButton(Icons.eyeClose)
private val properties get() = Database.getDatabase().properties private val properties get() = Database.getDatabase().properties
private val showHiddenFilesKey by lazy { "termora.transport.host.${host.id}.show-hidden-files" } private val showHiddenFilesKey by lazy { "termora.transport.host.${host.id}.show-hidden-files" }
private val evt by lazy { AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) }
private val sftp get() = Database.getDatabase().sftp
private val actionManager get() = ActionManager.getInstance()
/**
* Edit
*/
private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
val workdir get() = tableModel.workdir val workdir get() = tableModel.workdir
@@ -80,6 +94,8 @@ class FileSystemPanel(
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString()) bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString())
table.setUI(FlatTableUI()) table.setUI(FlatTableUI())
table.dragEnabled = true
table.dropMode = DropMode.INSERT_ROWS
table.rowHeight = UIManager.getInt("Table.rowHeight") table.rowHeight = UIManager.getInt("Table.rowHeight")
table.autoResizeMode = JTable.AUTO_RESIZE_OFF table.autoResizeMode = JTable.AUTO_RESIZE_OFF
table.fillsViewportHeight = true table.fillsViewportHeight = true
@@ -231,17 +247,45 @@ class FileSystemPanel(
} }
}) })
// 本地文件系统不支持本地拖拽进去
if (!tableModel.isLocalFileSystem) { table.transferHandler = object : TransferHandler() {
table.dropTarget = object : DropTarget() { override fun canImport(support: TransferSupport): Boolean {
override fun drop(dtde: DropTargetDropEvent) { if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
dtde.acceptDrop(DnDConstants.ACTION_COPY) val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
val files = dtde.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*> return data is FileSystemTableRowTransferable && data.fileSystemPanel != this@FileSystemPanel
if (files.isEmpty()) return } else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
copyLocalFileToFileSystem(files.filterIsInstance<File>()) return !tableModel.isLocalFileSystem
} }
}.apply { return false
this.defaultActions = DnDConstants.ACTION_COPY }
override fun importData(comp: JComponent?, t: Transferable): Boolean {
if (t.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = t.getTransferData(FileSystemTableRowTransferable.dataFlavor)
if (data !is FileSystemTableRowTransferable) {
return false
}
data.fileSystemPanel.transport(data.paths)
return true
} else if (t.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = t.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return false
copyLocalFileToFileSystem(files.filterIsInstance<File>())
return true
}
return false
}
override fun getSourceActions(c: JComponent?): Int {
return COPY
}
override fun createTransferable(c: JComponent?): Transferable? {
val paths = table.selectedRows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
if (paths.isEmpty()) {
return null
}
return FileSystemTableRowTransferable(this@FileSystemPanel, paths)
} }
} }
@@ -313,6 +357,9 @@ class FileSystemPanel(
} }
override fun dispose() {
coroutineScope.cancel()
}
private fun copyLocalFileToFileSystem(files: List<File>) { private fun copyLocalFileToFileSystem(files: List<File>) {
val event = AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) val event = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
@@ -396,14 +443,6 @@ class FileSystemPanel(
} }
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listenerList.add(FileSystemTransportListener::class.java, listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listenerList.remove(FileSystemTransportListener::class.java, listener)
}
private fun openFolder() { private fun openFolder() {
val row = table.selectedRow val row = table.selectedRow
if (row < 0) return if (row < 0) return
@@ -431,6 +470,7 @@ class FileSystemPanel(
private fun showContextMenu(rows: IntArray, event: MouseEvent) { private fun showContextMenu(rows: IntArray, event: MouseEvent) {
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
val popupMenu = FlatPopupMenu() val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new")) val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
@@ -448,11 +488,22 @@ class FileSystemPanel(
// 传输 // 传输
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer")) val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
transfer.addActionListener { transfer.addActionListener {
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
if (paths.isNotEmpty()) { if (paths.isNotEmpty()) {
transport(paths) transport(paths)
} }
} }
// 编辑
val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit"))
// 不是本地文件系统 & 包含文件
edit.isEnabled = !tableModel.isLocalFileSystem && paths.any { !it.isDirectory }
edit.addActionListener {
val files = paths.filter { !it.isDirectory }
if (files.isNotEmpty()) {
editFiles(files)
}
}
popupMenu.addSeparator() popupMenu.addSeparator()
// 复制路径 // 复制路径
@@ -545,6 +596,127 @@ class FileSystemPanel(
popupMenu.show(table, event.x, event.y) popupMenu.show(table, event.x, event.y)
} }
private fun editFiles(files: List<FileSystemTableModel.CacheablePath>) {
if (files.isEmpty()) return
val transportManager = evt.getData(TransportDataProviders.TransportManager) ?: return
if (SystemInfo.isLinux) {
if (sftp.editCommand.isBlank()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.transport.table.contextmenu.edit-command"),
messageType = JOptionPane.INFORMATION_MESSAGE
)
actionManager.getAction(SettingsAction.SETTING)
?.actionPerformed(AnActionEvent(this, StringUtils.EMPTY, EventObject(this)))
return
}
}
val temporary = Application.getTemporaryDir().toPath()
for (file in files) {
val dir = Files.createTempDirectory(temporary, "termora-")
val path = Paths.get(dir.absolutePathString(), file.fileName)
transportManager.addTransport(
transport = FileTransport(
name = file.fileName,
source = file.path,
target = path,
sourceHolder = this,
targetHolder = this,
listener = editFileTransportListener(file.path, path)
)
)
}
}
private fun editFileTransportListener(source: Path, localPath: Path): TransportListener {
return object : TransportListener {
override fun onTransportChanged(transport: Transport) {
// 传输成功
if (transport.state == TransportState.Done) {
val transportManager = evt.getData(TransportDataProviders.TransportManager) ?: return
var lastModifiedTime = localPath.getLastModifiedTime().toMillis()
try {
if (sftp.editCommand.isNotBlank()) {
ProcessBuilder(
parseCommand(
MessageFormat.format(
sftp.editCommand,
localPath.absolutePathString()
)
)
).start()
} else if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start()
} else if (SystemInfo.isWindows) {
ProcessBuilder("notepad", localPath.absolutePathString()).start()
} else {
return
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
return
}
coroutineScope.launch(Dispatchers.IO) {
while (coroutineScope.isActive) {
try {
if (!Files.exists(localPath)) {
break
}
val nowModifiedTime = localPath.getLastModifiedTime().toMillis()
if (nowModifiedTime != lastModifiedTime) {
lastModifiedTime = nowModifiedTime
withContext(Dispatchers.Swing) {
// upload
transportManager.addTransport(
transport = FileTransport(
name = PathUtils.getFileNameString(localPath.fileName),
source = localPath,
target = source,
sourceHolder = this@FileSystemPanel,
targetHolder = this@FileSystemPanel,
)
)
}
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
break
}
delay(250.milliseconds)
}
}
}
}
fun parseCommand(command: String): List<String> {
val result = mutableListOf<String>()
val matcher = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(command)
while (matcher.find()) {
if (matcher.group(1) != null) {
result.add(matcher.group(1)) // 处理双引号部分
} else {
result.add(matcher.group(2).replace("\\\\ ", " "))
}
}
return result
}
}
}
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
private fun renamePath(path: Path) { private fun renamePath(path: Path) {
@@ -760,17 +932,31 @@ class FileSystemPanel(
private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) { private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) {
if (paths.isEmpty()) return if (paths.isEmpty()) return
val transportPanel = evt.getData(TransportDataProviders.TransportPanel) ?: return
val listeners = listenerList.getListeners(FileSystemTransportListener::class.java) val leftFileSystemPanel = evt.getData(TransportDataProviders.LeftFileSystemPanel) ?: return
if (listeners.isEmpty()) return val rightFileSystemPanel = evt.getData(TransportDataProviders.RightFileSystemPanel) ?: return
val sourceFileSystemPanel = this
val targetFileSystemPanel = if (this == leftFileSystemPanel) rightFileSystemPanel else leftFileSystemPanel
// 收集数据 // 收集数据
for (e in paths) { for (e in paths) {
if (!e.isDirectory) { if (!e.isDirectory) {
val job = TransportJob(
fileSystemPanel = this,
workdir = workdir,
isDirectory = false,
path = e.path,
)
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, false, e.path) } transportPanel.transport(
sourceWorkdir = workdir,
targetWorkdir = targetFileSystemPanel.workdir,
isSourceDirectory = false,
sourcePath = e.path,
sourceHolder = sourceFileSystemPanel,
targetHolder = targetFileSystemPanel
)
} }
continue continue
} }
@@ -782,12 +968,26 @@ class FileSystemPanel(
val isDirectory = if (path.attributes != null) val isDirectory = if (path.attributes != null)
path.attributes.isDirectory else path.isDirectory() path.attributes.isDirectory else path.isDirectory()
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) } transportPanel.transport(
sourceWorkdir = workdir,
targetWorkdir = targetFileSystemPanel.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = sourceFileSystemPanel,
targetHolder = targetFileSystemPanel
)
} }
} else { } else {
val isDirectory = path.isDirectory() val isDirectory = path.isDirectory()
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) } transportPanel.transport(
sourceWorkdir = workdir,
targetWorkdir = targetFileSystemPanel.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = sourceFileSystemPanel,
targetHolder = targetFileSystemPanel
)
} }
} }
} }
@@ -838,4 +1038,28 @@ class FileSystemPanel(
} }
private class FileSystemTableRowTransferable(
val fileSystemPanel: FileSystemPanel,
val paths: List<FileSystemTableModel.CacheablePath>
) : Transferable {
companion object {
val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable")
}
override fun getTransferDataFlavors(): Array<DataFlavor> {
return arrayOf(dataFlavor)
}
override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean {
return flavor == dataFlavor
}
override fun getTransferData(flavor: DataFlavor?): Any {
if (flavor != dataFlavor) {
throw UnsupportedFlavorException(flavor)
}
return this
}
}
} }

View File

@@ -3,9 +3,9 @@ package app.termora.transport
import app.termora.* import app.termora.*
import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import java.awt.Component
import java.awt.Point import java.awt.Point
import java.nio.file.FileSystems import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.* import javax.swing.*
import kotlin.math.max import kotlin.math.max
@@ -13,9 +13,8 @@ import kotlin.math.max
class FileSystemTabbed( class FileSystemTabbed(
private val transportManager: TransportManager, private val transportManager: TransportManager,
private val isLeft: Boolean = false private val isLeft: Boolean = false
) : FlatTabbedPane(), FileSystemTransportListener.Provider, Disposable { ) : FlatTabbedPane(), Disposable {
private val addBtn = JButton(Icons.add) private val addBtn = JButton(Icons.add)
private val listeners = mutableListOf<FileSystemTransportListener>()
init { init {
initView() initView()
@@ -36,23 +35,20 @@ class FileSystemTabbed(
trailingComponent = toolbar trailingComponent = toolbar
if (isLeft) { if (isLeft) {
addFileSystemTransportProvider( addTab(
I18n.getString("termora.transport.local"), I18n.getString("termora.transport.local"), FileSystemPanel(
FileSystemPanel(
FileSystems.getDefault(), FileSystems.getDefault(),
transportManager,
host = Host( host = Host(
id = "local", id = "local",
name = I18n.getString("termora.transport.local"), name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local, protocol = Protocol.Local,
) )
).apply { reload() } ).apply { reload() })
)
setTabClosable(0, false) setTabClosable(0, false)
} else { } else {
addFileSystemTransportProvider( addTab(
I18n.getString("termora.transport.sftp.select-host"), I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel(transportManager) SftpFileSystemPanel()
) )
} }
@@ -61,17 +57,18 @@ class FileSystemTabbed(
private fun initEvents() { private fun initEvents() {
addBtn.addActionListener { addBtn.addActionListener {
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this)) val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(this))
dialog.location = Point( dialog.location = Point(
addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2, max(0, addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2),
addBtn.locationOnScreen.y + max(tabHeight, addBtn.height) addBtn.locationOnScreen.y + max(tabHeight, addBtn.height)
) )
dialog.setFilter { it.host.protocol == Protocol.SSH }
dialog.setTreeName("FileSystemTabbed.Tree")
dialog.isVisible = true dialog.isVisible = true
for (host in dialog.hosts) { for (host in dialog.hosts) {
val panel = SftpFileSystemPanel(transportManager, host) val panel = SftpFileSystemPanel(host)
addFileSystemTransportProvider(host.name, panel) addTab(host.name, panel)
panel.connect() panel.connect()
} }
@@ -120,9 +117,9 @@ class FileSystemTabbed(
if (tabCount == 0) { if (tabCount == 0) {
if (!isLeft) { if (!isLeft) {
addFileSystemTransportProvider( addTab(
I18n.getString("termora.transport.sftp.select-host"), I18n.getString("termora.transport.sftp.select-host"),
SftpFileSystemPanel(transportManager) SftpFileSystemPanel()
) )
} }
} }
@@ -130,39 +127,31 @@ class FileSystemTabbed(
} }
fun addFileSystemTransportProvider(title: String, provider: FileSystemTransportListener.Provider) { override fun addTab(title: String, component: Component) {
if (provider !is JComponent) { super.addTab(title, component)
throw IllegalArgumentException("Provider is not an JComponent")
}
provider.addFileSystemTransportListener(object : FileSystemTransportListener { selectedIndex = tabCount - 1
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
}
})
// 修改 Tab名称 if (component is SftpFileSystemPanel) {
provider.addPropertyChangeListener("TabName") { e -> component.addPropertyChangeListener("TabName") { e ->
SwingUtilities.invokeLater { SwingUtilities.invokeLater {
val name = StringUtils.defaultIfEmpty( val name = StringUtils.defaultIfEmpty(
e.newValue.toString(), e.newValue.toString(),
I18n.getString("termora.transport.sftp.select-host") I18n.getString("termora.transport.sftp.select-host")
) )
for (i in 0 until tabCount) { for (i in 0 until tabCount) {
if (getComponentAt(i) == provider) { if (getComponentAt(i) == component) {
setTitleAt(i, name) setTitleAt(i, name)
break break
}
} }
} }
} }
} }
addTab(title, provider)
if (tabCount > 0)
selectedIndex = tabCount - 1
} }
fun getSelectedFileSystemPanel(): FileSystemPanel? { fun getSelectedFileSystemPanel(): FileSystemPanel? {
return getFileSystemPanel(selectedIndex) return getFileSystemPanel(selectedIndex)
} }
@@ -184,14 +173,6 @@ class FileSystemTabbed(
return null return null
} }
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.add(listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.remove(listener)
}
override fun dispose() { override fun dispose() {
while (tabCount > 0) { while (tabCount > 0) {
val c = getComponentAt(0) val c = getComponentAt(0)

View File

@@ -1,19 +0,0 @@
package app.termora.transport
import java.nio.file.Path
import java.util.*
interface FileSystemTransportListener : EventListener {
/**
* @param workdir 当前工作目录
* @param isDirectory 要传输的是否是文件夹
* @param path 要传输的文件/文件夹
*/
fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path)
interface Provider {
fun addFileSystemTransportListener(listener: FileSystemTransportListener)
fun removeFileSystemTransportListener(listener: FileSystemTransportListener)
}
}

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