mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 10:22:58 +08:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9caf2578 | ||
|
|
b85bdf840e | ||
|
|
a2d7f3b5bb | ||
|
|
02a96d73c8 | ||
|
|
9fb12c7a71 | ||
|
|
145d8fc802 | ||
|
|
72c9dba806 | ||
|
|
de20bd654c | ||
|
|
35b3a10746 | ||
|
|
05fe6a0eb1 | ||
|
|
0552917c26 | ||
|
|
51c355c113 | ||
|
|
034ee3791d | ||
|
|
adabaf8f2d | ||
|
|
1f392c52a1 | ||
|
|
28fe4c725f | ||
|
|
18fe92cb11 | ||
|
|
c49acf7b51 | ||
|
|
7df317a1b9 | ||
|
|
219e5420f5 | ||
|
|
aefb7c3014 | ||
|
|
f0c7f06ff5 | ||
|
|
604e07b43a | ||
|
|
0000e4610a | ||
|
|
510324d7c4 | ||
|
|
33a359fcbf | ||
|
|
0b84d3271c | ||
|
|
57547c95cb | ||
|
|
503cfa9a4e | ||
|
|
af1f979e31 | ||
|
|
3cd9f92ea9 | ||
|
|
b332bada95 | ||
|
|
63a12c2ec8 | ||
|
|
743f242805 | ||
|
|
5bead0b27d | ||
|
|
73e3c7016b | ||
|
|
3829dcd0f9 | ||
|
|
b2047044fe | ||
|
|
47d1a13189 | ||
|
|
309909cbd7 | ||
|
|
b5cebb4cea | ||
|
|
b6dd2693cd | ||
|
|
5fdfe98f26 | ||
|
|
0c768aa1ca | ||
|
|
d493e6dc9e | ||
|
|
7e0c7d8891 | ||
|
|
3510c6600d | ||
|
|
32d91150bd | ||
|
|
bbf2d50e3f | ||
|
|
39725f9828 | ||
|
|
1e8c617a85 | ||
|
|
7f8573ec4c | ||
|
|
d8e629917e | ||
|
|
bdc0a15439 | ||
|
|
a25b97614f | ||
|
|
4e12c32566 | ||
|
|
ea9c0f1225 | ||
|
|
ff865f13a2 | ||
|
|
9875200912 | ||
|
|
9f218d004e | ||
|
|
ab727f66f4 | ||
|
|
efbc0302e4 | ||
|
|
ab2367d670 | ||
|
|
045e4f81d6 | ||
|
|
160cfee947 | ||
|
|
0e40b5ecce | ||
|
|
fcaddcee80 | ||
|
|
8d6295fd3b | ||
|
|
d0d51b3e6f | ||
|
|
b8d612f1d5 | ||
|
|
f7c49cde0c | ||
|
|
189f8fb3ba | ||
|
|
2a64bd28a8 | ||
|
|
8a733379a3 | ||
|
|
e5f854dfcd |
47
.github/workflows/linux-aarch64.yml
vendored
Normal file
47
.github/workflows/linux-aarch64.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Linux aarch64
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# download jdk
|
||||
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-aarch64-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
|
||||
20
.github/workflows/linux-x86-64.yml
vendored
20
.github/workflows/linux-x86-64.yml
vendored
@@ -4,14 +4,17 @@ on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# download jdk
|
||||
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.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
|
||||
- name: Installing Java
|
||||
@@ -22,6 +25,15 @@ jobs:
|
||||
java-version: '21.0.6'
|
||||
architecture: x64
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
./gradlew dist --no-daemon
|
||||
@@ -30,4 +42,6 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-linux-x86-64
|
||||
path: build/distributions/*.tar.gz
|
||||
path: |
|
||||
build/distributions/*.tar.gz
|
||||
build/distributions/*.AppImage
|
||||
|
||||
32
.github/workflows/osx-aarch64.yml
vendored
32
.github/workflows/osx-aarch64.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the Apple certificate
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name == 'push' && github.repository == 'TermoraDev/termora'
|
||||
env:
|
||||
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
|
||||
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
@@ -33,8 +33,18 @@ jobs:
|
||||
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
|
||||
- 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
|
||||
- name: Installing Java
|
||||
@@ -45,11 +55,23 @@ jobs:
|
||||
java-version: '21.0.6'
|
||||
architecture: aarch64
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- name: Dist
|
||||
env:
|
||||
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' }}
|
||||
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
|
||||
|
||||
@@ -57,4 +79,6 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-osx-aarch64
|
||||
path: build/distributions/*.dmg
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.dmg
|
||||
|
||||
33
.github/workflows/osx-x86-64.yml
vendored
33
.github/workflows/osx-x86-64.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the Apple certificate
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name == 'push' && github.repository == 'TermoraDev/termora'
|
||||
env:
|
||||
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
|
||||
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
@@ -33,8 +33,18 @@ jobs:
|
||||
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
|
||||
- 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
|
||||
- name: Installing Java
|
||||
@@ -46,11 +56,24 @@ jobs:
|
||||
architecture: x64
|
||||
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
|
||||
# dist
|
||||
- name: Dist
|
||||
env:
|
||||
TERMORA_MAC_SIGN: ${{ github.event_name == 'push' }}
|
||||
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
|
||||
|
||||
@@ -58,4 +81,6 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termora-osx-x86-64
|
||||
path: build/distributions/*.dmg
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.dmg
|
||||
|
||||
28
.github/workflows/windows-x86-64.yml
vendored
28
.github/workflows/windows-x86-64.yml
vendored
@@ -10,15 +10,34 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install zip
|
||||
run: |
|
||||
$system32 = [System.Environment]::GetEnvironmentVariable("WINDIR") + "\System32"
|
||||
Invoke-WebRequest -Uri "http://stahlworks.com/dev/zip.exe" -OutFile "$system32\zip.exe"
|
||||
Invoke-WebRequest -Uri "http://stahlworks.com/dev/unzip.exe" -OutFile "$system32\unzip.exe"
|
||||
|
||||
- name: Install 7z
|
||||
uses: milliewalky/setup-7-zip@v2
|
||||
|
||||
- name: Installing Java
|
||||
uses: actions/setup-java@v4
|
||||
run: |
|
||||
curl -s --output ${{ runner.temp }}\java_package.zip -L https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-windows-x64-b895.91.zip
|
||||
unzip -q ${{ runner.temp }}\java_package.zip -d ${{ runner.temp }}\jbr
|
||||
echo "JAVA_HOME=${{ runner.temp }}\jbr\jbrsdk-21.0.6-windows-x64-b895.91" >> $env:GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
distribution: 'jetbrains'
|
||||
java-version: '21'
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
# dist
|
||||
- run: |
|
||||
.\gradlew.bat dist --no-daemon
|
||||
.\gradlew.bat --stop
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -26,4 +45,5 @@ jobs:
|
||||
name: termora-windows-x86-64
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.msi
|
||||
build/distributions/*.msi
|
||||
build/distributions/*.exe
|
||||
|
||||
3
.github/workflows/winget.yml
vendored
3
.github/workflows/winget.yml
vendored
@@ -7,7 +7,8 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@main
|
||||
if: github.repository == 'TermoraDev/termora'
|
||||
with:
|
||||
identifier: TermoraDev.Termora
|
||||
installers-regex: 'x86-64\.msi$' # Only x86-64.msi files
|
||||
installers-regex: 'x86-64\.exe$' # Only x86-64.exe files
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
|
||||
- SSH and local terminal support
|
||||
- Serial port protocol support
|
||||
- [SFTP](./docs/sftp.png?raw=1) file transfer support
|
||||
- [SFTP](./docs/sftp.png?raw=1) & [Command](./docs/sftp-command.png?raw=1) file transfer support
|
||||
- Compatible with Windows, macOS, and Linux
|
||||
- Zmodem protocol support
|
||||
- SSH port forwarding & Jump hosts
|
||||
- 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)
|
||||
- Keyword highlighting
|
||||
- Key management
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
- 支持 SSH 和本地终端
|
||||
- 支持串口协议
|
||||
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) 文件传输
|
||||
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) & [命令行](./docs/sftp-command.png?raw=1) 文件传输
|
||||
- 支持 Windows、macOS、Linux 平台
|
||||
- 支持 Zmodem 协议
|
||||
- 支持 SSH 端口转发和跳板机
|
||||
- 终端日志记录
|
||||
- 支持配置同步到 [Gist](https://gist.github.com)
|
||||
- 支持配置同步到 [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
|
||||
- 支持宏(录制脚本并回放)
|
||||
- 支持关键词高亮
|
||||
- 支持密钥管理器
|
||||
|
||||
22
THIRDPARTY
22
THIRDPARTY
@@ -14,7 +14,7 @@ commonmark 0.24.0
|
||||
BSD 2-Clause "Simplified" License
|
||||
https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt
|
||||
|
||||
commons-codec 1.17.1
|
||||
commons-codec 1.18.0
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-codec/blob/master/LICENSE.txt
|
||||
|
||||
@@ -34,10 +34,18 @@ commons-net 3.11.1
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-net/blob/master/LICENSE.txt
|
||||
|
||||
commons-text 1.12.0
|
||||
commons-text 1.13.0
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-text/blob/master/LICENSE.txt
|
||||
|
||||
commons-csv 1.13.0
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-csv/blob/master/LICENSE.txt
|
||||
|
||||
ini4j 0.5.5-2
|
||||
Apache License 2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
|
||||
eddsa 0.3.0
|
||||
Creative Commons Zero v1.0 Universal
|
||||
https://github.com/str4d/ed25519-java/blob/master/LICENSE.txt
|
||||
@@ -110,7 +118,7 @@ kotlin-logging 1.7.9
|
||||
Apache License 2.0
|
||||
https://github.com/oshai/kotlin-logging/blob/master/LICENSE
|
||||
|
||||
kotlin-stdlib 2.1.0
|
||||
kotlin-stdlib 2.1.10
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
@@ -126,6 +134,10 @@ kotlin-stdlib-jdk8 1.9.10
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
restart4j 0.0.1
|
||||
Apache License 2.0
|
||||
https://github.com/hstyi/restart4j/blob/main/LICENSE
|
||||
|
||||
kotlinx-coroutines-core-jvm 1.10.1
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
@@ -134,11 +146,11 @@ kotlinx-coroutines-swing 1.10.1
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
kotlinx-serialization-core-jvm 1.7.3
|
||||
kotlinx-serialization-core-jvm 1.8.0
|
||||
Apache License 2.0
|
||||
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
|
||||
|
||||
kotlinx-serialization-json-jvm 1.7.3
|
||||
kotlinx-serialization-json-jvm 1.8.0
|
||||
Apache License 2.0
|
||||
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
|
||||
|
||||
|
||||
434
build.gradle.kts
434
build.gradle.kts
@@ -3,11 +3,16 @@ import org.gradle.kotlin.dsl.support.uppercaseFirstChar
|
||||
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
|
||||
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
|
||||
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils
|
||||
import org.jetbrains.kotlin.org.apache.commons.io.filefilter.FileFilterUtils
|
||||
import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
|
||||
import java.io.FileNotFoundException
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
|
||||
plugins {
|
||||
java
|
||||
idea
|
||||
application
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
@@ -15,7 +20,7 @@ plugins {
|
||||
|
||||
|
||||
group = "app.termora"
|
||||
version = "1.0.7"
|
||||
version = "1.0.9"
|
||||
|
||||
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
|
||||
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
|
||||
@@ -58,6 +63,7 @@ dependencies {
|
||||
implementation(libs.commons.codec)
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.commons.lang3)
|
||||
implementation(libs.commons.csv)
|
||||
implementation(libs.commons.net)
|
||||
implementation(libs.commons.text)
|
||||
implementation(libs.commons.compress)
|
||||
@@ -97,7 +103,7 @@ dependencies {
|
||||
implementation(libs.sshd.core)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.jgit)
|
||||
implementation(libs.jgit.sshd)
|
||||
implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
|
||||
implementation(libs.jnafilechooser)
|
||||
implementation(libs.xodus.vfs)
|
||||
implementation(libs.xodus.openAPI)
|
||||
@@ -106,6 +112,8 @@ dependencies {
|
||||
implementation(libs.colorpicker)
|
||||
implementation(libs.mixpanel)
|
||||
implementation(libs.jSerialComm)
|
||||
implementation(libs.ini4j)
|
||||
implementation(libs.restart4j)
|
||||
}
|
||||
|
||||
application {
|
||||
@@ -116,7 +124,6 @@ application {
|
||||
"-XX:+ZUncommit",
|
||||
"-XX:+ZGenerational",
|
||||
"-XX:ZUncommitDelay=60",
|
||||
"-XX:SoftMaxHeapSize=64m"
|
||||
)
|
||||
|
||||
if (os.isMacOsX) {
|
||||
@@ -142,16 +149,17 @@ tasks.test {
|
||||
tasks.register<Copy>("copy-dependencies") {
|
||||
val dir = layout.buildDirectory.dir("libs")
|
||||
from(configurations.runtimeClasspath).into(dir)
|
||||
val jna = libs.jna.asProvider().get()
|
||||
val pty4j = libs.pty4j.get()
|
||||
val jSerialComm = libs.jSerialComm.get()
|
||||
val restart4j = libs.restart4j.get()
|
||||
|
||||
// 对 JNA 和 PTY4J 的本地库提取
|
||||
// 提取出来是为了单独签名,不然无法通过公证
|
||||
if (os.isMacOsX && macOSSign) {
|
||||
doLast {
|
||||
val jna = libs.jna.asProvider().get()
|
||||
val archName = if (arch.isArm) "aarch64" else "x86_64"
|
||||
val dylib = dir.get().dir("dylib").asFile
|
||||
val pty4j = libs.pty4j.get()
|
||||
val jSerialComm = libs.jSerialComm.get()
|
||||
|
||||
for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
|
||||
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
|
||||
val targetDir = File(dylib, jna.name)
|
||||
@@ -177,7 +185,6 @@ tasks.register<Copy>("copy-dependencies") {
|
||||
// 删除所有二进制类库
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "resources/*") }
|
||||
} 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)
|
||||
FileUtils.forceMkdir(targetDir)
|
||||
// @formatter:off
|
||||
@@ -191,6 +198,24 @@ tasks.register<Copy>("copy-dependencies") {
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
|
||||
} else if ("${restart4j.name}-${restart4j.version}" == file.nameWithoutExtension) {
|
||||
val targetDir = FileUtils.getFile(dylib, restart4j.name)
|
||||
FileUtils.forceMkdir(targetDir)
|
||||
// @formatter:off
|
||||
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "darwin/${archName}/*", "-d", targetDir.absolutePath) }
|
||||
// @formatter:on
|
||||
// 删除所有二进制类库
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "win32/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "darwin/*") }
|
||||
exec { commandLine("zip", "-d", file.absolutePath, "linux/*") }
|
||||
// 设置可执行权限
|
||||
for (e in FileUtils.listFiles(
|
||||
targetDir,
|
||||
FileFilterUtils.trueFileFilter(),
|
||||
FileFilterUtils.falseFileFilter()
|
||||
)) {
|
||||
e.setExecutable(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +336,6 @@ tasks.register<Exec>("jpackage") {
|
||||
"-XX:+ZUncommit",
|
||||
"-XX:+ZGenerational",
|
||||
"-XX:ZUncommitDelay=60",
|
||||
"-XX:SoftMaxHeapSize=64m",
|
||||
"-XX:+HeapDumpOnOutOfMemoryError",
|
||||
"-Dlogger.console.level=off",
|
||||
"-Dkotlinx.coroutines.debug=off",
|
||||
@@ -274,7 +365,17 @@ tasks.register<Exec>("jpackage") {
|
||||
arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE)))
|
||||
arguments.addAll(listOf("--vendor", "TermoraDev"))
|
||||
arguments.addAll(listOf("--copyright", "TermoraDev"))
|
||||
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) {
|
||||
@@ -292,6 +393,10 @@ tasks.register<Exec>("jpackage") {
|
||||
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.ico"))
|
||||
}
|
||||
|
||||
if (os.isLinux) {
|
||||
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.png"))
|
||||
}
|
||||
|
||||
|
||||
arguments.add("--type")
|
||||
if (os.isMacOsX) {
|
||||
@@ -322,11 +427,8 @@ tasks.register("dist") {
|
||||
throw GradleException("JVM: $vendor is not supported")
|
||||
}
|
||||
|
||||
val distributionDir = layout.buildDirectory.dir("distributions").get()
|
||||
val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath
|
||||
val osName = if (os.isMacOsX) "osx" else if (os.isWindows) "windows" else "linux"
|
||||
val finalFilenameWithoutExtension = "${project.name}-${project.version}-${osName}-${arch.name}"
|
||||
val macOSFinalFilePath = distributionDir.file("${finalFilenameWithoutExtension}.dmg").asFile.absolutePath
|
||||
|
||||
|
||||
// 清空目录
|
||||
exec { commandLine(gradlew, "clean") }
|
||||
@@ -346,75 +448,8 @@ tasks.register("dist") {
|
||||
// 打包
|
||||
exec { commandLine(gradlew, "jpackage") }
|
||||
|
||||
// pack
|
||||
if (os.isWindows) { // zip and msi
|
||||
// zip
|
||||
exec {
|
||||
commandLine(
|
||||
"tar", "-vacf",
|
||||
distributionDir.file("${finalFilenameWithoutExtension}.zip").asFile.absolutePath,
|
||||
project.name.uppercaseFirstChar()
|
||||
)
|
||||
workingDir = layout.buildDirectory.dir("jpackage/images/win-msi.image/").get().asFile
|
||||
}
|
||||
|
||||
// msi
|
||||
exec {
|
||||
commandLine(
|
||||
"cmd", "/c", "move",
|
||||
"${project.name.uppercaseFirstChar()}-${project.version}.msi",
|
||||
"${finalFilenameWithoutExtension}.msi"
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
} else if (os.isLinux) { // tar.gz
|
||||
exec {
|
||||
commandLine(
|
||||
"tar", "-czvf",
|
||||
distributionDir.file("${finalFilenameWithoutExtension}.tar.gz").asFile.absolutePath,
|
||||
project.name.uppercaseFirstChar()
|
||||
)
|
||||
workingDir = distributionDir.asFile
|
||||
}
|
||||
} else if (os.isMacOsX) { // rename
|
||||
exec {
|
||||
commandLine(
|
||||
"mv",
|
||||
distributionDir.file("${project.name.uppercaseFirstChar()}-${project.version}.dmg").asFile.absolutePath,
|
||||
macOSFinalFilePath,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw GradleException("${os.name} is not supported")
|
||||
}
|
||||
|
||||
|
||||
// sign dmg
|
||||
if (os.isMacOsX && macOSSign) {
|
||||
|
||||
// sign
|
||||
signMacOSLocalFile(File(macOSFinalFilePath))
|
||||
|
||||
// notary
|
||||
if (macOSNotary) {
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/xcrun", "notarytool",
|
||||
"submit", macOSFinalFilePath,
|
||||
"--keychain-profile", macOSNotaryKeychainProfile,
|
||||
"--wait",
|
||||
)
|
||||
}
|
||||
|
||||
// 绑定公证信息
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/xcrun",
|
||||
"stapler", "staple", macOSFinalFilePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 根据不同的系统构建不同的二进制包
|
||||
pack()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,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 对本地文件进行签名
|
||||
*/
|
||||
@@ -471,6 +698,40 @@ fun signMacOSLocalFile(file: File) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS 对本地文件进行公证
|
||||
*/
|
||||
fun notaryMacOSLocalFile(file: File) {
|
||||
if (os.isMacOsX && macOSNotary) {
|
||||
if (file.exists()) {
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/xcrun", "notarytool",
|
||||
"submit", file,
|
||||
"--keychain-profile", macOSNotaryKeychainProfile,
|
||||
"--wait",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 盖章
|
||||
*/
|
||||
fun stapleMacOSLocalFile(file: File) {
|
||||
if (os.isMacOsX && macOSNotary) {
|
||||
if (file.exists()) {
|
||||
exec {
|
||||
commandLine(
|
||||
"/usr/bin/xcrun",
|
||||
"stapler", "staple", file,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
@@ -478,4 +739,11 @@ kotlin {
|
||||
@Suppress("UnstableApiUsage")
|
||||
vendor = JvmVendorSpec.JETBRAINS
|
||||
}
|
||||
}
|
||||
|
||||
idea {
|
||||
module {
|
||||
isDownloadJavadoc = true
|
||||
isDownloadSources = true
|
||||
}
|
||||
}
|
||||
BIN
docs/sftp-command.png
Normal file
BIN
docs/sftp-command.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -1,16 +1,17 @@
|
||||
[versions]
|
||||
kotlin = "2.1.0"
|
||||
kotlin = "2.1.10"
|
||||
slf4j = "2.0.16"
|
||||
pty4j = "0.13.2"
|
||||
tinylog = "2.7.0"
|
||||
kotlinx-coroutines = "1.10.1"
|
||||
flatlaf = "3.5.4"
|
||||
trove4j = "1.0.20200330"
|
||||
kotlinx-serialization-json = "1.7.3"
|
||||
commons-codec = "1.17.1"
|
||||
kotlinx-serialization-json = "1.8.0"
|
||||
commons-codec = "1.18.0"
|
||||
commons-lang3 = "3.17.0"
|
||||
commons-csv = "1.13.0"
|
||||
commons-net = "3.11.1"
|
||||
commons-text = "1.12.0"
|
||||
commons-text = "1.13.0"
|
||||
commons-compress = "1.27.1"
|
||||
koin-bom = "4.0.0"
|
||||
swingx = "1.6.5-1"
|
||||
@@ -41,7 +42,9 @@ rhino = "1.7.15"
|
||||
delight-rhino-sandbox = "0.0.17"
|
||||
testcontainers = "1.20.4"
|
||||
mixpanel = "1.5.3"
|
||||
jSerialComm="2.11.0"
|
||||
jSerialComm = "2.11.0"
|
||||
ini4j = "0.5.5-2"
|
||||
restart4j = "0.0.1"
|
||||
|
||||
[libraries]
|
||||
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
@@ -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-net = { group = "commons-net", name = "commons-net", version.ref = "commons-net" }
|
||||
commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version.ref = "commons-lang3" }
|
||||
commons-csv = { group = "org.apache.commons", name = "commons-csv", version.ref = "commons-csv" }
|
||||
commons-text = { group = "org.apache.commons", name = "commons-text", version.ref = "commons-text" }
|
||||
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" }
|
||||
pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" }
|
||||
ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" }
|
||||
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
|
||||
flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" }
|
||||
trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" }
|
||||
@@ -72,6 +77,7 @@ versioncompare = { module = "io.github.g00fy2:versioncompare", version.ref = "ve
|
||||
jfa = { module = "de.jangassen:jfa", version.ref = "jfa" }
|
||||
oshi-core = { module = "com.github.oshi:oshi-core", version.ref = "oshi" }
|
||||
commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" }
|
||||
restart4j = { module = "com.github.hstyi:restart4j", version.ref = "restart4j" }
|
||||
jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" }
|
||||
flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" }
|
||||
leveldb = { module = "org.iq80.leveldb:leveldb", version.ref = "leveldb" }
|
||||
|
||||
@@ -15,6 +15,8 @@ import org.slf4j.LoggerFactory
|
||||
import java.awt.Desktop
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.Duration
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
@@ -60,6 +62,16 @@ object Application {
|
||||
return "/bin/bash"
|
||||
}
|
||||
|
||||
fun getTemporaryDir(): File {
|
||||
val temporaryDir = File(getBaseDataDir(), "temporary")
|
||||
FileUtils.forceMkdir(temporaryDir)
|
||||
return temporaryDir
|
||||
}
|
||||
|
||||
fun createSubTemporaryDir(prefix: String = getName()): Path {
|
||||
return Files.createTempDirectory(getTemporaryDir().toPath(), prefix)
|
||||
}
|
||||
|
||||
fun getBaseDataDir(): File {
|
||||
if (::baseDataDir.isInitialized) {
|
||||
return baseDataDir
|
||||
|
||||
@@ -4,6 +4,8 @@ import app.termora.actions.ActionManager
|
||||
import app.termora.keymap.KeymapManager
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatSystemProperties
|
||||
import com.formdev.flatlaf.extras.FlatDesktop
|
||||
import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse
|
||||
import com.formdev.flatlaf.extras.FlatInspector
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jthemedetecor.OsThemeDetector
|
||||
@@ -20,12 +22,14 @@ import org.apache.commons.lang3.SystemUtils
|
||||
import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.tinylog.configuration.Configuration
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.io.File
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.FileLock
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.*
|
||||
import java.util.function.Consumer
|
||||
import javax.swing.*
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
@@ -73,6 +77,9 @@ class ApplicationRunner {
|
||||
// 解密数据
|
||||
val openDoor = measureTimeMillis { openDoor() }
|
||||
|
||||
// clear temporary
|
||||
clearTemporary()
|
||||
|
||||
// 启动主窗口
|
||||
val startMainFrame = measureTimeMillis { startMainFrame() }
|
||||
|
||||
@@ -94,6 +101,14 @@ class ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
private fun clearTemporary() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
// 启动时清除
|
||||
FileUtils.cleanDirectory(Application.getTemporaryDir())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun openDoor() {
|
||||
if (Doorman.getInstance().isWorking()) {
|
||||
@@ -104,7 +119,37 @@ class ApplicationRunner {
|
||||
}
|
||||
|
||||
private fun startMainFrame() {
|
||||
|
||||
TermoraFrameManager.getInstance().createWindow().isVisible = true
|
||||
|
||||
if (SystemUtils.IS_OS_MAC_OSX) {
|
||||
SwingUtilities.invokeLater {
|
||||
FlatDesktop.setQuitHandler(object : Consumer<QuitResponse> {
|
||||
override fun accept(response: QuitResponse) {
|
||||
quitHandler(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun quitHandler(response: QuitResponse) {
|
||||
val keyboardFocusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||
|
||||
if (OptionPane.showConfirmDialog(
|
||||
keyboardFocusManager.focusedWindow,
|
||||
I18n.getString("termora.quit-confirm", Application.getName()),
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
) != JOptionPane.YES_OPTION
|
||||
) {
|
||||
response.cancelQuit()
|
||||
return
|
||||
}
|
||||
|
||||
for (frame in TermoraFrameManager.getInstance().getWindows()) {
|
||||
frame.dispose()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
@@ -142,7 +187,7 @@ class ApplicationRunner {
|
||||
themeManager.change(theme, true)
|
||||
|
||||
if (Application.isUnknownVersion())
|
||||
FlatInspector.install("ctrl shift alt X");
|
||||
FlatInspector.install("ctrl shift alt X")
|
||||
|
||||
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
|
||||
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)
|
||||
|
||||
@@ -14,12 +14,10 @@ import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.set
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
@@ -55,6 +53,7 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
val safetyProperties by lazy { SafetyProperties("Setting.SafetyProperties") }
|
||||
val terminal by lazy { Terminal() }
|
||||
val appearance by lazy { Appearance() }
|
||||
val sftp by lazy { SFTP() }
|
||||
val sync by lazy { Sync() }
|
||||
|
||||
private val doorman get() = Doorman.getInstance()
|
||||
@@ -401,10 +400,10 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
protected inner class CursorStylePropertyDelegate(defaultValue: CursorStyle) :
|
||||
PropertyDelegate<CursorStyle>(defaultValue) {
|
||||
override fun convertValue(value: String): CursorStyle {
|
||||
try {
|
||||
return CursorStyle.valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
return initializer.invoke()
|
||||
return try {
|
||||
CursorStyle.valueOf(value)
|
||||
} catch (_: Exception) {
|
||||
initializer.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,6 +458,11 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
*/
|
||||
var beep by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 光标闪烁
|
||||
*/
|
||||
var cursorBlink by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 选中复制
|
||||
*/
|
||||
@@ -473,6 +477,11 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
* 终端断开连接时自动关闭Tab
|
||||
*/
|
||||
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 是否显示悬浮工具栏
|
||||
*/
|
||||
var floatingToolbar by BooleanPropertyDelegate(true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,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)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步配置
|
||||
*/
|
||||
|
||||
@@ -54,7 +54,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
protected fun init() {
|
||||
|
||||
|
||||
defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||
defaultCloseOperation = DISPOSE_ON_CLOSE
|
||||
|
||||
initTitleBar()
|
||||
initEvents()
|
||||
@@ -158,12 +158,14 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
openPopup = true
|
||||
}
|
||||
|
||||
val window = SwingUtilities.windowForComponent(c)
|
||||
val windows = window.ownedWindows
|
||||
for (w in windows) {
|
||||
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
|
||||
openPopup = true
|
||||
w.dispose()
|
||||
val window = c as? Window ?: SwingUtilities.windowForComponent(c)
|
||||
if (window != null) {
|
||||
val windows = window.ownedWindows
|
||||
for (w in windows) {
|
||||
if (w.isVisible && w.javaClass.getName().endsWith("HeavyWeightWindow")) {
|
||||
openPopup = true
|
||||
w.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
156
src/main/kotlin/app/termora/FilterableHostTreeModel.kt
Normal file
156
src/main/kotlin/app/termora/FilterableHostTreeModel.kt
Normal file
@@ -0,0 +1,156 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import java.util.function.Function
|
||||
import javax.swing.JTree
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.event.TreeModelEvent
|
||||
import javax.swing.event.TreeModelListener
|
||||
import javax.swing.tree.DefaultMutableTreeNode
|
||||
import javax.swing.tree.TreeModel
|
||||
import javax.swing.tree.TreeNode
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class FilterableHostTreeModel(
|
||||
private val tree: JTree,
|
||||
/**
|
||||
* 如果返回 true 则空文件夹也展示
|
||||
*/
|
||||
private val showEmptyFolder: () -> Boolean = { true }
|
||||
) : TreeModel {
|
||||
private val model = tree.model
|
||||
private val root = ReferenceTreeNode(model.root)
|
||||
private var listeners = emptyArray<TreeModelListener>()
|
||||
private var filters = emptyArray<Function<HostTreeNode, Boolean>>()
|
||||
private val mapping = mutableMapOf<TreeNode, ReferenceTreeNode>()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param a 旧的
|
||||
* @param b 新的
|
||||
*/
|
||||
private fun cloneTree(a: HostTreeNode, b: DefaultMutableTreeNode) {
|
||||
b.removeAllChildren()
|
||||
for (c in a.children()) {
|
||||
if (c !is HostTreeNode) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (c.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)
|
||||
|
||||
}
|
||||
@@ -5,6 +5,17 @@ import org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
|
||||
|
||||
fun Map<*, *>.toPropertiesString(): String {
|
||||
val env = StringBuilder()
|
||||
for ((i, e) in entries.withIndex()) {
|
||||
env.append(e.key).append('=').append(e.value)
|
||||
if (i != size - 1) {
|
||||
env.appendLine()
|
||||
}
|
||||
}
|
||||
return env.toString()
|
||||
}
|
||||
|
||||
fun UUID.toSimpleString(): String {
|
||||
return toString().replace("-", StringUtils.EMPTY)
|
||||
}
|
||||
@@ -13,7 +24,13 @@ enum class Protocol {
|
||||
Folder,
|
||||
SSH,
|
||||
Local,
|
||||
Serial
|
||||
Serial,
|
||||
|
||||
/**
|
||||
* 交互式的 SFTP,此协议只在系统内部交互不应该暴露给用户也不应该持久化
|
||||
*/
|
||||
@Transient
|
||||
SFTPPty
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +260,7 @@ data class Host(
|
||||
val tunnelings: List<Tunneling> = emptyList(),
|
||||
|
||||
/**
|
||||
* 排序
|
||||
* 排序,越小越靠前
|
||||
*/
|
||||
val sort: Long = 0,
|
||||
/**
|
||||
@@ -290,4 +307,8 @@ data class Host(
|
||||
result = 31 * result + ownerId.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
package app.termora
|
||||
|
||||
import java.util.*
|
||||
|
||||
interface HostListener : EventListener {
|
||||
fun hostAdded(host: Host) {}
|
||||
fun hostRemoved(id: String) {}
|
||||
fun hostsChanged() {}
|
||||
}
|
||||
|
||||
|
||||
class HostManager private constructor() {
|
||||
companion object {
|
||||
@@ -17,39 +9,38 @@ class HostManager private constructor() {
|
||||
}
|
||||
|
||||
private val database get() = Database.getDatabase()
|
||||
private val listeners = mutableListOf<HostListener>()
|
||||
private var hosts = mutableMapOf<String, Host>()
|
||||
|
||||
fun addHost(host: Host, notify: Boolean = true) {
|
||||
/**
|
||||
* 修改缓存并存入数据库
|
||||
*/
|
||||
fun addHost(host: Host) {
|
||||
assertEventDispatchThread()
|
||||
database.addHost(host)
|
||||
if (notify) listeners.forEach { it.hostAdded(host) }
|
||||
}
|
||||
|
||||
fun removeHost(id: String) {
|
||||
assertEventDispatchThread()
|
||||
database.removeHost(id)
|
||||
listeners.forEach { it.hostRemoved(id) }
|
||||
|
||||
if (host.deleted) {
|
||||
hosts.entries.removeIf { it.value.id == host.id || it.value.parentId == host.id }
|
||||
} else {
|
||||
hosts[host.id] = host
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 第一次调用从数据库中获取,后续从缓存中获取
|
||||
*/
|
||||
fun hosts(): List<Host> {
|
||||
return database.getHosts()
|
||||
if (hosts.isEmpty()) {
|
||||
database.getHosts().filter { !it.deleted }
|
||||
.forEach { hosts[it.id] = it }
|
||||
}
|
||||
return hosts.values.filter { !it.deleted }
|
||||
.sortedWith(compareBy<Host> { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort })
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
assertEventDispatchThread()
|
||||
database.removeAllHost()
|
||||
listeners.forEach { it.hostsChanged() }
|
||||
/**
|
||||
* 从缓存中获取
|
||||
*/
|
||||
fun getHost(id: String): Host? {
|
||||
return hosts[id]
|
||||
}
|
||||
|
||||
fun addHostListener(listener: HostListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeHostListener(listener: HostListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1134,16 +1134,16 @@ open class HostOptionsPane : OptionsPane() {
|
||||
private fun initEvents() {
|
||||
addBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent?) {
|
||||
val dialog = HostTreeDialog(owner) { host ->
|
||||
jumpHosts.none { it.id == host.id } && filter.invoke(host)
|
||||
}
|
||||
|
||||
val dialog = NewHostTreeDialog(owner)
|
||||
dialog.setFilter { node -> jumpHosts.none { it.id == node.host.id } && filter.invoke(node.host) }
|
||||
dialog.setTreeName("HostOptionsPane.JumpHostsOption.Tree")
|
||||
dialog.setLocationRelativeTo(owner)
|
||||
dialog.isVisible = true
|
||||
val hosts = dialog.hosts
|
||||
if (hosts.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
hosts.forEach {
|
||||
val rowCount = model.rowCount
|
||||
jumpHosts.add(it)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.terminal.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -12,7 +14,7 @@ abstract class HostTerminalTab(
|
||||
val windowScope: WindowScope,
|
||||
val host: Host,
|
||||
protected val terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal()
|
||||
) : PropertyTerminalTab() {
|
||||
) : PropertyTerminalTab(), DataProvider {
|
||||
companion object {
|
||||
val Host = DataKey(app.termora.Host::class)
|
||||
}
|
||||
@@ -69,4 +71,11 @@ abstract class HostTerminalTab(
|
||||
unread = false
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == DataProviders.Terminal) {
|
||||
return terminal as T?
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
97
src/main/kotlin/app/termora/HostTreeNode.kt
Normal file
97
src/main/kotlin/app/termora/HostTreeNode.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,15 @@ package app.termora
|
||||
object Icons {
|
||||
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
|
||||
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
|
||||
val closeSmall by lazy { DynamicIcon("icons/closeSmall.svg", "icons/closeSmall_dark.svg") }
|
||||
val closeSmallHovered by lazy { DynamicIcon("icons/closeSmallHovered.svg", "icons/closeSmallHovered_dark.svg") }
|
||||
val plugin by lazy { DynamicIcon("icons/plugin.svg", "icons/plugin_dark.svg") }
|
||||
val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") }
|
||||
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
|
||||
val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }
|
||||
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
|
||||
val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") }
|
||||
val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") }
|
||||
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
|
||||
val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
|
||||
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
|
||||
@@ -24,6 +28,9 @@ object Icons {
|
||||
val empty by lazy { DynamicIcon("icons/empty.svg") }
|
||||
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") }
|
||||
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
|
||||
val locate by lazy { DynamicIcon("icons/locate.svg", "icons/locate_dark.svg") }
|
||||
val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") }
|
||||
val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") }
|
||||
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
|
||||
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
|
||||
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
|
||||
@@ -48,6 +55,7 @@ object Icons {
|
||||
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
|
||||
val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") }
|
||||
val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_dark.svg") }
|
||||
val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_dark.svg") }
|
||||
val azure by lazy { DynamicIcon("icons/azure.svg", "icons/azure_dark.svg") }
|
||||
val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") }
|
||||
val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") }
|
||||
@@ -111,5 +119,6 @@ object Icons {
|
||||
val listKey by lazy { DynamicIcon("icons/listKey.svg", "icons/listKey_dark.svg") }
|
||||
val forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") }
|
||||
val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_dark.svg") }
|
||||
val nvidia by lazy { DynamicIcon("icons/nvidia.svg", "icons/nvidia_dark.svg") }
|
||||
|
||||
}
|
||||
@@ -12,6 +12,10 @@ fun main() {
|
||||
setupNativeLibraries()
|
||||
}
|
||||
|
||||
if (SystemUtils.IS_OS_MAC_OSX) {
|
||||
System.setProperty("apple.awt.application.name", Application.getName())
|
||||
}
|
||||
|
||||
ApplicationRunner().run()
|
||||
}
|
||||
|
||||
@@ -46,4 +50,9 @@ private fun setupNativeLibraries() {
|
||||
if (jSerialComm.exists()) {
|
||||
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
|
||||
}
|
||||
|
||||
val restart4j = FileUtils.getFile(dylib, "restart4j", "restarter")
|
||||
if (restart4j.exists()) {
|
||||
System.setProperty("restarter.path", restart4j.absolutePath)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import app.termora.actions.ActionManager
|
||||
import app.termora.terminal.Terminal
|
||||
import app.termora.terminal.TerminalColor
|
||||
import app.termora.terminal.TextStyle
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
import app.termora.terminal.panel.TerminalDisplay
|
||||
import app.termora.terminal.panel.TerminalPaintListener
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
@@ -32,13 +33,25 @@ class MultipleTerminalListener : TerminalPaintListener {
|
||||
// 正在搜索那么需要下移
|
||||
val finding = terminal.getTerminalModel().getData(TerminalPanel.Finding, false)
|
||||
|
||||
// 如果悬浮窗正在显示,那么需要下移
|
||||
val floatingToolBar = terminalPanel.getData(FloatingToolbarPanel.FloatingToolbar)?.isVisible == true
|
||||
|
||||
var y = g.fontMetrics.ascent
|
||||
if (finding) {
|
||||
y += g.fontMetrics.height + g.fontMetrics.ascent / 2
|
||||
}
|
||||
|
||||
if (floatingToolBar) {
|
||||
y += g.fontMetrics.height + g.fontMetrics.ascent / 2
|
||||
}
|
||||
|
||||
|
||||
g.font = font
|
||||
g.color = Color(colorPalette.getColor(TerminalColor.Normal.RED))
|
||||
g.drawString(
|
||||
text,
|
||||
terminalDisplay.width - width - terminalPanel.getAverageCharWidth() / 2,
|
||||
g.fontMetrics.ascent + if (finding)
|
||||
g.fontMetrics.height + g.fontMetrics.ascent / 2 else 0
|
||||
y
|
||||
)
|
||||
g.font = oldFont
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviders
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
@@ -13,11 +14,13 @@ import kotlin.math.abs
|
||||
|
||||
class MyTabbedPane : FlatTabbedPane() {
|
||||
|
||||
private val owner: Window get() = SwingUtilities.getWindowAncestor(this)
|
||||
private val dragMouseAdaptor = DragMouseAdaptor()
|
||||
private val terminalTabbedManager
|
||||
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
|
||||
.getData(DataProviders.TerminalTabbedManager)
|
||||
private val owner
|
||||
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
|
||||
.getData(DataProviders.TermoraFrame) as TermoraFrame
|
||||
|
||||
init {
|
||||
initEvents()
|
||||
@@ -80,6 +83,8 @@ class MyTabbedPane : FlatTabbedPane() {
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index < 0 || !isTabClosable(index)) {
|
||||
tabIndex = -1
|
||||
mousePressedPoint = Point()
|
||||
return
|
||||
}
|
||||
tabIndex = index
|
||||
@@ -143,11 +148,11 @@ class MyTabbedPane : FlatTabbedPane() {
|
||||
// 如果等于 null 表示在空地方释放,那么单独一个窗口
|
||||
if (c == null) {
|
||||
val window = TermoraFrameManager.getInstance().createWindow()
|
||||
dragToAnotherWindow(window)
|
||||
dragToAnotherWindow(owner, window)
|
||||
window.location = releasedPoint
|
||||
window.isVisible = true
|
||||
} else if (c != owner && c is TermoraFrame) { // 如果在某个窗口内释放,那么就移动到某个窗口内
|
||||
dragToAnotherWindow(c)
|
||||
dragToAnotherWindow(owner, c)
|
||||
} else {
|
||||
val tab = this.terminalTab
|
||||
val terminalTabbedManager = terminalTabbedManager
|
||||
@@ -222,20 +227,29 @@ class MyTabbedPane : FlatTabbedPane() {
|
||||
}
|
||||
|
||||
|
||||
private fun dragToAnotherWindow(frame: TermoraFrame) {
|
||||
private fun dragToAnotherWindow(oldFrame: TermoraFrame, frame: TermoraFrame) {
|
||||
val tab = this.terminalTab ?: return
|
||||
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
|
||||
val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return
|
||||
val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return
|
||||
val windowScope = frame.getData(DataProviders.WindowScope) ?: return
|
||||
val oldWindowScope = oldFrame.getData(DataProviders.WindowScope) ?: return
|
||||
val location = Point(MouseInfo.getPointerInfo().location)
|
||||
SwingUtilities.convertPointFromScreen(location, tabbedPane)
|
||||
val index = tabbedPane.indexAtLocation(location.x, location.y)
|
||||
|
||||
|
||||
moveTab(
|
||||
tabbedManager,
|
||||
tab,
|
||||
index
|
||||
)
|
||||
|
||||
TerminalPanelFactory.getInstance(oldWindowScope).removeTerminalPanel(terminalPanel)
|
||||
TerminalPanelFactory.getInstance(windowScope).addTerminalPanel(terminalPanel)
|
||||
|
||||
|
||||
|
||||
if (frame.hasFocus()) {
|
||||
return
|
||||
}
|
||||
|
||||
1116
src/main/kotlin/app/termora/NewHostTree.kt
Normal file
1116
src/main/kotlin/app/termora/NewHostTree.kt
Normal file
File diff suppressed because it is too large
Load Diff
87
src/main/kotlin/app/termora/NewHostTreeDialog.kt
Normal file
87
src/main/kotlin/app/termora/NewHostTreeDialog.kt
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
83
src/main/kotlin/app/termora/NewHostTreeModel.kt
Normal file
83
src/main/kotlin/app/termora/NewHostTreeModel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jetbrains.JBR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.JXLabel
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Desktop
|
||||
@@ -57,6 +55,7 @@ object OptionPane {
|
||||
pane.selectInitialValue()
|
||||
}
|
||||
})
|
||||
dialog.setLocationRelativeTo(parentComponent)
|
||||
dialog.isVisible = true
|
||||
dialog.dispose()
|
||||
val selectedValue = pane.value
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.beans.PropertyChangeEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.util.*
|
||||
|
||||
abstract class PropertyTerminalTab : TerminalTab {
|
||||
protected val listeners = mutableListOf<PropertyChangeListener>()
|
||||
@@ -26,6 +30,10 @@ abstract class PropertyTerminalTab : TerminalTab {
|
||||
|
||||
override fun onLostFocus() {
|
||||
hasFocus = false
|
||||
|
||||
// 切换标签时,尝试隐藏悬浮工具栏
|
||||
val evt = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
|
||||
evt.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerHide()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,20 @@ class PtyConnectorFactory : Disposable {
|
||||
rows: Int = 24, cols: Int = 80,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
charset: Charset = StandardCharsets.UTF_8
|
||||
): PtyConnector {
|
||||
val command = database.terminal.localShell
|
||||
val commands = mutableListOf(command)
|
||||
if (SystemUtils.IS_OS_UNIX) {
|
||||
commands.add("-l")
|
||||
}
|
||||
return createPtyConnector(commands.toTypedArray(), rows, cols, env, charset)
|
||||
}
|
||||
|
||||
fun createPtyConnector(
|
||||
commands: Array<String>,
|
||||
rows: Int = 24, cols: Int = 80,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
charset: Charset = StandardCharsets.UTF_8
|
||||
): PtyConnector {
|
||||
val envs = mutableMapOf<String, String>()
|
||||
envs.putAll(System.getenv())
|
||||
@@ -44,17 +58,11 @@ class PtyConnectorFactory : Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
val command = database.terminal.localShell
|
||||
val commands = mutableListOf(command)
|
||||
if (SystemUtils.IS_OS_UNIX) {
|
||||
commands.add("-l")
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("command: {} , envs: {}", commands.joinToString(" "), envs)
|
||||
}
|
||||
|
||||
val ptyProcess = PtyProcessBuilder(commands.toTypedArray())
|
||||
val ptyProcess = PtyProcessBuilder(commands)
|
||||
.setEnvironment(envs)
|
||||
.setInitialRows(rows)
|
||||
.setInitialColumns(cols)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.terminal.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
@@ -50,7 +51,7 @@ abstract class PtyHostTerminalTab(
|
||||
startPtyConnectorReader()
|
||||
|
||||
// 启动命令
|
||||
if (host.options.startupCommand.isNotBlank()) {
|
||||
if (host.options.startupCommand.isNotBlank() && host.protocol != Protocol.SFTPPty) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
delay(250.milliseconds)
|
||||
withContext(Dispatchers.Swing) {
|
||||
@@ -135,4 +136,12 @@ abstract class PtyHostTerminalTab(
|
||||
}
|
||||
|
||||
abstract suspend fun openPtyConnector(): PtyConnector
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == DataProviders.TerminalPanel) {
|
||||
return terminalPanel as T?
|
||||
}
|
||||
return super.getData(dataKey)
|
||||
}
|
||||
}
|
||||
161
src/main/kotlin/app/termora/RequestAuthenticationDialog.kt
Normal file
161
src/main/kotlin/app/termora/RequestAuthenticationDialog.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
201
src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt
Normal file
201
src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt
Normal 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())
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.transport.TransportDataProviders
|
||||
import app.termora.transport.TransportPanel
|
||||
import java.beans.PropertyChangeListener
|
||||
@@ -8,12 +10,13 @@ import javax.swing.JComponent
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class SFTPTerminalTab : Disposable, TerminalTab {
|
||||
class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
|
||||
|
||||
private val transportPanel by lazy {
|
||||
TransportPanel().apply {
|
||||
Disposer.register(this@SFTPTerminalTab, this)
|
||||
}
|
||||
private val sftp get() = Database.getDatabase().sftp
|
||||
private val transportPanel = TransportPanel()
|
||||
|
||||
init {
|
||||
Disposer.register(this, transportPanel)
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
@@ -41,6 +44,11 @@ class SFTPTerminalTab : Disposable, TerminalTab {
|
||||
|
||||
override fun canClose(): Boolean {
|
||||
assertEventDispatchThread()
|
||||
|
||||
if (sftp.pinTab) {
|
||||
return false
|
||||
}
|
||||
|
||||
val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true
|
||||
if (transportManager.getTransports().isEmpty()) {
|
||||
return true
|
||||
@@ -54,4 +62,12 @@ class SFTPTerminalTab : Disposable, TerminalTab {
|
||||
) == JOptionPane.OK_OPTION
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == TransportDataProviders.TransportPanel) {
|
||||
return transportPanel as T
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,10 +26,9 @@ import org.apache.sshd.common.channel.ChannelListener
|
||||
import org.apache.sshd.common.session.Session
|
||||
import org.apache.sshd.common.session.SessionListener
|
||||
import org.apache.sshd.common.session.SessionListener.Event
|
||||
import org.apache.sshd.common.util.net.SshdSocketAddress
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.EventObject
|
||||
import java.util.*
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
@@ -37,10 +36,13 @@ import javax.swing.SwingUtilities
|
||||
class SSHTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
PtyHostTerminalTab(windowScope, host) {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
|
||||
val SSHSession = DataKey(ClientSession::class)
|
||||
|
||||
private val log = LoggerFactory.getLogger(SSHTerminalTab::class.java)
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val tab = this
|
||||
|
||||
private var sshClient: SshClient? = null
|
||||
private var sshSession: ClientSession? = null
|
||||
@@ -87,9 +89,34 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
terminal.write("SSH client is opening...\r\n")
|
||||
}
|
||||
|
||||
var host =
|
||||
this.host.copy(authentication = this.host.authentication.copy(), updateDate = System.currentTimeMillis())
|
||||
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
|
||||
val client = SshClients.openClient(host).also { sshClient = it }
|
||||
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
|
||||
// keyboard interactive
|
||||
client.userInteraction = TerminalUserInteraction(SwingUtilities.getWindowAncestor(terminalPanel))
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
|
||||
if (host.authentication.type == AuthenticationType.No) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
val dialog = RequestAuthenticationDialog(owner, host)
|
||||
val authentication = dialog.getAuthentication()
|
||||
host = host.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(),
|
||||
updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
// save
|
||||
if (dialog.isRemembered()) {
|
||||
HostManager.getInstance().addHost(
|
||||
tab.host.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sessionListener = MySessionListener()
|
||||
val channelListener = MyChannelListener()
|
||||
@@ -141,7 +168,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
if (Database.getDatabase().terminal.autoCloseTabWhenDisconnected) {
|
||||
terminalTabbedManager?.let { manager ->
|
||||
SwingUtilities.invokeLater {
|
||||
manager.closeTerminalTab(this@SSHTerminalTab, true)
|
||||
manager.closeTerminalTab(tab, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,35 +205,30 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
}
|
||||
|
||||
for (tunneling in host.tunnelings) {
|
||||
if (tunneling.type == TunnelingType.Local) {
|
||||
session.startLocalPortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Remote) {
|
||||
session.startRemotePortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Dynamic) {
|
||||
session.startDynamicPortForwarding(
|
||||
SshdSocketAddress(
|
||||
tunneling.sourceHost,
|
||||
tunneling.sourcePort
|
||||
)
|
||||
)
|
||||
try {
|
||||
SshClients.openTunneling(session, host, tunneling)
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error("Start [${tunneling.name}] port forwarding failed: {}", e.message, e)
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("Start [${tunneling.name}] port forwarding failed: ${e.message}\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("SSH [{}] started {} port forwarding.", host.name, tunneling.name)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == SSHSession) {
|
||||
return sshSession as T?
|
||||
}
|
||||
return super.getData(dataKey)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (mutex.tryLock()) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import app.termora.AES.encodeBase64String
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.highlight.KeywordHighlight
|
||||
import app.termora.highlight.KeywordHighlightManager
|
||||
import app.termora.keymap.Keymap
|
||||
@@ -20,7 +21,9 @@ import app.termora.sync.SyncType
|
||||
import app.termora.sync.SyncerProvider
|
||||
import app.termora.terminal.CursorStyle
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import app.termora.transport.SFTPAction
|
||||
import cash.z.ecc.android.bip39.Mnemonics
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||
@@ -33,17 +36,19 @@ import com.jthemedetecor.OsThemeDetector
|
||||
import com.sun.jna.LastErrorException
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.*
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.jdesktop.swingx.JXEditorPane
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.event.ItemEvent
|
||||
@@ -64,6 +69,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
private val keymapManager get() = KeymapManager.getInstance()
|
||||
private val macroManager get() = MacroManager.getInstance()
|
||||
private val actionManager get() = ActionManager.getInstance()
|
||||
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
|
||||
private val keyManager get() = KeyManager.getInstance()
|
||||
|
||||
@@ -109,6 +115,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
addOption(AppearanceOption())
|
||||
addOption(TerminalOption())
|
||||
addOption(KeyShortcutsOption())
|
||||
addOption(SFTPOption())
|
||||
addOption(CloudSyncOption())
|
||||
addOption(DoormanOption())
|
||||
addOption(AboutOption())
|
||||
@@ -191,12 +198,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
appearance.language = languageComboBox.selectedItem as String
|
||||
SwingUtilities.invokeLater {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.restart.message"),
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
messageType = JOptionPane.INFORMATION_MESSAGE,
|
||||
)
|
||||
TermoraRestarter.getInstance().scheduleRestart(owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,6 +303,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private val cursorStyleComboBox = FlatComboBox<CursorStyle>()
|
||||
private val debugComboBox = YesOrNoComboBox()
|
||||
private val beepComboBox = YesOrNoComboBox()
|
||||
private val cursorBlinkComboBox = YesOrNoComboBox()
|
||||
private val fontComboBox = FlatComboBox<String>()
|
||||
private val shellComboBox = FlatComboBox<String>()
|
||||
private val maxRowsTextField = IntSpinner(0, 0)
|
||||
@@ -308,6 +311,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private val terminalSetting get() = Database.getDatabase().terminal
|
||||
private val selectCopyComboBox = YesOrNoComboBox()
|
||||
private val autoCloseTabComboBox = YesOrNoComboBox()
|
||||
private val floatingToolbarComboBox = YesOrNoComboBox()
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -330,6 +334,19 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
autoCloseTabComboBox.toolTipText = I18n.getString("termora.settings.terminal.auto-close-tab-description")
|
||||
|
||||
floatingToolbarComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.floatingToolbar = floatingToolbarComboBox.selectedItem as Boolean
|
||||
TerminalPanelFactory.getAllTerminalPanel().forEach { tp ->
|
||||
if (terminalSetting.floatingToolbar && FloatingToolbarPanel.isPined) {
|
||||
tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerShow()
|
||||
} else {
|
||||
tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerHide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectCopyComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean
|
||||
@@ -372,6 +389,12 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
cursorBlinkComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.cursorBlink = cursorBlinkComboBox.selectedItem as Boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
shellComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
@@ -408,6 +431,11 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
|
||||
fontComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
init {
|
||||
preferredSize = Dimension(preferredSize.width, fontComboBox.preferredSize.height - 2)
|
||||
maximumSize = Dimension(preferredSize.width, preferredSize.height)
|
||||
}
|
||||
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
@@ -441,28 +469,11 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
shellComboBox.selectedItem = terminalSetting.localShell
|
||||
|
||||
val fonts = linkedSetOf(
|
||||
"JetBrains Mono",
|
||||
"Source Code Pro",
|
||||
"Monospaced",
|
||||
"Andale Mono",
|
||||
"Ayuthaya",
|
||||
"Courier New",
|
||||
"Droid Sans Mono",
|
||||
"Fira Code",
|
||||
"PCMyungjo",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Osaka",
|
||||
"PT Mono",
|
||||
"SimSong",
|
||||
)
|
||||
|
||||
for (font in FontUtils.getAllFonts()) {
|
||||
if (fonts.contains(font.family)) {
|
||||
continue
|
||||
val fonts = linkedSetOf<String>("JetBrains Mono", "Source Code Pro", "Monospaced")
|
||||
FontUtils.getAllFonts().forEach {
|
||||
if (!fonts.contains(it.family)) {
|
||||
fonts.addLast(it.family)
|
||||
}
|
||||
fonts.remove(font.family)
|
||||
}
|
||||
|
||||
for (font in fonts) {
|
||||
@@ -472,9 +483,11 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
fontComboBox.selectedItem = terminalSetting.font
|
||||
debugComboBox.selectedItem = terminalSetting.debug
|
||||
beepComboBox.selectedItem = terminalSetting.beep
|
||||
cursorBlinkComboBox.selectedItem = terminalSetting.cursorBlink
|
||||
cursorStyleComboBox.selectedItem = terminalSetting.cursor
|
||||
selectCopyComboBox.selectedItem = terminalSetting.selectCopy
|
||||
autoCloseTabComboBox.selectedItem = terminalSetting.autoCloseTabWhenDisconnected
|
||||
floatingToolbarComboBox.selectedItem = terminalSetting.floatingToolbar
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
@@ -492,7 +505,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $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)
|
||||
@@ -519,6 +532,10 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
.add(selectCopyComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows)
|
||||
.add(cursorStyleComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.cursor-blink")}:").xy(1, rows)
|
||||
.add(cursorBlinkComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.floating-toolbar")}:").xy(1, rows)
|
||||
.add(floatingToolbarComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.auto-close-tab")}:").xy(1, rows)
|
||||
.add(autoCloseTabComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.local-shell")}:").xy(1, rows)
|
||||
@@ -666,13 +683,40 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
private fun export() {
|
||||
|
||||
assertEventDispatchThread()
|
||||
|
||||
val passwordField = OutlinePasswordField()
|
||||
val panel = object : JPanel(BorderLayout()) {
|
||||
override fun requestFocusInWindow(): Boolean {
|
||||
return passwordField.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
|
||||
val label = JLabel(I18n.getString("termora.settings.sync.export-encrypt") + StringUtils.SPACE.repeat(25))
|
||||
label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
|
||||
panel.add(label, BorderLayout.NORTH)
|
||||
panel.add(passwordField, BorderLayout.CENTER)
|
||||
|
||||
var password = StringUtils.EMPTY
|
||||
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
panel,
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
initialValue = passwordField
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
password = String(passwordField.password).trim()
|
||||
}
|
||||
|
||||
|
||||
val fileChooser = FileChooser()
|
||||
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
|
||||
fileChooser.win32Filters.add(Pair("All Files", listOf("*")))
|
||||
fileChooser.win32Filters.add(Pair("JSON files", listOf("json")))
|
||||
fileChooser.showSaveDialog(owner, "${Application.getName()}.json").thenAccept { file ->
|
||||
if (file != null) {
|
||||
SwingUtilities.invokeLater { exportText(file) }
|
||||
SwingUtilities.invokeLater { exportText(file, password) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -689,6 +733,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
private fun importFromFile(file: File) {
|
||||
if (!file.exists()) {
|
||||
return
|
||||
@@ -719,7 +764,79 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
return
|
||||
}
|
||||
|
||||
val json = jsonResult.getOrNull() ?: return
|
||||
var json = jsonResult.getOrNull() ?: return
|
||||
|
||||
// 如果加密了 则解密数据
|
||||
if (json["encryption"]?.jsonPrimitive?.booleanOrNull == true) {
|
||||
val data = json["data"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
|
||||
if (data.isBlank()) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, "Data file corruption",
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val passwordField = OutlinePasswordField()
|
||||
val panel = object : JPanel(BorderLayout()) {
|
||||
override fun requestFocusInWindow(): Boolean {
|
||||
return passwordField.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
|
||||
val label = JLabel("Please enter the password" + StringUtils.SPACE.repeat(25))
|
||||
label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
|
||||
panel.add(label, BorderLayout.NORTH)
|
||||
panel.add(passwordField, BorderLayout.CENTER)
|
||||
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
panel,
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
initialValue = passwordField
|
||||
) != JOptionPane.YES_OPTION
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordField.password.isEmpty()) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, I18n.getString("termora.doorman.unlock-data"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
val password = String(passwordField.password)
|
||||
val key = PBKDF2.generateSecret(
|
||||
password.toCharArray(),
|
||||
password.toByteArray(), keyLength = 128
|
||||
)
|
||||
|
||||
try {
|
||||
val dataText = AES.ECB.decrypt(key, Base64.decodeBase64(data)).toString(Charsets.UTF_8)
|
||||
val dataJsonResult = ohMyJson.runCatching { decodeFromString<JsonObject>(dataText) }
|
||||
if (dataJsonResult.isFailure) {
|
||||
val e = dataJsonResult.exceptionOrNull() ?: return
|
||||
OptionPane.showMessageDialog(
|
||||
owner, ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
json = dataJsonResult.getOrNull() ?: return
|
||||
break
|
||||
} catch (_: Exception) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, I18n.getString("termora.doorman.password-wrong"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.contains(SyncRange.Hosts)) {
|
||||
val hosts = json["hosts"]
|
||||
if (hosts is JsonArray) {
|
||||
@@ -780,9 +897,9 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun exportText(file: File) {
|
||||
private fun exportText(file: File, password: String) {
|
||||
val syncConfig = getSyncConfig()
|
||||
val text = ohMyJson.encodeToString(buildJsonObject {
|
||||
var text = ohMyJson.encodeToString(buildJsonObject {
|
||||
val now = System.currentTimeMillis()
|
||||
put("exporter", SystemUtils.USER_NAME)
|
||||
put("version", Application.getVersion())
|
||||
@@ -821,6 +938,19 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
put("terminal", ohMyJson.encodeToJsonElement(database.terminal.getProperties()))
|
||||
})
|
||||
})
|
||||
|
||||
if (password.isNotBlank()) {
|
||||
val key = PBKDF2.generateSecret(
|
||||
password.toCharArray(),
|
||||
password.toByteArray(), keyLength = 128
|
||||
)
|
||||
|
||||
text = ohMyJson.encodeToString(buildJsonObject {
|
||||
put("encryption", true)
|
||||
put("data", AES.ECB.encrypt(key, text.toByteArray(Charsets.UTF_8)).encodeBase64String())
|
||||
})
|
||||
}
|
||||
|
||||
file.outputStream().use {
|
||||
IOUtils.write(text, it, StandardCharsets.UTF_8)
|
||||
OptionPane.openFileInFolder(
|
||||
@@ -1172,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 {
|
||||
|
||||
init {
|
||||
@@ -1389,7 +1618,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
val key = doorman.work(passwordTextField.password)
|
||||
|
||||
hosts.forEach { hostManager.addHost(it, false) }
|
||||
hosts.forEach { hostManager.addHost(it) }
|
||||
keyPairs.forEach { keyManager.addOhKeyPair(it) }
|
||||
for (e in properties) {
|
||||
for ((k, v) in e.second) {
|
||||
|
||||
@@ -2,14 +2,23 @@ package app.termora
|
||||
|
||||
import app.termora.keymgr.OhKeyPairKeyPairProvider
|
||||
import app.termora.terminal.TerminalSize
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.sshd.client.ClientBuilder
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.channel.ChannelShell
|
||||
import org.apache.sshd.client.channel.ClientChannelEvent
|
||||
import org.apache.sshd.client.config.hosts.HostConfigEntry
|
||||
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
|
||||
import org.apache.sshd.client.config.hosts.KnownHostEntry
|
||||
import org.apache.sshd.client.kex.DHGClient
|
||||
import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier
|
||||
import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
|
||||
import org.apache.sshd.client.keyverifier.ServerKeyVerifier
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.common.SshException
|
||||
import org.apache.sshd.common.channel.PtyChannelConfiguration
|
||||
import org.apache.sshd.common.config.keys.KeyUtils
|
||||
import org.apache.sshd.common.global.KeepAliveHandler
|
||||
import org.apache.sshd.common.kex.BuiltinDHFactories
|
||||
import org.apache.sshd.common.keyprovider.KeyIdentityProvider
|
||||
@@ -17,14 +26,25 @@ import org.apache.sshd.common.util.net.SshdSocketAddress
|
||||
import org.apache.sshd.core.CoreModuleProperties
|
||||
import org.apache.sshd.server.forward.AcceptAllForwardingFilter
|
||||
import org.apache.sshd.server.forward.RejectAllForwardingFilter
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitSshClient
|
||||
import org.eclipse.jgit.transport.CredentialsProvider
|
||||
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
|
||||
import org.eclipse.jgit.transport.sshd.ProxyData
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Window
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.SocketAddress
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.security.PublicKey
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.math.max
|
||||
|
||||
object SshClients {
|
||||
@@ -59,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())
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开一个会话
|
||||
*/
|
||||
@@ -89,7 +137,7 @@ object SshClients {
|
||||
val sessions = mutableListOf<ClientSession>()
|
||||
for (i in 0 until jumpHosts.size) {
|
||||
val currentHost = jumpHosts[i]
|
||||
sessions.add(doOpenSession(currentHost, client))
|
||||
sessions.add(doOpenSession(currentHost, client, i != 0))
|
||||
|
||||
// 如果有下一跳
|
||||
if (i < jumpHosts.size - 1) {
|
||||
@@ -103,15 +151,34 @@ object SshClients {
|
||||
log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
|
||||
}
|
||||
// 映射完毕之后修改Host和端口
|
||||
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port)
|
||||
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
return sessions.last()
|
||||
}
|
||||
|
||||
private fun doOpenSession(host: Host, client: SshClient): ClientSession {
|
||||
val session = client.connect(host.username, host.host, host.port)
|
||||
fun isMiddleware(session: ClientSession): Boolean {
|
||||
if (session is JGitClientSession) {
|
||||
if (session.hostConfigEntry.properties["Middleware"]?.toBoolean() == true) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param middleware 如果为 true 表示是跳板
|
||||
*/
|
||||
private fun doOpenSession(host: Host, client: SshClient, middleware: Boolean = false): ClientSession {
|
||||
val entry = HostConfigEntry()
|
||||
entry.port = host.port
|
||||
entry.username = host.username
|
||||
entry.hostName = host.host
|
||||
entry.setProperty("Middleware", middleware.toString())
|
||||
|
||||
val session = client.connect(entry)
|
||||
.verify(timeout).session
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
session.addPasswordIdentity(host.authentication.password)
|
||||
@@ -127,6 +194,41 @@ object SshClients {
|
||||
return session
|
||||
}
|
||||
|
||||
fun openTunneling(session: ClientSession, host: Host, tunneling: Tunneling): SshdSocketAddress {
|
||||
|
||||
val sshdSocketAddress = if (tunneling.type == TunnelingType.Local) {
|
||||
session.startLocalPortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Remote) {
|
||||
session.startRemotePortForwarding(
|
||||
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
|
||||
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
|
||||
)
|
||||
} else if (tunneling.type == TunnelingType.Dynamic) {
|
||||
session.startDynamicPortForwarding(
|
||||
SshdSocketAddress(
|
||||
tunneling.sourceHost,
|
||||
tunneling.sourcePort
|
||||
)
|
||||
)
|
||||
} else {
|
||||
SshdSocketAddress.LOCALHOST_ADDRESS
|
||||
}
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info(
|
||||
"SSH [{}] started {} port forwarding. host: {} , port: {}",
|
||||
host.name,
|
||||
tunneling.name,
|
||||
sshdSocketAddress.hostName,
|
||||
sshdSocketAddress.port
|
||||
)
|
||||
}
|
||||
|
||||
return sshdSocketAddress
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开一个客户端
|
||||
@@ -191,4 +293,94 @@ object SshClients {
|
||||
sshClient.start()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,67 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.highlight.KeywordHighlightPaintListener
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.Terminal
|
||||
import app.termora.terminal.panel.TerminalHyperlinkPaintListener
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import kotlinx.coroutines.*
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.awt.event.ComponentListener
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class TerminalPanelFactory {
|
||||
class TerminalPanelFactory : Disposable {
|
||||
private val terminalPanels = mutableListOf<TerminalPanel>()
|
||||
|
||||
companion object {
|
||||
|
||||
private val Factory = DataKey(TerminalPanelFactory::class)
|
||||
|
||||
fun getInstance(scope: Scope): TerminalPanelFactory {
|
||||
return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
|
||||
}
|
||||
|
||||
fun getAllTerminalPanel(): Array<TerminalPanel> {
|
||||
return ApplicationScope.forApplicationScope().windowScopes()
|
||||
.map { getInstance(it) }
|
||||
.flatMap { it.terminalPanels }.toTypedArray()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// repaint
|
||||
Painter.getInstance()
|
||||
}
|
||||
|
||||
|
||||
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
|
||||
val terminalPanel = TerminalPanel(terminal, ptyConnector)
|
||||
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
|
||||
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
|
||||
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
|
||||
terminal.getTerminalModel().setData(Factory, this)
|
||||
|
||||
Disposer.register(terminalPanel, object : Disposable {
|
||||
override fun dispose() {
|
||||
terminalPanels.remove(terminalPanel)
|
||||
if (terminal.getTerminalModel().hasData(Factory)) {
|
||||
terminal.getTerminalModel().getData(Factory).removeTerminalPanel(terminalPanel)
|
||||
}
|
||||
}
|
||||
})
|
||||
terminalPanels.add(terminalPanel)
|
||||
|
||||
addTerminalPanel(terminalPanel)
|
||||
return terminalPanel
|
||||
}
|
||||
|
||||
fun getTerminalPanels(): List<TerminalPanel> {
|
||||
return terminalPanels
|
||||
fun getTerminalPanels(): Array<TerminalPanel> {
|
||||
return terminalPanels.toTypedArray()
|
||||
}
|
||||
|
||||
fun repaintAll() {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
terminalPanels.forEach { it.repaintImmediate() }
|
||||
getTerminalPanels().forEach { it.repaintImmediate() }
|
||||
} else {
|
||||
SwingUtilities.invokeLater { repaintAll() }
|
||||
}
|
||||
@@ -56,4 +79,35 @@ class TerminalPanelFactory {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,11 +18,8 @@ import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.util.*
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.*
|
||||
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.math.min
|
||||
|
||||
class TerminalTabbed(
|
||||
@@ -76,12 +73,17 @@ class TerminalTabbed(
|
||||
tabbedPane.addPropertyChangeListener("selectedIndex") { evt ->
|
||||
val oldIndex = evt.oldValue as Int
|
||||
val newIndex = evt.newValue as Int
|
||||
|
||||
if (oldIndex >= 0 && tabs.size > newIndex) {
|
||||
tabs[oldIndex].onLostFocus()
|
||||
}
|
||||
|
||||
if (newIndex >= 0 && tabs.size > newIndex) {
|
||||
tabs[newIndex].onGrabFocus()
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater { tabbedPane.getComponentAt(newIndex).requestFocusInWindow() }
|
||||
|
||||
}
|
||||
|
||||
// 选择变动
|
||||
@@ -177,6 +179,9 @@ class TerminalTabbed(
|
||||
// 新的获取到焦点
|
||||
tabs[tabbedPane.selectedIndex].onGrabFocus()
|
||||
|
||||
// 新的真正获取焦点
|
||||
tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow()
|
||||
|
||||
if (disposable) {
|
||||
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()
|
||||
|
||||
// 关闭
|
||||
@@ -264,7 +280,7 @@ class TerminalTabbed(
|
||||
}
|
||||
|
||||
|
||||
close.isEnabled = c !is WelcomePanel
|
||||
close.isEnabled = tab.canClose()
|
||||
rename.isEnabled = close.isEnabled
|
||||
clone.isEnabled = close.isEnabled
|
||||
openInNewWindow.isEnabled = close.isEnabled
|
||||
@@ -290,7 +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 title = (c.getClientProperty(titleProperty) ?: tab.getTitle()).toString()
|
||||
|
||||
@@ -301,16 +317,53 @@ class TerminalTabbed(
|
||||
StringUtils.EMPTY,
|
||||
index
|
||||
)
|
||||
c.putClientProperty(titleProperty, title)
|
||||
|
||||
// 设置标题
|
||||
c.putClientProperty(titleProperty, title)
|
||||
// 监听 icons 变化
|
||||
tab.addPropertyChangeListener(iconListener)
|
||||
|
||||
tabs.add(index, tab)
|
||||
tabbedPane.selectedIndex = index
|
||||
|
||||
if (selected) {
|
||||
tabbedPane.selectedIndex = index
|
||||
}
|
||||
|
||||
tabbedPane.setTabClosable(index, tab.canClose())
|
||||
|
||||
Disposer.register(this, tab)
|
||||
}
|
||||
|
||||
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
|
||||
if (!SFTPPtyTerminalTab.canSupports) {
|
||||
OptionPane.showMessageDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.tabbed.contextmenu.sftp-not-install"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var host = tab.host
|
||||
|
||||
if (host.protocol == Protocol.SSH) {
|
||||
val envs = tab.host.options.envs().toMutableMap()
|
||||
val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel()
|
||||
?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY
|
||||
|
||||
if (currentDir.isNotBlank()) {
|
||||
envs["CurrentDir"] = currentDir
|
||||
}
|
||||
|
||||
host = host.copy(
|
||||
protocol = Protocol.SFTPPty, updateDate = System.currentTimeMillis(),
|
||||
options = host.options.copy(env = envs.toPropertiesString())
|
||||
)
|
||||
}
|
||||
|
||||
openHostAction.actionPerformed(OpenHostActionEvent(this, host, evt))
|
||||
}
|
||||
|
||||
/**
|
||||
* 对着 ToolBar 右键
|
||||
*/
|
||||
@@ -399,12 +452,12 @@ class TerminalTabbed(
|
||||
override fun dispose() {
|
||||
}
|
||||
|
||||
override fun addTerminalTab(tab: TerminalTab) {
|
||||
addTab(tabs.size, tab)
|
||||
override fun addTerminalTab(tab: TerminalTab, selected: Boolean) {
|
||||
addTab(tabs.size, tab, selected)
|
||||
}
|
||||
|
||||
override fun addTerminalTab(index: Int, tab: TerminalTab) {
|
||||
addTab(index, tab)
|
||||
override fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean) {
|
||||
addTab(index, tab, selected)
|
||||
}
|
||||
|
||||
override fun getSelectedTerminalTab(): TerminalTab? {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package app.termora
|
||||
|
||||
interface TerminalTabbedManager {
|
||||
fun addTerminalTab(tab: TerminalTab)
|
||||
fun addTerminalTab(index: Int, tab: TerminalTab)
|
||||
fun addTerminalTab(tab: TerminalTab, selected: Boolean = true)
|
||||
fun addTerminalTab(index: Int, tab: TerminalTab, selected: Boolean = true)
|
||||
fun getSelectedTerminalTab(): TerminalTab?
|
||||
fun getTerminalTabs(): List<TerminalTab>
|
||||
fun setSelectedTerminalTab(tab: TerminalTab)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package app.termora
|
||||
|
||||
|
||||
import app.termora.actions.ActionManager
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.actions.DataProviders
|
||||
@@ -12,7 +11,6 @@ import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jetbrains.JBR
|
||||
import java.awt.Dimension
|
||||
import java.awt.Insets
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.*
|
||||
@@ -32,7 +30,6 @@ fun assertEventDispatchThread() {
|
||||
class TermoraFrame : JFrame(), DataProvider {
|
||||
|
||||
|
||||
private val actionManager get() = ActionManager.getInstance()
|
||||
private val id = UUID.randomUUID().toString()
|
||||
private val windowScope = ApplicationScope.forWindowScope(this)
|
||||
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
|
||||
@@ -42,7 +39,7 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val welcomePanel = WelcomePanel(windowScope)
|
||||
private val keyboardFocusManager by lazy { KeyboardFocusManager.getCurrentKeyboardFocusManager() }
|
||||
private val sftp get() = Database.getDatabase().sftp
|
||||
|
||||
|
||||
init {
|
||||
@@ -103,6 +100,13 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
minimumSize = Dimension(640, 400)
|
||||
terminalTabbed.addTerminalTab(welcomePanel)
|
||||
|
||||
// 下一次事件循环检测是否固定 SFTP
|
||||
SwingUtilities.invokeLater {
|
||||
if (sftp.pinTab) {
|
||||
terminalTabbed.addTerminalTab(SFTPTerminalTab(), false)
|
||||
}
|
||||
}
|
||||
|
||||
// macOS 要避开左边的控制栏
|
||||
if (SystemInfo.isMacOS) {
|
||||
val left = max(titleBar.leftInset.toInt(), 76)
|
||||
|
||||
@@ -19,6 +19,8 @@ class TermoraFrameManager {
|
||||
}
|
||||
}
|
||||
|
||||
private val frames = mutableListOf<TermoraFrame>()
|
||||
|
||||
fun createWindow(): TermoraFrame {
|
||||
val frame = TermoraFrame()
|
||||
registerCloseCallback(frame)
|
||||
@@ -26,16 +28,26 @@ class TermoraFrameManager {
|
||||
frame.defaultCloseOperation = DO_NOTHING_ON_CLOSE
|
||||
frame.setSize(1280, 800)
|
||||
frame.setLocationRelativeTo(null)
|
||||
frames.add(frame)
|
||||
return frame
|
||||
}
|
||||
|
||||
fun getWindows(): Array<TermoraFrame> {
|
||||
return frames.toTypedArray()
|
||||
}
|
||||
|
||||
|
||||
private fun registerCloseCallback(window: TermoraFrame) {
|
||||
window.addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
|
||||
// 删除
|
||||
frames.remove(window)
|
||||
|
||||
// dispose windowScope
|
||||
Disposer.dispose(ApplicationScope.forWindowScope(e.window))
|
||||
val windowScope = ApplicationScope.forWindowScope(e.window)
|
||||
Disposer.disposeChildren(windowScope, null)
|
||||
Disposer.dispose(windowScope)
|
||||
|
||||
val windowScopes = ApplicationScope.windowScopes()
|
||||
|
||||
@@ -68,7 +80,9 @@ class TermoraFrameManager {
|
||||
try {
|
||||
Disposer.getTree().assertIsEmpty(true)
|
||||
} catch (e: Exception) {
|
||||
log.error(e.message)
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
exitProcess(0)
|
||||
|
||||
155
src/main/kotlin/app/termora/TermoraRestarter.kt
Normal file
155
src/main/kotlin/app/termora/TermoraRestarter.kt
Normal file
@@ -0,0 +1,155 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.github.hstyi.restart4j.Restarter
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Component
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class TermoraRestarter {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TermoraRestarter::class.java)
|
||||
|
||||
fun getInstance(): TermoraRestarter {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(TermoraRestarter::class) { TermoraRestarter() }
|
||||
}
|
||||
|
||||
init {
|
||||
Restarter.setProcessHandler { ProcessHandle.current().pid().toInt() }
|
||||
Restarter.setExecCommandsHandler { commands ->
|
||||
val pb = ProcessBuilder(commands)
|
||||
if (SystemInfo.isLinux) {
|
||||
// 去掉链接库变量
|
||||
pb.environment().remove("LD_LIBRARY_PATH")
|
||||
}
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||
pb.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
pb.directory(Paths.get(System.getProperty("user.home")).toFile())
|
||||
pb.start()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val restarting = AtomicBoolean(false)
|
||||
private val isSupported get() = !restarting.get() && checkIsSupported()
|
||||
private val isLinuxAppImage by lazy { System.getenv("LinuxAppImage")?.toBoolean() == true }
|
||||
private val startupCommand by lazy { ProcessHandle.current().info().command().getOrNull() }
|
||||
private val macOSApplicationPath by lazy {
|
||||
StringUtils.removeEndIgnoreCase(
|
||||
Application.getAppPath(),
|
||||
"/Contents/MacOS/Termora"
|
||||
)
|
||||
}
|
||||
|
||||
private fun restart(commands: List<String>) {
|
||||
if (!isSupported) return
|
||||
if (!restarting.compareAndSet(false, true)) return
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
try {
|
||||
doRestart(commands)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划重启,如果当前进程支持重启,那么会询问用户是否重启。如果不支持重启,那么弹窗提示需要手动重启。
|
||||
*/
|
||||
fun scheduleRestart(owner: Component?, commands: List<String> = emptyList()) {
|
||||
|
||||
if (isSupported) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.restart.message"),
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
messageType = JOptionPane.QUESTION_MESSAGE,
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
options = arrayOf(
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
I18n.getString("termora.cancel")
|
||||
),
|
||||
initialValue = I18n.getString("termora.settings.restart.title")
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
restart(commands)
|
||||
}
|
||||
} else {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.restart.message"),
|
||||
I18n.getString("termora.settings.restart.title"),
|
||||
messageType = JOptionPane.INFORMATION_MESSAGE,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun doRestart(commands: List<String>) {
|
||||
|
||||
if (commands.isEmpty()) {
|
||||
if (SystemInfo.isMacOS) {
|
||||
Restarter.restart(arrayOf("open", "-n", macOSApplicationPath))
|
||||
} else if (SystemInfo.isWindows && startupCommand != null) {
|
||||
Restarter.restart(arrayOf(startupCommand))
|
||||
} else if (SystemInfo.isLinux) {
|
||||
if (isLinuxAppImage) {
|
||||
Restarter.restart(arrayOf(System.getenv("APPIMAGE")))
|
||||
} else if (startupCommand != null) {
|
||||
Restarter.restart(arrayOf(startupCommand))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Restarter.restart(commands.toTypedArray())
|
||||
}
|
||||
|
||||
for (window in TermoraFrameManager.getInstance().getWindows()) {
|
||||
window.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun checkIsSupported(): Boolean {
|
||||
val appPath = Application.getAppPath()
|
||||
if (appPath.isBlank() || Application.isUnknownVersion()) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Restart not supported")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (SystemInfo.isWindows && startupCommand == null) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Restart not supported , ProcessHandle#info#command is null.")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (SystemInfo.isLinux) {
|
||||
if (isLinuxAppImage) {
|
||||
val appImage = System.getenv("APPIMAGE") ?: StringUtils.EMPTY
|
||||
return appImage.isNotBlank() && FileUtils.getFile(appImage).exists()
|
||||
}
|
||||
return startupCommand != null
|
||||
}
|
||||
|
||||
if (SystemInfo.isMacOS) {
|
||||
return Application.getAppPath().isNotBlank()
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -109,6 +109,10 @@ class TermoraToolBar(
|
||||
|
||||
toolbar.add(Box.createHorizontalGlue())
|
||||
|
||||
if (SystemInfo.isLinux || SystemInfo.isWindows) {
|
||||
toolbar.add(Box.createHorizontalStrut(16))
|
||||
}
|
||||
|
||||
|
||||
// update btn
|
||||
val updateBtn = actionContainerFactory.createButton(actionManager.getAction(Actions.APP_UPDATE))
|
||||
|
||||
@@ -70,6 +70,9 @@ class UpdaterManager private constructor() {
|
||||
.build()
|
||||
val response = Application.httpClient.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error("Failed to fetch latest version, response was ${response.code}")
|
||||
}
|
||||
return LatestVersion.self
|
||||
}
|
||||
|
||||
@@ -151,8 +154,4 @@ class UpdaterManager private constructor() {
|
||||
fun ignore(version: String) {
|
||||
properties.putString("ignored.version.$version", "true")
|
||||
}
|
||||
|
||||
private fun doGetLatestVersion() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package app.termora
|
||||
|
||||
|
||||
import app.termora.actions.*
|
||||
import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhereProvider
|
||||
import app.termora.findeverywhere.FindEverywhereResult
|
||||
import app.termora.terminal.DataKey
|
||||
@@ -27,11 +26,15 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val rootPanel = JPanel(BorderLayout())
|
||||
private val searchTextField = FlatTextField()
|
||||
private val hostTree = HostTree()
|
||||
private val hostTree = NewHostTree()
|
||||
private val bannerPanel = BannerPanel()
|
||||
private val toggle = FlatButton()
|
||||
private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean()
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val hostTreeModel = hostTree.model as NewHostTreeModel
|
||||
private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) {
|
||||
searchTextField.text.isBlank()
|
||||
}
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -126,8 +129,6 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
})
|
||||
hostTree.showsRootHandles = true
|
||||
|
||||
Disposer.register(this, hostTree)
|
||||
|
||||
val scrollPane = JScrollPane(hostTree)
|
||||
scrollPane.verticalScrollBar.maximumSize = Dimension(0, 0)
|
||||
scrollPane.verticalScrollBar.preferredSize = Dimension(0, 0)
|
||||
@@ -138,6 +139,11 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
panel.add(scrollPane, BorderLayout.CENTER)
|
||||
panel.border = BorderFactory.createEmptyBorder(10, 0, 0, 0)
|
||||
|
||||
hostTree.model = filterableHostTreeModel
|
||||
TreeUtils.loadExpansionState(
|
||||
hostTree,
|
||||
properties.getString("Welcome.HostTree.state", StringUtils.EMPTY)
|
||||
)
|
||||
|
||||
return panel
|
||||
}
|
||||
@@ -163,37 +169,49 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
})
|
||||
|
||||
|
||||
FindEverywhereProvider.getFindEverywhereProviders(windowScope)
|
||||
.add(BasicFilterFindEverywhereProvider(object : FindEverywhereProvider {
|
||||
override fun find(pattern: String): List<FindEverywhereResult> {
|
||||
return TreeUtils.children(hostTree.model, hostTree.model.root)
|
||||
.filterIsInstance<Host>()
|
||||
.filter { it.protocol != Protocol.Folder }
|
||||
.map { HostFindEverywhereResult(it) }
|
||||
FindEverywhereProvider.getFindEverywhereProviders(windowScope).add(object : FindEverywhereProvider {
|
||||
override fun find(pattern: String): List<FindEverywhereResult> {
|
||||
var filter = hostTreeModel.root.getAllChildren()
|
||||
.map { it.host }
|
||||
.filter { it.protocol != Protocol.Folder }
|
||||
|
||||
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 I18n.getString("termora.find-everywhere.groups.open-new-hosts")
|
||||
}
|
||||
return filter.map { HostFindEverywhereResult(it) }
|
||||
}
|
||||
|
||||
override fun order(): Int {
|
||||
return Integer.MIN_VALUE + 2
|
||||
}
|
||||
}))
|
||||
override fun group(): String {
|
||||
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() {
|
||||
private var state = StringUtils.EMPTY
|
||||
override fun changedUpdate(e: DocumentEvent) {
|
||||
val text = searchTextField.text
|
||||
if (text.isBlank()) {
|
||||
hostTree.setModel(hostTree.model)
|
||||
TreeUtils.loadExpansionState(hostTree, state)
|
||||
state = String()
|
||||
} else {
|
||||
if (state.isBlank()) state = TreeUtils.saveExpansionState(hostTree)
|
||||
hostTree.setModel(hostTree.searchableModel)
|
||||
hostTree.searchableModel.search(text)
|
||||
TreeUtils.expandAll(hostTree)
|
||||
filterableHostTreeModel.refresh()
|
||||
if (text.isNotBlank()) {
|
||||
hostTree.expandAll()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -241,11 +259,13 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
hostTree.setModel(null)
|
||||
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) {
|
||||
ActionManager.getInstance()
|
||||
.getAction(OpenHostAction.OPEN_HOST)
|
||||
@@ -261,7 +281,18 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
return Icons.terminal
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
override fun getText(isSelected: Boolean): String {
|
||||
if (showMoreInfo) {
|
||||
val color = UIManager.getColor(if (isSelected) "textHighlightText" else "textInactiveText")
|
||||
val moreInfo = when (host.protocol) {
|
||||
Protocol.SSH -> "${host.username}@${host.host}"
|
||||
Protocol.Serial -> host.options.serialComm.port
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
if (moreInfo.isNotBlank()) {
|
||||
return "<html>${host.name} <font color=rgb(${color.red},${color.green},${color.blue})>${moreInfo}</font></html>"
|
||||
}
|
||||
}
|
||||
return host.name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() {
|
||||
addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction())
|
||||
|
||||
addAction(Actions.MULTIPLE, MultipleAction())
|
||||
addAction(Actions.APP_UPDATE, AppUpdateAction())
|
||||
addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance())
|
||||
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
|
||||
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
|
||||
addAction(Actions.SFTP, SFTPAction())
|
||||
addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction())
|
||||
addAction(Actions.MACRO, MacroAction())
|
||||
addAction(Actions.KEY_MANAGER, KeyManagerAction())
|
||||
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.Application.httpClient
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.sun.jna.platform.win32.Advapi32
|
||||
import com.sun.jna.platform.win32.WinError
|
||||
import com.sun.jna.platform.win32.WinNT
|
||||
import com.sun.jna.platform.win32.WinReg
|
||||
import io.github.g00fy2.versioncompare.Version
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import okhttp3.Request
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.JXEditorPane
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Dimension
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.io.File
|
||||
import java.net.ProxySelector
|
||||
import java.net.URI
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.JScrollPane
|
||||
@@ -18,11 +32,20 @@ import kotlin.concurrent.fixedRateTimer
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class AppUpdateAction : AnAction(
|
||||
class AppUpdateAction private constructor() : AnAction(
|
||||
StringUtils.EMPTY,
|
||||
Icons.ideUpdate
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(AppUpdateAction::class.java)
|
||||
private const val PKG_FILE_KEY = "pkgFile"
|
||||
|
||||
fun getInstance(): AppUpdateAction {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(AppUpdateAction::class) { AppUpdateAction() }
|
||||
}
|
||||
}
|
||||
|
||||
private val updaterManager get() = UpdaterManager.getInstance()
|
||||
|
||||
init {
|
||||
@@ -63,11 +86,75 @@ class AppUpdateAction : AnAction(
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
ActionManager.getInstance()
|
||||
.setEnabled(Actions.APP_UPDATE, true)
|
||||
try {
|
||||
downloadLatestPkg(latestVersion)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
withContext(Dispatchers.Swing) { isEnabled = true }
|
||||
|
||||
}
|
||||
|
||||
|
||||
private suspend fun downloadLatestPkg(latestVersion: UpdaterManager.LatestVersion) {
|
||||
if (SystemInfo.isLinux) return
|
||||
|
||||
super.putValue(PKG_FILE_KEY, null)
|
||||
val arch = if (SystemInfo.isAARCH64) "aarch64" else "x86-64"
|
||||
val osName = if (SystemInfo.isWindows) "windows" else "osx"
|
||||
val suffix = if (SystemInfo.isWindows) "exe" else "dmg"
|
||||
val filename = "termora-${latestVersion.version}-${osName}-${arch}.${suffix}"
|
||||
val asset = latestVersion.assets.find { it.name == filename } ?: return
|
||||
|
||||
val response = httpClient
|
||||
.newBuilder()
|
||||
.callTimeout(15, TimeUnit.MINUTES)
|
||||
.readTimeout(15, TimeUnit.MINUTES)
|
||||
.proxySelector(ProxySelector.getDefault())
|
||||
.build()
|
||||
.newCall(Request.Builder().url(asset.downloadUrl).build())
|
||||
.execute()
|
||||
if (!response.isSuccessful) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.warn("Failed to download latest version ${latestVersion.version}, response code ${response.code}")
|
||||
}
|
||||
IOUtils.closeQuietly(response)
|
||||
return
|
||||
}
|
||||
|
||||
val body = response.body
|
||||
val input = body?.byteStream()
|
||||
val file = FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}-${filename}")
|
||||
val output = file.outputStream()
|
||||
|
||||
val downloaded = runCatching { IOUtils.copy(input, output) }.isSuccess
|
||||
IOUtils.closeQuietly(input, output, body, response)
|
||||
|
||||
if (!downloaded) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error("Failed to download latest version to $filename")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Successfully downloaded latest version to $file")
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) { setLatestPkgFile(file) }
|
||||
|
||||
}
|
||||
|
||||
private fun setLatestPkgFile(file: File) {
|
||||
putValue(PKG_FILE_KEY, file)
|
||||
}
|
||||
|
||||
private fun getLatestPkgFile(): File? {
|
||||
return getValue(PKG_FILE_KEY) as? File
|
||||
}
|
||||
|
||||
private fun showUpdateDialog() {
|
||||
@@ -106,12 +193,59 @@ class AppUpdateAction : AnAction(
|
||||
if (option == JOptionPane.CANCEL_OPTION) {
|
||||
return
|
||||
} else if (option == JOptionPane.NO_OPTION) {
|
||||
ActionManager.getInstance().setEnabled(Actions.APP_UPDATE, false)
|
||||
updaterManager.ignore(updaterManager.lastVersion.version)
|
||||
isEnabled = false
|
||||
updaterManager.ignore(lastVersion.version)
|
||||
} else if (option == JOptionPane.YES_OPTION) {
|
||||
ActionManager.getInstance()
|
||||
.setEnabled(Actions.APP_UPDATE, false)
|
||||
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${lastVersion.version}"))
|
||||
updateSelf(lastVersion)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSelf(latestVersion: UpdaterManager.LatestVersion) {
|
||||
val file = getLatestPkgFile()
|
||||
if (SystemInfo.isLinux || file == null) {
|
||||
isEnabled = false
|
||||
Application.browse(URI.create("https://github.com/TermoraDev/termora/releases/tag/${latestVersion.version}"))
|
||||
return
|
||||
}
|
||||
|
||||
val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
|
||||
val commands = if (SystemInfo.isMacOS) listOf("open", "-n", file.absolutePath)
|
||||
// 如果安装过,那么直接静默安装和自动启动
|
||||
else if (isAppInstalled()) listOf(
|
||||
file.absolutePath,
|
||||
"/SILENT",
|
||||
"/AUTOSTART",
|
||||
"/NORESTART",
|
||||
"/FORCECLOSEAPPLICATIONS"
|
||||
)
|
||||
// 没有安装过 则打开安装向导
|
||||
else listOf(file.absolutePath)
|
||||
|
||||
println(commands)
|
||||
TermoraRestarter.getInstance().scheduleRestart(owner, commands)
|
||||
|
||||
}
|
||||
|
||||
private fun isAppInstalled(): Boolean {
|
||||
val keyPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${Application.getName()}_is1"
|
||||
val phkKey = WinReg.HKEYByReference()
|
||||
|
||||
// 尝试打开注册表键
|
||||
val result = Advapi32.INSTANCE.RegOpenKeyEx(
|
||||
WinReg.HKEY_LOCAL_MACHINE,
|
||||
keyPath,
|
||||
0,
|
||||
WinNT.KEY_READ,
|
||||
phkKey
|
||||
)
|
||||
|
||||
if (result == WinError.ERROR_SUCCESS) {
|
||||
// 键存在,关闭句柄
|
||||
Advapi32.INSTANCE.RegCloseKey(phkKey.getValue())
|
||||
return true
|
||||
} else {
|
||||
// 键不存在或无权限
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,6 @@ object DataProviders {
|
||||
|
||||
|
||||
object Welcome {
|
||||
val HostTree = DataKey(app.termora.HostTree::class)
|
||||
val HostTree = DataKey(app.termora.NewHostTree::class)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.Host
|
||||
import app.termora.HostDialog
|
||||
import app.termora.HostManager
|
||||
import app.termora.Protocol
|
||||
import app.termora.*
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class NewHostAction : AnAction() {
|
||||
@@ -20,27 +17,27 @@ class NewHostAction : AnAction() {
|
||||
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val tree = evt.getData(DataProviders.Welcome.HostTree) ?: return
|
||||
val model = tree.model
|
||||
var lastHost = tree.lastSelectedPathComponent ?: model.root
|
||||
if (lastHost !is Host) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lastHost.protocol != Protocol.Folder) {
|
||||
val p = model.getParent(lastHost) ?: return
|
||||
lastHost = p
|
||||
var lastNode = (tree.lastSelectedPathComponent ?: tree.model.root) as? HostTreeNode ?: return
|
||||
if (lastNode.host.protocol != Protocol.Folder) {
|
||||
lastNode = lastNode.parent ?: return
|
||||
}
|
||||
|
||||
val lastHost = lastNode.host
|
||||
val dialog = HostDialog(evt.window)
|
||||
dialog.setLocationRelativeTo(evt.window)
|
||||
dialog.isVisible = true
|
||||
val host = (dialog.host ?: return).copy(parentId = lastHost.id)
|
||||
|
||||
hostManager.addHost(host)
|
||||
val newNode = HostTreeNode(host)
|
||||
|
||||
tree.expandNode(lastHost)
|
||||
val model = if (tree.model is FilterableHostTreeModel) (tree.model as FilterableHostTreeModel).getModel()
|
||||
else tree.model
|
||||
|
||||
tree.selectionPath = TreePath(model.getPathToRoot(host))
|
||||
if (model is NewHostTreeModel) {
|
||||
model.insertNodeInto(newNode, lastNode, lastNode.childCount)
|
||||
tree.selectionPath = TreePath(model.getPathToRoot(newNode))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,17 @@ class OpenHostAction : AnAction() {
|
||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: 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) {
|
||||
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
|
||||
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
|
||||
Protocol.SFTPPty -> SFTPPtyTerminalTab(windowScope, evt.host)
|
||||
else -> LocalTerminalTab(windowScope, evt.host)
|
||||
}
|
||||
|
||||
|
||||
30
src/main/kotlin/app/termora/actions/SFTPCommandAction.kt
Normal file
30
src/main/kotlin/app/termora/actions/SFTPCommandAction.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.I18n
|
||||
|
||||
class TerminalClearScreenAction : AnAction() {
|
||||
companion object {
|
||||
const val CLEAR_SCREEN = "ClearScreen"
|
||||
}
|
||||
|
||||
init {
|
||||
putValue(SHORT_DESCRIPTION, "Clear Terminal Buffer")
|
||||
putValue(SHORT_DESCRIPTION, I18n.getString("termora.actions.clear-screen"))
|
||||
putValue(ACTION_COMMAND_KEY, CLEAR_SCREEN)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ interface FindEverywhereResult : ActionListener {
|
||||
|
||||
fun getIcon(isSelected: Boolean): Icon = Icons.empty
|
||||
|
||||
fun getText(isSelected: Boolean) = toString()
|
||||
|
||||
}
|
||||
@@ -94,16 +94,16 @@ class FindEverywhereXList(private val model: DefaultListModel<FindEverywhereResu
|
||||
label.font = font.deriveFont(font.size - 2f)
|
||||
val box = Box.createHorizontalBox()
|
||||
box.add(label)
|
||||
/*box.add(object : JComponent() {
|
||||
override fun paintComponent(g: Graphics) {
|
||||
g.color = DynamicColor.BorderColor
|
||||
g.drawLine(10, height / 2, width, height / 2)
|
||||
}
|
||||
})*/
|
||||
return box
|
||||
}
|
||||
|
||||
val c = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
|
||||
val c = super.getListCellRendererComponent(
|
||||
list,
|
||||
if (value is FindEverywhereResult) value.getText(isSelected) else value,
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
if (isSelected) {
|
||||
background = UIManager.getColor("List.selectionBackground")
|
||||
foreground = UIManager.getColor("List.selectionForeground")
|
||||
|
||||
@@ -76,6 +76,12 @@ class KeymapImpl(private val menuShortcutKeyMaskEx: Int) : Keymap("Keymap", null
|
||||
KeyShortcut(KeyStroke.getKeyStroke(KeyEvent.VK_R, menuShortcutKeyMaskEx or InputEvent.SHIFT_DOWN_MASK))
|
||||
)
|
||||
|
||||
// Command + Shift + P
|
||||
addShortcut(
|
||||
SFTPCommandAction.SFTP_COMMAND,
|
||||
KeyShortcut(KeyStroke.getKeyStroke(KeyEvent.VK_P, menuShortcutKeyMaskEx or InputEvent.SHIFT_DOWN_MASK))
|
||||
)
|
||||
|
||||
|
||||
// switch map
|
||||
for (i in KeyEvent.VK_1..KeyEvent.VK_9) {
|
||||
|
||||
@@ -2,19 +2,18 @@ package app.termora.keymap
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.findeverywhere.FindEverywhereAction
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Container
|
||||
import java.awt.KeyEventDispatcher
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JDialog
|
||||
import javax.swing.JPopupMenu
|
||||
import javax.swing.KeyStroke
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class KeymapManager private constructor() : Disposable {
|
||||
|
||||
@@ -28,7 +27,6 @@ class KeymapManager private constructor() : Disposable {
|
||||
}
|
||||
|
||||
private val keymapKeyEventDispatcher = KeymapKeyEventDispatcher()
|
||||
private val myKeyEventDispatcher = MyKeyEventDispatcher()
|
||||
private val database get() = Database.getDatabase()
|
||||
private val properties get() = database.properties
|
||||
private val keymaps = linkedMapOf<String, Keymap>()
|
||||
@@ -37,7 +35,6 @@ class KeymapManager private constructor() : Disposable {
|
||||
|
||||
init {
|
||||
keyboardFocusManager.addKeyEventDispatcher(keymapKeyEventDispatcher)
|
||||
keyboardFocusManager.addKeyEventDispatcher(myKeyEventDispatcher)
|
||||
|
||||
try {
|
||||
for (keymap in database.getKeymaps()) {
|
||||
@@ -127,6 +124,17 @@ class KeymapManager private constructor() : Disposable {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果当前有 Popup ,那么不派发事件
|
||||
val c = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
|
||||
if (c is Container) {
|
||||
val popups: List<JPopupMenu> = SwingUtils.getDescendantsOfType(
|
||||
JPopupMenu::class.java,
|
||||
c, true
|
||||
)
|
||||
if (popups.isNotEmpty()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val evt = AnActionEvent(e.source, StringUtils.EMPTY, e)
|
||||
for (actionId in actionIds) {
|
||||
@@ -145,54 +153,7 @@ class KeymapManager private constructor() : Disposable {
|
||||
|
||||
}
|
||||
|
||||
@Deprecated(message = "Deprecated")
|
||||
private inner class MyKeyEventDispatcher : KeyEventDispatcher {
|
||||
// double shift
|
||||
private var lastTime = -1L
|
||||
private val findEverywhereAction
|
||||
get() = ActionManager.getInstance().getAction(FindEverywhereAction.FIND_EVERYWHERE)
|
||||
private val deprecatedKey by lazy { "${Application.getVersion()}.FindEverywhereActionDeprecated" }
|
||||
|
||||
|
||||
override fun dispatchKeyEvent(e: KeyEvent): Boolean {
|
||||
if (e.keyCode == KeyEvent.VK_SHIFT && e.id == KeyEvent.KEY_PRESSED) {
|
||||
val evt = AnActionEvent(e.source, StringUtils.EMPTY, e)
|
||||
val owner = evt.getData(DataProviders.TermoraFrame) ?: return false
|
||||
if (keyboardFocusManager.focusedWindow == owner) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - 250 < lastTime) {
|
||||
if (!properties.getString(deprecatedKey, "false").toBoolean()) {
|
||||
properties.putString(deprecatedKey, "true")
|
||||
val shortcut = getActiveKeymap().getShortcut(FindEverywhereAction.FIND_EVERYWHERE)
|
||||
.firstOrNull()
|
||||
if (shortcut == null) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.find-everywhere.double-shift-deprecated")
|
||||
)
|
||||
} else {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.find-everywhere.double-shift-deprecated-instead", shortcut)
|
||||
)
|
||||
}
|
||||
}
|
||||
SwingUtilities.invokeLater { findEverywhereAction?.actionPerformed(evt) }
|
||||
}
|
||||
lastTime = now
|
||||
}
|
||||
} else if (e.keyCode != KeyEvent.VK_SHIFT) { // 如果不是 Shift 键,那么就阻断了连续性,重置时间
|
||||
lastTime = -1
|
||||
}
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun dispose() {
|
||||
keyboardFocusManager.removeKeyEventDispatcher(keymapKeyEventDispatcher)
|
||||
keyboardFocusManager.removeKeyEventDispatcher(myKeyEventDispatcher)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ class KeymapTableModel : DefaultTableModel() {
|
||||
FindEverywhereAction.FIND_EVERYWHERE,
|
||||
NewWindowAction.NEW_WINDOW,
|
||||
TabReconnectAction.RECONNECT_TAB,
|
||||
TerminalClearScreenAction.CLEAR_SCREEN,
|
||||
SFTPCommandAction.SFTP_COMMAND,
|
||||
SwitchTabAction.SWITCH_TAB,
|
||||
)) {
|
||||
val action = actionManager.getAction(id) ?: continue
|
||||
|
||||
@@ -211,9 +211,9 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
}
|
||||
|
||||
val owner = SwingUtilities.getWindowAncestor(this) ?: return
|
||||
val hostTreeDialog = HostTreeDialog(owner) {
|
||||
it.protocol == Protocol.SSH
|
||||
}
|
||||
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()) {
|
||||
|
||||
@@ -42,6 +42,7 @@ class SSHCopyIdDialog(
|
||||
}
|
||||
private val terminalPanel by lazy {
|
||||
terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate())
|
||||
.apply { enableFloatingToolbar = false }
|
||||
}
|
||||
private val coroutineScope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
|
||||
@@ -152,7 +153,7 @@ class SSHCopyIdDialog(
|
||||
val baos = ByteArrayOutputStream()
|
||||
channel.out = baos
|
||||
if (channel.open().verify(timeout).await(timeout)) {
|
||||
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout);
|
||||
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout)
|
||||
}
|
||||
if (channel.exitStatus != 0) {
|
||||
throw IllegalStateException("Server response: ${channel.exitStatus}")
|
||||
|
||||
@@ -13,9 +13,11 @@ data class CursorStore(
|
||||
*/
|
||||
val textStyle: TextStyle,
|
||||
/**
|
||||
* 如果为 null 表示没有设置
|
||||
*
|
||||
* @see [DataKey.AutoWrapMode]
|
||||
*/
|
||||
val autoWarpMode: Boolean,
|
||||
val autoWarpMode: Boolean?,
|
||||
/**
|
||||
* @see [DataKey.OriginMode]
|
||||
*/
|
||||
|
||||
@@ -22,7 +22,9 @@ object CursorStoreStores {
|
||||
|
||||
terminalModel.setData(DataKey.OriginMode, cursorStore.originMode)
|
||||
terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle)
|
||||
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
|
||||
if (cursorStore.autoWarpMode != null) {
|
||||
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
|
||||
}
|
||||
terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet)
|
||||
|
||||
val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion()
|
||||
@@ -52,7 +54,7 @@ object CursorStoreStores {
|
||||
val cursorStore = CursorStore(
|
||||
position = terminal.getCursorModel().getPosition(),
|
||||
textStyle = terminalModel.getData(DataKey.TextStyle),
|
||||
autoWarpMode = terminalModel.getData(DataKey.AutoWrapMode, false),
|
||||
autoWarpMode = if (terminalModel.hasData(DataKey.AutoWrapMode)) terminalModel.getData(DataKey.AutoWrapMode) else null,
|
||||
originMode = terminalModel.isOriginMode(),
|
||||
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
|
||||
)
|
||||
|
||||
@@ -74,6 +74,13 @@ class DataKey<T : Any>(val clazz: KClass<T>) {
|
||||
*/
|
||||
val Workdir = DataKey(String::class)
|
||||
|
||||
/**
|
||||
* OSC 1337 CurrentDir
|
||||
*
|
||||
* https://iterm2.com/documentation-escape-codes.html
|
||||
*/
|
||||
val CurrentDir = DataKey(String::class)
|
||||
|
||||
/**
|
||||
* true: alternate keypad.
|
||||
* false: Normal Keypad (DECKPNM)
|
||||
|
||||
@@ -4,6 +4,8 @@ import org.apache.commons.codec.binary.Base64
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.io.StringReader
|
||||
import java.util.*
|
||||
|
||||
class OperatingSystemCommandProcessor(terminal: Terminal, reader: TerminalReader) :
|
||||
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
|
||||
// 10: foreground color
|
||||
11, 10 -> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
119
src/main/kotlin/app/termora/terminal/panel/TerminalBlink.kt
Normal file
119
src/main/kotlin/app/termora/terminal/panel/TerminalBlink.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import kotlin.time.Duration
|
||||
class TerminalDisplay(
|
||||
private val terminalPanel: TerminalPanel,
|
||||
private val terminal: Terminal,
|
||||
private val terminalBlink: TerminalBlink
|
||||
) : JComponent() {
|
||||
|
||||
companion object {
|
||||
@@ -132,31 +133,30 @@ class TerminalDisplay(
|
||||
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 position = terminal.getCursorModel().getPosition()
|
||||
val row = position.y
|
||||
val style = if (inputMethodData.isNoTyping)
|
||||
terminal.getTerminalModel().getData(DataKey.CursorStyle) else CursorStyle.Bar
|
||||
val hasFocus = terminal.getTerminalModel().getData(TerminalPanel.Focused, false)
|
||||
|
||||
// background
|
||||
g.color = Color(colorPalette.getColor(TerminalColor.Cursor.BACKGROUND))
|
||||
|
||||
if (style == CursorStyle.Block) {
|
||||
if (terminalPanel.hasFocus()) {
|
||||
g.fillRect(xOffset, (row - 1) * lineHeight, width, lineHeight)
|
||||
if (hasFocus) {
|
||||
g.fillRect(xOffset, (y - 1) * lineHeight, width, lineHeight)
|
||||
} else {
|
||||
g.drawRect(xOffset, (row - 1) * lineHeight, width, lineHeight)
|
||||
g.drawRect(xOffset, (y - 1) * lineHeight, width, lineHeight)
|
||||
}
|
||||
} else if (style == CursorStyle.Underline) {
|
||||
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) {
|
||||
if (inputMethodData.isTyping) {
|
||||
val w = ceil(width / 3.5).toInt()
|
||||
g.fillRect(xOffset, (row - 1) * lineHeight, w, lineHeight)
|
||||
g.fillRect(xOffset, (y - 1) * lineHeight, w, lineHeight)
|
||||
} 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) {
|
||||
val reverseVideo = terminal.getTerminalModel().getData(DataKey.ReverseVideo, false)
|
||||
val rows = terminal.getTerminalModel().getRows()
|
||||
val cols = terminal.getTerminalModel().getCols()
|
||||
val buffer = terminal.getDocument().getCurrentTerminalLineBuffer()
|
||||
val terminalModel = terminal.getTerminalModel()
|
||||
val reverseVideo = terminalModel.getData(DataKey.ReverseVideo, false)
|
||||
val rows = terminalModel.getRows()
|
||||
val cols = terminalModel.getCols()
|
||||
val triple = Triple(Char.Space.toString(), TextStyle.Default, 1)
|
||||
val cursorPosition = terminal.getCursorModel().getPosition()
|
||||
val averageCharWidth = getAverageCharWidth()
|
||||
val maxVerticalScrollOffset = terminal.getScrollingModel().getMaxVerticalScrollOffset()
|
||||
val verticalScrollOffset = terminal.getScrollingModel().getVerticalScrollOffset()
|
||||
val selectionModel = terminal.getSelectionModel()
|
||||
val cursorStyle = terminal.getTerminalModel().getData(DataKey.CursorStyle)
|
||||
val showCursor = terminal.getTerminalModel().getData(DataKey.ShowCursor)
|
||||
val cursorStyle = terminalModel.getData(DataKey.CursorStyle)
|
||||
val showCursor = terminalModel.getData(DataKey.ShowCursor)
|
||||
val markupModel = terminal.getMarkupModel()
|
||||
val lineHeight = getLineHeight()
|
||||
val blink = terminalBlink.blink
|
||||
val cursorBlink = terminalBlink.cursorBlink
|
||||
val hasFocus = terminalModel.getData(TerminalPanel.Focused, false)
|
||||
|
||||
|
||||
for (i in 1..rows) {
|
||||
@@ -242,7 +246,7 @@ class TerminalDisplay(
|
||||
while (j <= cols) {
|
||||
val position = Position(row + 1, j)
|
||||
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
|
||||
var textStyle = style
|
||||
@@ -271,6 +275,13 @@ class TerminalDisplay(
|
||||
background = colorPalette.getColor(TerminalColor.Basic.SELECTION_BACKGROUND)
|
||||
}
|
||||
|
||||
// 如果启用了闪烁
|
||||
if (textStyle.blink) {
|
||||
if (!blink) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 设置字体
|
||||
g.font = getDisplayFont(text, textStyle)
|
||||
val charWidth = min(
|
||||
@@ -312,12 +323,15 @@ class TerminalDisplay(
|
||||
|
||||
// 渲染光标
|
||||
if (caret) {
|
||||
drawCursor(g, xOffset, charWidth)
|
||||
// 如果是获取焦点状态,那么颜色互换
|
||||
if (terminalPanel.hasFocus() && cursorStyle == CursorStyle.Block && inputMethodData.isNoTyping) {
|
||||
g.color = Color(colorPalette.getColor(TerminalColor.Basic.BACKGROUND))
|
||||
} else {
|
||||
g.color = Color(foreground)
|
||||
// 这几种情况光标才会渲染:输入中、闪烁中、没有焦点
|
||||
if (inputMethodData.isTyping || cursorBlink || !hasFocus) {
|
||||
drawCursor(g, i, xOffset, charWidth)
|
||||
// 如果是获取焦点状态,那么颜色互换
|
||||
if (hasFocus && cursorStyle == CursorStyle.Block && inputMethodData.isNoTyping) {
|
||||
g.color = Color(colorPalette.getColor(TerminalColor.Basic.BACKGROUND))
|
||||
} else {
|
||||
g.color = Color(foreground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package app.termora.terminal.panel
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.Disposer
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.actions.DataProviders
|
||||
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 org.apache.commons.lang3.ArrayUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import java.awt.*
|
||||
@@ -31,18 +35,33 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnector) :
|
||||
JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
JPanel(BorderLayout()), DataProvider, Disposable, VisualWindowManager {
|
||||
|
||||
companion object {
|
||||
val Debug = DataKey(Boolean::class)
|
||||
val Finding = DataKey(Boolean::class)
|
||||
val Focused = DataKey(Boolean::class)
|
||||
val SelectCopy = DataKey(Boolean::class)
|
||||
}
|
||||
|
||||
private val terminalBlink = TerminalBlink(terminal)
|
||||
private val terminalFindPanel = TerminalFindPanel(this, terminal)
|
||||
private val terminalDisplay = TerminalDisplay(this, terminal)
|
||||
val scrollBar = TerminalScrollBar(this@TerminalPanel, terminalFindPanel, terminal)
|
||||
private val floatingToolbar = FloatingToolbarPanel()
|
||||
private val terminalDisplay = TerminalDisplay(this, terminal, terminalBlink)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@@ -113,10 +132,11 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
scrollBar.blockIncrement = 1
|
||||
background = Color.black
|
||||
|
||||
|
||||
val layeredPane = TerminalLayeredPane()
|
||||
layeredPane.add(terminalDisplay, JLayeredPane.DEFAULT_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(scrollBar, BorderLayout.EAST)
|
||||
|
||||
@@ -127,6 +147,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
dataProviderSupport.addData(DataProviders.TerminalPanel, this)
|
||||
dataProviderSupport.addData(DataProviders.Terminal, terminal)
|
||||
dataProviderSupport.addData(DataProviders.PtyConnector, ptyConnector)
|
||||
dataProviderSupport.addData(FloatingToolbarPanel.FloatingToolbar, floatingToolbar)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
@@ -135,10 +156,12 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
|
||||
this.addFocusListener(object : FocusAdapter() {
|
||||
override fun focusLost(e: FocusEvent) {
|
||||
terminal.getTerminalModel().setData(Focused, false)
|
||||
repaintImmediate()
|
||||
}
|
||||
|
||||
override fun focusGained(e: FocusEvent) {
|
||||
terminal.getTerminalModel().setData(Focused, true)
|
||||
repaintImmediate()
|
||||
}
|
||||
})
|
||||
@@ -158,6 +181,11 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
this.addMouseListener(trackingAdapter)
|
||||
this.addMouseWheelListener(trackingAdapter)
|
||||
|
||||
// 悬浮工具栏
|
||||
val floatingToolBarAdapter = TerminalPanelMouseFloatingToolBarAdapter(this, terminalDisplay)
|
||||
this.addMouseMotionListener(floatingToolBarAdapter)
|
||||
this.addMouseListener(floatingToolBarAdapter)
|
||||
|
||||
// 滚动相关
|
||||
this.addMouseWheelListener(object : MouseWheelListener {
|
||||
override fun mouseWheelMoved(e: MouseWheelEvent) {
|
||||
@@ -197,6 +225,8 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
// 开启拖拽
|
||||
enableDropTarget()
|
||||
|
||||
// 监听悬浮工具栏变化,然后重新渲染
|
||||
floatingToolbar.addPropertyChangeListener { repaintImmediate() }
|
||||
|
||||
}
|
||||
|
||||
@@ -373,6 +403,10 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
Disposer.dispose(terminalBlink)
|
||||
Disposer.dispose(floatingToolbar)
|
||||
}
|
||||
|
||||
fun getAverageCharWidth(): Int {
|
||||
return terminalDisplay.getAverageCharWidth()
|
||||
@@ -450,6 +484,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
synchronized(treeLock) {
|
||||
val w = width
|
||||
val h = height
|
||||
val findPanelHeight = max(terminalFindPanel.preferredSize.height, terminalFindPanel.height)
|
||||
for (c in components) {
|
||||
when (c) {
|
||||
terminalDisplay -> {
|
||||
@@ -467,7 +502,36 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
w - width,
|
||||
0,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -479,4 +543,41 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -42,6 +42,8 @@ class TerminalPanelMouseSelectionAdapter(private val terminalPanel: TerminalPane
|
||||
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
|
||||
terminalPanel.requestFocusInWindow()
|
||||
|
||||
if (isMouseTracking) {
|
||||
return
|
||||
}
|
||||
@@ -77,7 +79,6 @@ class TerminalPanelMouseSelectionAdapter(private val terminalPanel: TerminalPane
|
||||
mousePressedPoint.y = e.y
|
||||
}
|
||||
|
||||
terminalPanel.requestFocusInWindow()
|
||||
|
||||
// 如果只有 Shift 键按下,那么应该追加选中
|
||||
if (selectionModel.hasSelection() && SwingUtilities.isLeftMouseButton(e) && e.modifiersEx == 1088) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.termora.transport
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.SettingsAction
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||
@@ -12,6 +13,7 @@ import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.file.PathUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
@@ -19,6 +21,7 @@ import org.apache.sshd.sftp.client.SftpClient
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.apache.sshd.sftp.client.fs.SftpPath
|
||||
import org.jdesktop.swingx.JXBusyLabel
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
@@ -32,11 +35,16 @@ import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.io.File
|
||||
import java.nio.file.*
|
||||
import java.text.MessageFormat
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
import javax.swing.*
|
||||
import javax.swing.table.DefaultTableCellRenderer
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.getLastModifiedTime
|
||||
import kotlin.io.path.isDirectory
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
/**
|
||||
@@ -44,9 +52,8 @@ import kotlin.io.path.isDirectory
|
||||
*/
|
||||
class FileSystemPanel(
|
||||
private val fileSystem: FileSystem,
|
||||
private val transportManager: TransportManager,
|
||||
private val host: Host
|
||||
) : JPanel(BorderLayout()), Disposable, FileSystemTransportListener.Provider {
|
||||
) : JPanel(BorderLayout()), Disposable {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(FileSystemPanel::class.java)
|
||||
@@ -64,6 +71,14 @@ class FileSystemPanel(
|
||||
private val showHiddenFilesBtn = JButton(Icons.eyeClose)
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
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
|
||||
|
||||
@@ -342,6 +357,9 @@ class FileSystemPanel(
|
||||
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
private fun copyLocalFileToFileSystem(files: List<File>) {
|
||||
val event = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
|
||||
@@ -425,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() {
|
||||
val row = table.selectedRow
|
||||
if (row < 0) return
|
||||
@@ -460,6 +470,7 @@ class FileSystemPanel(
|
||||
|
||||
|
||||
private fun showContextMenu(rows: IntArray, event: MouseEvent) {
|
||||
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
|
||||
val popupMenu = FlatPopupMenu()
|
||||
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
|
||||
|
||||
@@ -477,11 +488,22 @@ class FileSystemPanel(
|
||||
// 传输
|
||||
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
|
||||
transfer.addActionListener {
|
||||
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
|
||||
if (paths.isNotEmpty()) {
|
||||
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()
|
||||
|
||||
// 复制路径
|
||||
@@ -574,6 +596,127 @@ class FileSystemPanel(
|
||||
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)
|
||||
private fun renamePath(path: Path) {
|
||||
@@ -789,17 +932,31 @@ class FileSystemPanel(
|
||||
|
||||
private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) {
|
||||
if (paths.isEmpty()) return
|
||||
|
||||
val listeners = listenerList.getListeners(FileSystemTransportListener::class.java)
|
||||
if (listeners.isEmpty()) return
|
||||
|
||||
val transportPanel = evt.getData(TransportDataProviders.TransportPanel) ?: return
|
||||
val leftFileSystemPanel = evt.getData(TransportDataProviders.LeftFileSystemPanel) ?: return
|
||||
val rightFileSystemPanel = evt.getData(TransportDataProviders.RightFileSystemPanel) ?: return
|
||||
val sourceFileSystemPanel = this
|
||||
val targetFileSystemPanel = if (this == leftFileSystemPanel) rightFileSystemPanel else leftFileSystemPanel
|
||||
|
||||
// 收集数据
|
||||
for (e in paths) {
|
||||
|
||||
if (!e.isDirectory) {
|
||||
val job = TransportJob(
|
||||
fileSystemPanel = this,
|
||||
workdir = workdir,
|
||||
isDirectory = false,
|
||||
path = e.path,
|
||||
)
|
||||
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
|
||||
}
|
||||
@@ -811,12 +968,26 @@ class FileSystemPanel(
|
||||
val isDirectory = if (path.attributes != null)
|
||||
path.attributes.isDirectory else path.isDirectory()
|
||||
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 {
|
||||
val isDirectory = path.isDirectory()
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package app.termora.transport
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.Component
|
||||
import java.awt.Point
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -13,9 +13,8 @@ import kotlin.math.max
|
||||
class FileSystemTabbed(
|
||||
private val transportManager: TransportManager,
|
||||
private val isLeft: Boolean = false
|
||||
) : FlatTabbedPane(), FileSystemTransportListener.Provider, Disposable {
|
||||
) : FlatTabbedPane(), Disposable {
|
||||
private val addBtn = JButton(Icons.add)
|
||||
private val listeners = mutableListOf<FileSystemTransportListener>()
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -36,23 +35,20 @@ class FileSystemTabbed(
|
||||
trailingComponent = toolbar
|
||||
|
||||
if (isLeft) {
|
||||
addFileSystemTransportProvider(
|
||||
I18n.getString("termora.transport.local"),
|
||||
FileSystemPanel(
|
||||
addTab(
|
||||
I18n.getString("termora.transport.local"), FileSystemPanel(
|
||||
FileSystems.getDefault(),
|
||||
transportManager,
|
||||
host = Host(
|
||||
id = "local",
|
||||
name = I18n.getString("termora.transport.local"),
|
||||
protocol = Protocol.Local,
|
||||
)
|
||||
).apply { reload() }
|
||||
)
|
||||
).apply { reload() })
|
||||
setTabClosable(0, false)
|
||||
} else {
|
||||
addFileSystemTransportProvider(
|
||||
addTab(
|
||||
I18n.getString("termora.transport.sftp.select-host"),
|
||||
SftpFileSystemPanel(transportManager)
|
||||
SftpFileSystemPanel()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,17 +57,18 @@ class FileSystemTabbed(
|
||||
|
||||
private fun initEvents() {
|
||||
addBtn.addActionListener {
|
||||
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this))
|
||||
|
||||
val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(this))
|
||||
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)
|
||||
)
|
||||
dialog.setFilter { it.host.protocol == Protocol.SSH }
|
||||
dialog.setTreeName("FileSystemTabbed.Tree")
|
||||
dialog.isVisible = true
|
||||
|
||||
for (host in dialog.hosts) {
|
||||
val panel = SftpFileSystemPanel(transportManager, host)
|
||||
addFileSystemTransportProvider(host.name, panel)
|
||||
val panel = SftpFileSystemPanel(host)
|
||||
addTab(host.name, panel)
|
||||
panel.connect()
|
||||
}
|
||||
|
||||
@@ -120,9 +117,9 @@ class FileSystemTabbed(
|
||||
|
||||
if (tabCount == 0) {
|
||||
if (!isLeft) {
|
||||
addFileSystemTransportProvider(
|
||||
addTab(
|
||||
I18n.getString("termora.transport.sftp.select-host"),
|
||||
SftpFileSystemPanel(transportManager)
|
||||
SftpFileSystemPanel()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -130,39 +127,31 @@ class FileSystemTabbed(
|
||||
|
||||
}
|
||||
|
||||
fun addFileSystemTransportProvider(title: String, provider: FileSystemTransportListener.Provider) {
|
||||
if (provider !is JComponent) {
|
||||
throw IllegalArgumentException("Provider is not an JComponent")
|
||||
}
|
||||
override fun addTab(title: String, component: Component) {
|
||||
super.addTab(title, component)
|
||||
|
||||
provider.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
|
||||
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
|
||||
}
|
||||
})
|
||||
selectedIndex = tabCount - 1
|
||||
|
||||
// 修改 Tab名称
|
||||
provider.addPropertyChangeListener("TabName") { e ->
|
||||
SwingUtilities.invokeLater {
|
||||
val name = StringUtils.defaultIfEmpty(
|
||||
e.newValue.toString(),
|
||||
I18n.getString("termora.transport.sftp.select-host")
|
||||
)
|
||||
for (i in 0 until tabCount) {
|
||||
if (getComponentAt(i) == provider) {
|
||||
setTitleAt(i, name)
|
||||
break
|
||||
if (component is SftpFileSystemPanel) {
|
||||
component.addPropertyChangeListener("TabName") { e ->
|
||||
SwingUtilities.invokeLater {
|
||||
val name = StringUtils.defaultIfEmpty(
|
||||
e.newValue.toString(),
|
||||
I18n.getString("termora.transport.sftp.select-host")
|
||||
)
|
||||
for (i in 0 until tabCount) {
|
||||
if (getComponentAt(i) == component) {
|
||||
setTitleAt(i, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addTab(title, provider)
|
||||
|
||||
if (tabCount > 0)
|
||||
selectedIndex = tabCount - 1
|
||||
}
|
||||
|
||||
|
||||
fun getSelectedFileSystemPanel(): FileSystemPanel? {
|
||||
return getFileSystemPanel(selectedIndex)
|
||||
}
|
||||
@@ -184,14 +173,6 @@ class FileSystemTabbed(
|
||||
return null
|
||||
}
|
||||
|
||||
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
while (tabCount > 0) {
|
||||
val c = getComponentAt(0)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Icons
|
||||
import app.termora.SFTPTerminalTab
|
||||
import app.termora.*
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
@@ -9,15 +8,73 @@ import app.termora.actions.DataProviders
|
||||
class SFTPAction : AnAction("SFTP", Icons.folder) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
|
||||
val selectedTerminalTab = terminalTabbedManager.getSelectedTerminalTab()
|
||||
val host = if (selectedTerminalTab is SSHTerminalTab || selectedTerminalTab is SFTPPtyTerminalTab)
|
||||
selectedTerminalTab.host else null
|
||||
val tab = openOrCreateSFTPTerminalTab(evt) ?: return
|
||||
|
||||
if (host != null) {
|
||||
connectHost(host.copy(protocol = Protocol.SSH, updateDate = System.currentTimeMillis()), tab)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开一个已经存在或者创建一个 SFTP Tab
|
||||
*
|
||||
* @return null 表示当前条件下无法创建
|
||||
*/
|
||||
fun openOrCreateSFTPTerminalTab(evt: AnActionEvent, selected: Boolean = true): SFTPTerminalTab? {
|
||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return null
|
||||
|
||||
val tabs = terminalTabbedManager.getTerminalTabs()
|
||||
for (tab in tabs) {
|
||||
if (tab is SFTPTerminalTab) {
|
||||
terminalTabbedManager.setSelectedTerminalTab(tab)
|
||||
return
|
||||
if (selected) {
|
||||
terminalTabbedManager.setSelectedTerminalTab(tab)
|
||||
}
|
||||
return tab
|
||||
}
|
||||
}
|
||||
|
||||
// 创建一个新的
|
||||
terminalTabbedManager.addTerminalTab(SFTPTerminalTab())
|
||||
val tab = SFTPTerminalTab()
|
||||
terminalTabbedManager.addTerminalTab(tab, selected)
|
||||
|
||||
return tab
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果当前选中的是 SSH 服务器 Tab,那么直接打开 SFTP 通道
|
||||
*/
|
||||
fun connectHost(host: Host, tab: SFTPTerminalTab) {
|
||||
val tabbed = tab.getData(TransportDataProviders.TransportPanel)
|
||||
?.getData(TransportDataProviders.RightFileSystemTabbed) ?: return
|
||||
|
||||
// 如果已经有对应的连接
|
||||
for (i in 0 until tabbed.tabCount) {
|
||||
val c = tabbed.getComponentAt(i)
|
||||
if (c is SftpFileSystemPanel) {
|
||||
if (c.host == host) {
|
||||
tabbed.selectedIndex = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 寻找空的 Tab,如果有则占用
|
||||
for (i in 0 until tabbed.tabCount) {
|
||||
val c = tabbed.getComponentAt(i)
|
||||
if (c is SftpFileSystemPanel) {
|
||||
if (c.host == null) {
|
||||
c.host = host
|
||||
c.connect()
|
||||
tabbed.selectedIndex = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开启一个新的
|
||||
tabbed.addTab(host.name, SftpFileSystemPanel(host).apply { connect() })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
|
||||
import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon
|
||||
@@ -21,15 +23,12 @@ import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.CardLayout
|
||||
import java.awt.event.ActionEvent
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.*
|
||||
|
||||
class SftpFileSystemPanel(
|
||||
private val transportManager: TransportManager,
|
||||
private var host: Host? = null
|
||||
) : JPanel(BorderLayout()), Disposable,
|
||||
FileSystemTransportListener.Provider {
|
||||
var host: Host? = null
|
||||
) : JPanel(BorderLayout()), Disposable {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java)
|
||||
@@ -50,7 +49,6 @@ class SftpFileSystemPanel(
|
||||
private val connectingPanel = ConnectingPanel()
|
||||
private val selectHostPanel = SelectHostPanel()
|
||||
private val connectFailedPanel = ConnectFailedPanel()
|
||||
private val listeners = mutableListOf<FileSystemTransportListener>()
|
||||
private val isDisposed = AtomicBoolean(false)
|
||||
|
||||
private var client: SshClient? = null
|
||||
@@ -108,15 +106,35 @@ class SftpFileSystemPanel(
|
||||
|
||||
private suspend fun doConnect() {
|
||||
|
||||
val host = this.host ?: return
|
||||
val thisHost = this.host ?: return
|
||||
var host = thisHost.copy(authentication = thisHost.authentication.copy(), updateDate = System.currentTimeMillis())
|
||||
|
||||
closeIO()
|
||||
|
||||
try {
|
||||
val client = SshClients.openClient(host).apply { client = this }
|
||||
withContext(Dispatchers.Swing) {
|
||||
client.userInteraction =
|
||||
TerminalUserInteraction(SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel))
|
||||
val owner = SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel)
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
|
||||
// 弹出授权框
|
||||
if (host.authentication.type == AuthenticationType.No) {
|
||||
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(
|
||||
host.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val session = SshClients.openSession(host, client).apply { session = this }
|
||||
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
|
||||
@@ -135,17 +153,7 @@ class SftpFileSystemPanel(
|
||||
withContext(Dispatchers.Swing) {
|
||||
state = State.Connected
|
||||
|
||||
val fileSystemPanel = FileSystemPanel(fileSystem, transportManager, host)
|
||||
fileSystemPanel.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(
|
||||
fileSystemPanel: FileSystemPanel,
|
||||
workdir: Path,
|
||||
isDirectory: Boolean,
|
||||
path: Path
|
||||
) {
|
||||
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
|
||||
}
|
||||
})
|
||||
val fileSystemPanel = FileSystemPanel(fileSystem, host)
|
||||
|
||||
cardPanel.add(fileSystemPanel, State.Connected.name)
|
||||
cardLayout.show(cardPanel, State.Connected.name)
|
||||
@@ -292,9 +300,11 @@ class SftpFileSystemPanel(
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
builder.add(FlatOptionPaneInformationIcon()).xy(2, 2)
|
||||
builder.add(errorInfo).xyw(1, 4, 3, "fill, center")
|
||||
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.select-host")) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel))
|
||||
builder.add(JXHyperlink(object : AnAction(I18n.getString("termora.transport.sftp.select-host")) {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val dialog = NewHostTreeDialog(evt.window)
|
||||
dialog.setFilter { it.host.protocol == Protocol.SSH }
|
||||
dialog.setTreeName("SftpFileSystemPanel.SelectHostTree")
|
||||
dialog.allowMulti = false
|
||||
dialog.setLocationRelativeTo(this@SelectHostPanel)
|
||||
dialog.isVisible = true
|
||||
@@ -311,11 +321,4 @@ class SftpFileSystemPanel(
|
||||
}
|
||||
|
||||
|
||||
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.termora.transport
|
||||
|
||||
import app.termora.Disposable
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.ObjectUtils
|
||||
import org.apache.commons.net.io.CopyStreamEvent
|
||||
import org.apache.commons.net.io.CopyStreamListener
|
||||
import org.apache.commons.net.io.Util
|
||||
@@ -31,10 +32,15 @@ abstract class Transport(
|
||||
val target: Path,
|
||||
val sourceHolder: Disposable,
|
||||
val targetHolder: Disposable,
|
||||
val listener: TransportListener = TransportListener.EMPTY
|
||||
) : Disposable, Runnable {
|
||||
|
||||
private val listeners = ArrayList<TransportListener>()
|
||||
|
||||
init {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var state = TransportState.Waiting
|
||||
protected set(value) {
|
||||
@@ -100,7 +106,10 @@ abstract class Transport(
|
||||
if (fileSystem is SftpFileSystem) {
|
||||
val clientSession = fileSystem.session
|
||||
if (clientSession is JGitClientSession) {
|
||||
return clientSession.hostConfigEntry.host
|
||||
return ObjectUtils.defaultIfNull(
|
||||
clientSession.hostConfigEntry.host,
|
||||
clientSession.hostConfigEntry.hostName
|
||||
)
|
||||
}
|
||||
}
|
||||
return "file"
|
||||
@@ -142,9 +151,9 @@ private class SlidingWindowByteCounter {
|
||||
*/
|
||||
class FileTransport(
|
||||
name: String, source: Path, target: Path,
|
||||
sourceHolder: Disposable, targetHolder: Disposable,
|
||||
sourceHolder: Disposable, targetHolder: Disposable, listener: TransportListener = TransportListener.EMPTY
|
||||
) : Transport(
|
||||
name, source, target, sourceHolder, targetHolder,
|
||||
name, source, target, sourceHolder, targetHolder, listener
|
||||
), CopyStreamListener {
|
||||
|
||||
companion object {
|
||||
|
||||
27
src/main/kotlin/app/termora/transport/TransportJob.kt
Normal file
27
src/main/kotlin/app/termora/transport/TransportJob.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package app.termora.transport
|
||||
|
||||
import java.nio.file.Path
|
||||
|
||||
data class TransportJob(
|
||||
/**
|
||||
* 发起方
|
||||
*/
|
||||
val fileSystemPanel: FileSystemPanel,
|
||||
/**
|
||||
* 发起方工作目录
|
||||
*/
|
||||
val workdir: Path,
|
||||
/**
|
||||
* 要传输的文件是否是文件夹
|
||||
*/
|
||||
val isDirectory: Boolean,
|
||||
/**
|
||||
* 要传输的文件/文件夹
|
||||
*/
|
||||
val path: Path,
|
||||
|
||||
/**
|
||||
* 监听
|
||||
*/
|
||||
val listener: TransportListener? = null
|
||||
)
|
||||
@@ -3,18 +3,33 @@ package app.termora.transport
|
||||
import java.util.*
|
||||
|
||||
interface TransportListener : EventListener {
|
||||
|
||||
companion object {
|
||||
val EMPTY = object : TransportListener {
|
||||
override fun onTransportAdded(transport: Transport) {
|
||||
|
||||
}
|
||||
|
||||
override fun onTransportRemoved(transport: Transport) {
|
||||
}
|
||||
|
||||
override fun onTransportChanged(transport: Transport) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Added
|
||||
*/
|
||||
fun onTransportAdded(transport: Transport)
|
||||
fun onTransportAdded(transport: Transport){}
|
||||
|
||||
/**
|
||||
* Removed
|
||||
*/
|
||||
fun onTransportRemoved(transport: Transport)
|
||||
fun onTransportRemoved(transport: Transport){}
|
||||
|
||||
/**
|
||||
* 状态变化
|
||||
*/
|
||||
fun onTransportChanged(transport: Transport)
|
||||
fun onTransportChanged(transport: Transport){}
|
||||
}
|
||||
@@ -107,32 +107,6 @@ class TransportPanel : JPanel(BorderLayout()), Disposable, DataProvider {
|
||||
})
|
||||
|
||||
|
||||
leftFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
|
||||
val target = rightFileSystemTabbed.getSelectedFileSystemPanel() ?: return
|
||||
transport(
|
||||
fileSystemPanel.workdir, target.workdir,
|
||||
isSourceDirectory = isDirectory,
|
||||
sourcePath = path,
|
||||
sourceHolder = fileSystemPanel,
|
||||
targetHolder = target,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
rightFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
|
||||
val target = leftFileSystemTabbed.getSelectedFileSystemPanel() ?: return
|
||||
transport(
|
||||
fileSystemPanel.workdir, target.workdir,
|
||||
isSourceDirectory = isDirectory,
|
||||
sourcePath = path,
|
||||
sourceHolder = fileSystemPanel,
|
||||
targetHolder = target,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun transport(
|
||||
|
||||
@@ -38,6 +38,9 @@ termora.doorman.mnemonic.title=Enter 12 mnemonic words
|
||||
termora.doorman.mnemonic.incorrect=Incorrect mnemonic
|
||||
|
||||
|
||||
# Hosts
|
||||
termora.host.verify-server-key=Host [{0}] key has been changed<br/><br/>{1} key fingerprint is {2}<br/><br/>Are you sure you want to continue connecting?
|
||||
termora.host.modified-server-key=HOST [{0}] IDENTIFICATION HAS CHANGED<br/><br/>Expected: {1} key fingerprint is {2}<br/><br/>Actual: {3} key fingerprint is {4}<br/><br/>Are you sure you want to continue connecting?
|
||||
|
||||
|
||||
# Settings
|
||||
@@ -66,7 +69,9 @@ termora.settings.terminal.debug=Debug mode
|
||||
termora.settings.terminal.beep=Beep
|
||||
termora.settings.terminal.select-copy=Select copy
|
||||
termora.settings.terminal.cursor-style=Cursor type
|
||||
termora.settings.terminal.cursor-blink=Cursor blink
|
||||
termora.settings.terminal.local-shell=Local shell
|
||||
termora.settings.terminal.floating-toolbar=Floating Toolbar
|
||||
termora.settings.terminal.auto-close-tab=Auto Close Tab
|
||||
termora.settings.terminal.auto-close-tab-description=Automatically close the tab when the terminal is disconnected normally
|
||||
|
||||
@@ -80,6 +85,7 @@ termora.settings.sync.import=${termora.keymgr.import}
|
||||
termora.settings.sync.import.file-too-large=The file is too large
|
||||
termora.settings.sync.import.successful=Import data successfully
|
||||
termora.settings.sync.export-done=The export was successful
|
||||
termora.settings.sync.export-encrypt=Enter password to encrypt file (optional)
|
||||
termora.settings.sync.export-done-open-folder=The export was successful. Do you want to open the folder?
|
||||
termora.settings.sync.range=Range
|
||||
termora.settings.sync.range.keys=My keys
|
||||
@@ -103,6 +109,10 @@ termora.settings.keymap.action=Action
|
||||
termora.settings.keymap.already-exists=The shortcut [{0}] is already in use by [{1}]
|
||||
|
||||
|
||||
termora.settings.sftp.edit-command=Edit Command
|
||||
termora.settings.sftp.fixed-tab=Fixed tab
|
||||
|
||||
|
||||
termora.settings.restart.title=Restart
|
||||
termora.settings.restart.message=Changes will take effect after restarting the application
|
||||
|
||||
@@ -115,23 +125,31 @@ termora.find-everywhere.groups.opened-hosts=Opened hosts
|
||||
termora.find-everywhere.groups.tools=Tools
|
||||
termora.find-everywhere.groups.settings=${termora.setting}
|
||||
termora.find-everywhere.quick-command.local-terminal=Local Terminal
|
||||
termora.find-everywhere.double-shift-deprecated=The double-click Shift shortcut will be removed in a future version
|
||||
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated}, use {0} instead
|
||||
|
||||
# Welcome
|
||||
termora.welcome.my-hosts=My hosts
|
||||
termora.welcome.contextmenu.open=Open
|
||||
termora.welcome.contextmenu.connect=Connect
|
||||
termora.welcome.contextmenu.connect-with=Connect with
|
||||
termora.welcome.contextmenu.open-in-new-window=${termora.tabbed.contextmenu.open-in-new-window}
|
||||
termora.welcome.contextmenu.refresh=${termora.transport.table.contextmenu.refresh}
|
||||
termora.welcome.contextmenu.copy=${termora.copy}
|
||||
termora.welcome.contextmenu.remove=${termora.remove}
|
||||
termora.welcome.contextmenu.rename=Rename
|
||||
termora.welcome.contextmenu.expand-all=Expand all
|
||||
termora.welcome.contextmenu.collapse-all=Collapse all
|
||||
termora.welcome.contextmenu.new=New
|
||||
termora.welcome.contextmenu.import=${termora.keymgr.import}
|
||||
termora.welcome.contextmenu.new.folder=${termora.folder}
|
||||
termora.welcome.contextmenu.new.host=Host
|
||||
termora.welcome.contextmenu.new.folder.name=New Folder
|
||||
termora.welcome.contextmenu.property=Properties
|
||||
termora.welcome.contextmenu.show-more-info=Show more info
|
||||
termora.welcome.contextmenu.download=Download
|
||||
termora.welcome.contextmenu.import.csv.download-template=Do you want to import or download the template?
|
||||
termora.welcome.contextmenu.import.csv.download-template-done=Download the template successfully
|
||||
termora.welcome.contextmenu.import.csv.download-template-done-open-folder=Download the template successfully, Do you want to open the folder?
|
||||
termora.welcome.contextmenu.import.xshell-folder-empty=The folder does not contain any *.xsh files, Please select the correct Xshell Sessions directory
|
||||
termora.welcome.contextmenu.import.finalshell-folder-empty=The folder does not contain any *_connect_config.json files, Please select the correct FinalShell directory
|
||||
|
||||
# New Host
|
||||
termora.new-host.title=Create a new host
|
||||
@@ -196,6 +214,8 @@ termora.keymgr.ssh-copy-id.end=End of public key copying
|
||||
|
||||
# Tabbed
|
||||
termora.tabbed.contextmenu.rename=Rename
|
||||
termora.tabbed.contextmenu.sftp-command=SFTP Command
|
||||
termora.tabbed.contextmenu.sftp-not-install=SFTP programme not found, please install and try again
|
||||
termora.tabbed.contextmenu.clone=Clone
|
||||
termora.tabbed.contextmenu.open-in-new-window=Open in New Window
|
||||
termora.tabbed.contextmenu.close=Close
|
||||
@@ -255,6 +275,8 @@ termora.transport.table.owner=Owner
|
||||
|
||||
# contextmenu
|
||||
termora.transport.table.contextmenu.transfer=Transfer
|
||||
termora.transport.table.contextmenu.edit=${termora.keymgr.edit}
|
||||
termora.transport.table.contextmenu.edit-command=You must configure the "Edit Command" in "Settings - SFTP" before you can edit the file
|
||||
termora.transport.table.contextmenu.copy-path=Copy Path
|
||||
termora.transport.table.contextmenu.open-in-folder=Open in {0}
|
||||
termora.transport.table.contextmenu.rename=${termora.welcome.contextmenu.rename}
|
||||
@@ -320,6 +342,8 @@ termora.actions.zoom-reset-terminal=Reset Terminal Zoom
|
||||
termora.actions.open-local-terminal=Open Local Terminal
|
||||
termora.actions.open-find-everywhere=Open FindEverywhere
|
||||
termora.actions.open-new-window=Open new Window
|
||||
termora.actions.clear-screen=Clear Terminal Screen
|
||||
termora.actions.open-sftp-command=Open SFTP Command
|
||||
termora.actions.switch-tab=Switch to specific Tab [1..9]
|
||||
|
||||
# Terminal
|
||||
@@ -328,6 +352,19 @@ termora.terminal.copied=Copied
|
||||
termora.terminal.channel-disconnected=Channel has been disconnected.\u0020
|
||||
termora.terminal.channel-reconnect=Type {0} to reconnect.
|
||||
|
||||
# Visual Window
|
||||
termora.visual-window.system-information=System information
|
||||
termora.visual-window.system-information.mem=Mem
|
||||
termora.visual-window.system-information.swap=Swap
|
||||
termora.visual-window.system-information.filesystem=Filesystem
|
||||
termora.visual-window.system-information.used-total=Used / Total
|
||||
|
||||
|
||||
termora.visual-window.nvidia-smi=NVIDIA SMI
|
||||
|
||||
|
||||
termora.floating-toolbar.not-supported=This action is not supported
|
||||
|
||||
|
||||
# zmodem
|
||||
termora.addons.zmodem.skip=SKIP
|
||||
@@ -36,6 +36,11 @@ termora.doorman.mnemonic.title=输入 12 个助记词
|
||||
termora.doorman.mnemonic.incorrect=助记词错误
|
||||
|
||||
|
||||
# Hosts
|
||||
termora.host.verify-server-key=主机 [{0}] 密钥已经改变<br/><br/>{1} 的指纹 {2}<br/><br/>你确定要继续连接吗?
|
||||
termora.host.modified-server-key=主机 [{0}] 身份已发生变化<br/><br/>期待: {1} 的指纹 {2}<br/><br/>实际: {3} 的指纹 {4}<br/><br/>你确定要继续连接吗?
|
||||
|
||||
|
||||
termora.setting=设置
|
||||
termora.settings.restart.title=重启
|
||||
termora.settings.restart.message=设置修改将在重启后生效
|
||||
@@ -60,8 +65,6 @@ termora.find-everywhere.groups.opened-hosts=已打开的主机
|
||||
termora.find-everywhere.groups.tools=工具
|
||||
termora.find-everywhere.groups.settings=${termora.setting}
|
||||
termora.find-everywhere.quick-command.local-terminal=本地终端
|
||||
termora.find-everywhere.double-shift-deprecated=双击 Shift 快捷键将会在未来的版本中移除
|
||||
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated},请使用 {0} 代替
|
||||
|
||||
termora.settings.terminal=终端
|
||||
termora.settings.terminal.font=字体
|
||||
@@ -71,7 +74,9 @@ termora.settings.terminal.debug=调试模式
|
||||
termora.settings.terminal.beep=蜂鸣声
|
||||
termora.settings.terminal.select-copy=选中复制
|
||||
termora.settings.terminal.cursor-style=光标样式
|
||||
termora.settings.terminal.cursor-blink=光标闪烁
|
||||
termora.settings.terminal.local-shell=本地终端
|
||||
termora.settings.terminal.floating-toolbar=悬浮工具栏
|
||||
termora.settings.terminal.auto-close-tab=自动关闭标签
|
||||
termora.settings.terminal.auto-close-tab-description=当终端正常断开连接时自动关闭标签页
|
||||
|
||||
@@ -81,6 +86,7 @@ termora.settings.sync.push=推送
|
||||
termora.settings.sync.push-warning=推送将覆盖已有配置,建议先拉取再推送
|
||||
termora.settings.sync.pull=拉取
|
||||
termora.settings.sync.export-done=导出成功
|
||||
termora.settings.sync.export-encrypt=输入密码加密文件 (可选)
|
||||
termora.settings.sync.export-done-open-folder=导出成功,是否需要打开所在文件夹?
|
||||
termora.settings.sync.range=范围
|
||||
termora.settings.sync.range.keys=我的密钥
|
||||
@@ -105,9 +111,15 @@ termora.settings.keymap.shortcut=快捷键
|
||||
termora.settings.keymap.action=操作
|
||||
termora.settings.keymap.already-exists=快捷键 [{0}] 已经被 [{1}] 占用
|
||||
|
||||
|
||||
termora.settings.sftp.edit-command=编辑命令
|
||||
termora.settings.sftp.fixed-tab=固定标签
|
||||
|
||||
|
||||
# Welcome
|
||||
termora.welcome.my-hosts=我的主机
|
||||
termora.welcome.contextmenu.open=打开
|
||||
termora.welcome.contextmenu.connect=连接
|
||||
termora.welcome.contextmenu.connect-with=连接到
|
||||
termora.welcome.contextmenu.copy=${termora.copy}
|
||||
termora.welcome.contextmenu.remove=${termora.remove}
|
||||
termora.welcome.contextmenu.rename=重命名
|
||||
@@ -118,6 +130,14 @@ termora.welcome.contextmenu.new.folder=文件夹
|
||||
termora.welcome.contextmenu.new.host=主机
|
||||
termora.welcome.contextmenu.new.folder.name=新建文件夹
|
||||
termora.welcome.contextmenu.property=属性
|
||||
termora.welcome.contextmenu.show-more-info=显示更多信息
|
||||
|
||||
termora.welcome.contextmenu.download=下载
|
||||
termora.welcome.contextmenu.import.csv.download-template=您要导入还是下载模板?
|
||||
termora.welcome.contextmenu.import.csv.download-template-done=下载成功
|
||||
termora.welcome.contextmenu.import.csv.download-template-done-open-folder=下载成功, 是否需要打开所在文件夹?
|
||||
termora.welcome.contextmenu.import.xshell-folder-empty=该文件夹不包含 *.xsh 文件,请选择正确的 Xshell 会话目录
|
||||
termora.welcome.contextmenu.import.finalshell-folder-empty=该文件夹不包含 *_connect_config.json 文件,请选择正确的 FinalShell 配置目录
|
||||
|
||||
# New Host
|
||||
termora.new-host.title=新建主机
|
||||
@@ -185,6 +205,8 @@ termora.tools.multiple=将命令发送到所有会话
|
||||
|
||||
# Tabbed
|
||||
termora.tabbed.contextmenu.rename=重命名
|
||||
termora.tabbed.contextmenu.sftp-command=SFTP 终端
|
||||
termora.tabbed.contextmenu.sftp-not-install=没有找到 SFTP 程序,请安装后重试
|
||||
termora.tabbed.contextmenu.clone=克隆
|
||||
termora.tabbed.contextmenu.open-in-new-window=在新窗口打开
|
||||
termora.tabbed.contextmenu.close=关闭
|
||||
@@ -245,6 +267,7 @@ termora.transport.table.owner=所有者
|
||||
# contextmenu
|
||||
termora.transport.table.contextmenu.transfer=传输
|
||||
termora.transport.table.contextmenu.copy-path=复制路径
|
||||
termora.transport.table.contextmenu.edit-command=你必须在 “设置 - SFTP” 中配置 “编辑命令” 后才能编辑文件
|
||||
termora.transport.table.contextmenu.open-in-folder=在{0}中打开
|
||||
termora.transport.table.contextmenu.change-permissions=更改权限...
|
||||
termora.transport.table.contextmenu.refresh=刷新
|
||||
@@ -310,7 +333,20 @@ termora.actions.zoom-reset-terminal=重置终端缩放
|
||||
termora.actions.open-local-terminal=打开本地终端
|
||||
termora.actions.open-find-everywhere=打开全局查找
|
||||
termora.actions.open-new-window=打开新窗口
|
||||
termora.actions.clear-screen=清除终端屏幕
|
||||
termora.actions.open-sftp-command=打开 SFTP 终端
|
||||
termora.actions.switch-tab=切换到特定标签页 [1..9]
|
||||
|
||||
|
||||
# Visual Window
|
||||
termora.visual-window.system-information=系统信息
|
||||
termora.visual-window.system-information.mem=内存
|
||||
termora.visual-window.system-information.swap=交换
|
||||
termora.visual-window.system-information.filesystem=文件系统
|
||||
termora.visual-window.system-information.used-total=使用 / 大小
|
||||
|
||||
termora.floating-toolbar.not-supported=不允许此操作
|
||||
|
||||
|
||||
# zmodem
|
||||
termora.addons.zmodem.skip=跳过
|
||||
@@ -35,6 +35,13 @@ termora.doorman.mnemonic-data-corrupted=無法從助記詞解密數據,資料
|
||||
termora.doorman.mnemonic.title=輸入 12 個助記詞
|
||||
termora.doorman.mnemonic.incorrect=助記詞錯誤
|
||||
|
||||
|
||||
|
||||
# Hosts
|
||||
termora.host.verify-server-key=主機 [{0}] 金鑰已經改變<br/><br/>{1} 的指紋 {2}<br/><br/>你確定要繼續連線嗎?
|
||||
termora.host.modified-server-key=主機 [{0}] 身分已變更<br/><br/>期待: {1} 的指紋 {2}<br/><br/>實際: {3} 的指紋 {4}<br/><br/>你確定要繼續連線嗎?
|
||||
|
||||
|
||||
termora.setting=設定
|
||||
termora.settings.restart.title=重啟
|
||||
termora.settings.restart.message=設定修改將在重新啟動後生效
|
||||
@@ -55,6 +62,9 @@ termora.settings.keymap.shortcut=快捷鍵
|
||||
termora.settings.keymap.action=操作
|
||||
termora.settings.keymap.already-exists=快捷鍵 [{0}] 已經被 [{1}] 占用
|
||||
|
||||
termora.settings.sftp.edit-command=編輯命令
|
||||
termora.settings.sftp.fixed-tab=固定標籤
|
||||
|
||||
|
||||
# Find everywhere
|
||||
termora.find-everywhere=尋找
|
||||
@@ -65,8 +75,6 @@ termora.find-everywhere.groups.opened-hosts=已開啟的主機
|
||||
termora.find-everywhere.groups.tools=工具
|
||||
termora.find-everywhere.groups.settings=${termora.setting}
|
||||
termora.find-everywhere.quick-command.local-terminal=本地端
|
||||
termora.find-everywhere.double-shift-deprecated=雙擊 Shift 快捷鍵將會在未來的版本中移除
|
||||
termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywhere.double-shift-deprecated},請使用 {0} 代替
|
||||
|
||||
termora.settings.terminal=終端
|
||||
termora.settings.terminal.font=字體
|
||||
@@ -76,7 +84,9 @@ termora.settings.terminal.debug=偵錯模式
|
||||
termora.settings.terminal.beep=蜂鳴聲
|
||||
termora.settings.terminal.select-copy=選取複製
|
||||
termora.settings.terminal.cursor-style=遊標風格
|
||||
termora.settings.terminal.cursor-blink=遊標閃爍
|
||||
termora.settings.terminal.local-shell=本地端
|
||||
termora.settings.terminal.floating-toolbar=懸浮工具列
|
||||
termora.settings.terminal.auto-close-tab=自動關閉標籤
|
||||
termora.settings.terminal.auto-close-tab-description=當終端正常斷開連線時自動關閉標籤頁
|
||||
|
||||
@@ -85,6 +95,7 @@ termora.settings.sync.push=推送
|
||||
termora.settings.sync.push-warning=推送將覆蓋先前的配置,建議先拉取再推送
|
||||
termora.settings.sync.pull=拉取
|
||||
termora.settings.sync.export-done=匯出成功
|
||||
termora.settings.sync.export-encrypt=輸入密碼加密檔案 (可選)
|
||||
termora.settings.sync.export-done-open-folder=匯出成功,是否需要打開所在資料夾?
|
||||
termora.settings.sync.range=範圍
|
||||
termora.settings.sync.range.keys=我的密鑰
|
||||
@@ -106,7 +117,8 @@ termora.settings.about.termora=<html><b>${termora.title}</b> ({0}) 是一個跨
|
||||
|
||||
# Welcome
|
||||
termora.welcome.my-hosts=我的主機
|
||||
termora.welcome.contextmenu.open=打開
|
||||
termora.welcome.contextmenu.connect=連接
|
||||
termora.welcome.contextmenu.connect-with=連接到
|
||||
termora.welcome.contextmenu.copy=複製
|
||||
termora.welcome.contextmenu.remove=${termora.remove}
|
||||
termora.welcome.contextmenu.rename=重新命名
|
||||
@@ -117,6 +129,13 @@ termora.welcome.contextmenu.new.folder=${termora.folder}
|
||||
termora.welcome.contextmenu.new.host=主機
|
||||
termora.welcome.contextmenu.new.folder.name=新建資料夾
|
||||
termora.welcome.contextmenu.property=屬性
|
||||
termora.welcome.contextmenu.show-more-info=顯示更多信息
|
||||
termora.welcome.contextmenu.download=下載
|
||||
termora.welcome.contextmenu.import.csv.download-template=您要匯入還是下載範本?
|
||||
termora.welcome.contextmenu.import.csv.download-template-done=下載成功
|
||||
termora.welcome.contextmenu.import.csv.download-template-done-open-folder=下載成功, 是否需要開啟所在資料夾?
|
||||
termora.welcome.contextmenu.import.xshell-folder-empty=該資料夾不包含 *.xsh 文件,請選擇正確的 Xshell 會話目錄
|
||||
termora.welcome.contextmenu.import.finalshell-folder-empty=該資料夾不包含 *_connect_config.json 文件,請選擇正確的 FinalShell 設定目錄
|
||||
|
||||
# New Host
|
||||
termora.new-host.title=新主機
|
||||
@@ -181,6 +200,8 @@ termora.tools.multiple=將指令傳送到所有會話
|
||||
|
||||
# Tabbed
|
||||
termora.tabbed.contextmenu.rename=重新命名
|
||||
termora.tabbed.contextmenu.sftp-command=SFTP 終端
|
||||
termora.tabbed.contextmenu.sftp-not-install=沒有找到 SFTP 程序,請安裝後重試
|
||||
termora.tabbed.contextmenu.clone=克隆
|
||||
termora.tabbed.contextmenu.open-in-new-window=在新視窗打開
|
||||
termora.tabbed.contextmenu.close=關閉
|
||||
@@ -239,6 +260,7 @@ termora.transport.table.owner=所有者
|
||||
# contextmenu
|
||||
termora.transport.table.contextmenu.transfer=傳輸
|
||||
termora.transport.table.contextmenu.copy-path=複製路徑
|
||||
termora.transport.table.contextmenu.edit-command=你必須在 “設定 - SFTP” 中設定 “編輯指令” 後才能編輯文件
|
||||
termora.transport.table.contextmenu.open-in-folder=在{0}中打開
|
||||
termora.transport.table.contextmenu.change-permissions=更改權限...
|
||||
termora.transport.table.contextmenu.refresh=刷新
|
||||
@@ -291,8 +313,18 @@ termora.actions.zoom-reset-terminal=重置終端縮放
|
||||
termora.actions.open-local-terminal=開啟本地終端
|
||||
termora.actions.open-find-everywhere=開啟全域搜尋
|
||||
termora.actions.open-new-window=開啟新視窗
|
||||
termora.actions.clear-screen=清除終端機螢幕
|
||||
termora.actions.open-sftp-command=打開 SFTP 終端
|
||||
termora.actions.switch-tab=切換到特定分頁 [1..9]
|
||||
|
||||
# Visual Window
|
||||
termora.visual-window.system-information=系統訊息
|
||||
termora.visual-window.system-information.mem=內存
|
||||
termora.visual-window.system-information.swap=交換
|
||||
termora.visual-window.system-information.filesystem=檔案系統
|
||||
termora.visual-window.system-information.used-total=使用 / 大小
|
||||
|
||||
termora.floating-toolbar.not-supported=不允許此操作
|
||||
|
||||
# zmodem
|
||||
termora.addons.zmodem.skip=跳過
|
||||
6
src/main/resources/icons/closeSmall.svg
Normal file
6
src/main/resources/icons/closeSmall.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M11.4939 4.48784C11.3002 4.28007 10.9724 4.27548 10.7729 4.47775L8.00074 7.28849L5.22871 4.47788C5.02922 4.27561 4.70143 4.2802 4.50768 4.48797C4.32506 4.68382 4.32933 4.98882 4.51736 5.17947L7.29908 7.99991L4.51756 10.8201C4.32953 11.0108 4.32526 11.3158 4.50788 11.5116C4.70163 11.7194 5.02942 11.724 5.22892 11.5217L8.00074 8.71133L10.7727 11.5219C10.9722 11.7241 11.3 11.7196 11.4937 11.5118C11.6764 11.3159 11.6721 11.0109 11.484 10.8203L8.7024 7.99991L11.4843 5.17934C11.6723 4.98869 11.6766 4.68368 11.4939 4.48784Z"
|
||||
fill="#A8ADBD"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 844 B |
7
src/main/resources/icons/closeSmallHovered.svg
Normal file
7
src/main/resources/icons/closeSmallHovered.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle opacity="0.1" cx="8" cy="8" r="8" fill="#313547"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M11.4939 4.48784C11.3002 4.28007 10.9724 4.27548 10.7729 4.47775L8.00074 7.28849L5.22871 4.47788C5.02922 4.27561 4.70143 4.2802 4.50768 4.48797C4.32506 4.68382 4.32933 4.98882 4.51736 5.17947L7.29908 7.99991L4.51756 10.8201C4.32953 11.0108 4.32526 11.3158 4.50788 11.5116C4.70163 11.7194 5.02942 11.724 5.22892 11.5217L8.00074 8.71133L10.7727 11.5219C10.9722 11.7241 11.3 11.7196 11.4937 11.5118C11.6764 11.3159 11.6721 11.0109 11.484 10.8203L8.7024 7.99991L11.4843 5.17934C11.6723 4.98869 11.6766 4.68368 11.4939 4.48784Z"
|
||||
fill="#818594"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 907 B |
7
src/main/resources/icons/closeSmallHovered_dark.svg
Normal file
7
src/main/resources/icons/closeSmallHovered_dark.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle opacity="0.13" cx="8" cy="8" r="8" fill="#F0F1F2"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M11.4939 4.48784C11.3002 4.28007 10.9724 4.27548 10.7729 4.47775L8.00074 7.28849L5.22871 4.47788C5.02922 4.27561 4.70143 4.2802 4.50768 4.48797C4.32506 4.68382 4.32933 4.98882 4.51736 5.17947L7.29908 7.99991L4.51756 10.8201C4.32953 11.0108 4.32526 11.3158 4.50788 11.5116C4.70163 11.7194 5.02942 11.724 5.22892 11.5217L8.00074 8.71133L10.7727 11.5219C10.9722 11.7241 11.3 11.7196 11.4937 11.5118C11.6764 11.3159 11.6721 11.0109 11.484 10.8203L8.7024 7.99991L11.4843 5.17934C11.6723 4.98869 11.6766 4.68368 11.4939 4.48784Z"
|
||||
fill="#868A91"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 908 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user