mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 02:12:58 +08:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc4333da21 | ||
|
|
184f6d46dc | ||
|
|
68788905fe | ||
|
|
fc46216a3f | ||
|
|
563143645e | ||
|
|
891ccb901b | ||
|
|
928a866fe7 | ||
|
|
ea25b5b46f | ||
|
|
1de10e6129 | ||
|
|
aaf9c2e8d2 | ||
|
|
b8196b5730 | ||
|
|
0a83e8beb4 | ||
|
|
bdf29b27e7 | ||
|
|
96da7eac41 | ||
|
|
71c0751692 | ||
|
|
442f334af2 | ||
|
|
48302a519f | ||
|
|
c00f759f15 | ||
|
|
1736dd909e | ||
|
|
1f01e368dd | ||
|
|
bfba958b7e | ||
|
|
758121b523 | ||
|
|
06e9a89e82 | ||
|
|
0ba6ac3305 | ||
|
|
993f220b8b | ||
|
|
8755c4ad23 | ||
|
|
77cb102dd6 | ||
|
|
89cfb0b451 | ||
|
|
6bdd83f208 | ||
|
|
8f86057dcc | ||
|
|
a7d7ffa2cc | ||
|
|
d51cbeee13 | ||
|
|
deb2a0151e | ||
|
|
e1c4e9312d | ||
|
|
c7233357bd | ||
|
|
eff8d565d0 | ||
|
|
932db49868 | ||
|
|
4d71c6cd05 | ||
|
|
96133e5abf | ||
|
|
f06e5d7dc1 | ||
|
|
d4b96edccf | ||
|
|
e9876d5b91 | ||
|
|
8b9a78a7bd | ||
|
|
6b48f577e9 | ||
|
|
da9b6c21d6 | ||
|
|
f1f889df14 | ||
|
|
ed65853ebe | ||
|
|
5ffdd219d9 | ||
|
|
4f84d6741c | ||
|
|
2568e7fcc8 | ||
|
|
dddbb49084 | ||
|
|
95846ab135 | ||
|
|
b5207e56c1 | ||
|
|
160771e912 | ||
|
|
0fbe180f3f | ||
|
|
41a0409e9e | ||
|
|
79e59143fb | ||
|
|
54e0f621ce | ||
|
|
4c8944d248 | ||
|
|
64bd95d8a8 | ||
|
|
1d88942e8e | ||
|
|
129e1b149a | ||
|
|
01aac98437 | ||
|
|
f9aaf7143f | ||
|
|
28174483f4 | ||
|
|
46412054c4 | ||
|
|
1ab0d26bab | ||
|
|
d90fb9aa35 | ||
|
|
744e64b359 | ||
|
|
2c5442f1f3 | ||
|
|
054c4701d2 | ||
|
|
54044625ea | ||
|
|
ca82704738 | ||
|
|
e98ec3fa8e | ||
|
|
6a4abf7e50 | ||
|
|
e2a6cceafd | ||
|
|
283404b6b9 | ||
|
|
c714f33a44 | ||
|
|
30fe047e5c | ||
|
|
827d814c7b | ||
|
|
ccb2c6daa0 | ||
|
|
1516d6d81e | ||
|
|
09b3655c4e | ||
|
|
614514c87e | ||
|
|
30cba6720d | ||
|
|
dce6551de2 | ||
|
|
95943cdeec | ||
|
|
18a26ee6bf | ||
|
|
f23aae371a | ||
|
|
757bc1c001 | ||
|
|
a19222dc60 | ||
|
|
24677ca4a6 | ||
|
|
0c5b6f8112 |
12
.github/dependabot.yml
vendored
Normal file
12
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 25
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ certs/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
.vs
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- Compatible with Windows, macOS, and Linux
|
||||
- Zmodem protocol support
|
||||
- SSH port forwarding & Jump hosts
|
||||
- Support for X11 and SSH-Agent
|
||||
- Terminal log
|
||||
- Configuration synchronization via [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
|
||||
- Macro support (record and replay scripts)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- 支持 Windows、macOS、Linux 平台
|
||||
- 支持 Zmodem 协议
|
||||
- 支持 SSH 端口转发和跳板机
|
||||
- 支持 X11 和 SSH-Agent
|
||||
- 终端日志记录
|
||||
- 支持配置同步到 [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
|
||||
- 支持宏(录制脚本并回放)
|
||||
|
||||
136
THIRDPARTY
136
THIRDPARTY
@@ -1,244 +1,248 @@
|
||||
annotations 24.0.1
|
||||
annotations
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/java-annotations/blob/master/LICENSE.txt
|
||||
|
||||
bip39-lib-jvm 1.0.8
|
||||
kotlin-bip39
|
||||
MIT License
|
||||
https://github.com/Electric-Coin-Company/kotlin-bip39/blob/main/LICENSE
|
||||
|
||||
colorpicker 2.0.1
|
||||
colorpicker
|
||||
BSD 3-Clause "New" or "Revised" License
|
||||
https://github.com/dheid/colorpicker/blob/main/LICENSE
|
||||
|
||||
commonmark 0.24.0
|
||||
commonmark
|
||||
BSD 2-Clause "Simplified" License
|
||||
https://github.com/commonmark/commonmark-java/blob/main/LICENSE.txt
|
||||
|
||||
commons-codec 1.18.0
|
||||
commons-codec
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-codec/blob/master/LICENSE.txt
|
||||
|
||||
commons-compress 1.27.1
|
||||
commons-compress
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-compress/blob/master/LICENSE.txt
|
||||
|
||||
commons-io 2.18.0
|
||||
commons-vfs2
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-vfs/blob/master/LICENSE.txt
|
||||
|
||||
commons-io
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-io/blob/master/LICENSE.txt
|
||||
|
||||
commons-lang3 3.17.0
|
||||
commons-lang3
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-lang/blob/master/LICENSE.txt
|
||||
|
||||
commons-net 3.11.1
|
||||
commons-net
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-net/blob/master/LICENSE.txt
|
||||
|
||||
commons-text 1.13.0
|
||||
commons-text
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-text/blob/master/LICENSE.txt
|
||||
|
||||
commons-csv 1.13.0
|
||||
commons-csv
|
||||
Apache License 2.0
|
||||
https://github.com/apache/commons-csv/blob/master/LICENSE.txt
|
||||
|
||||
ini4j 0.5.5-2
|
||||
ini4j
|
||||
Apache License 2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
|
||||
eddsa 0.3.0
|
||||
eddsa
|
||||
Creative Commons Zero v1.0 Universal
|
||||
https://github.com/str4d/ed25519-java/blob/master/LICENSE.txt
|
||||
|
||||
flatlaf 3.5.4
|
||||
flatlaf
|
||||
Apache License 2.0
|
||||
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
|
||||
|
||||
flatlaf 3.5.4-no-natives
|
||||
flatlaf-no-natives
|
||||
Apache License 2.0
|
||||
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
|
||||
|
||||
flatlaf-extras 3.5.4
|
||||
flatlaf-extras
|
||||
Apache License 2.0
|
||||
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
|
||||
|
||||
flatlaf-swingx 3.5.4
|
||||
flatlaf-swingx
|
||||
Apache License 2.0
|
||||
https://github.com/JFormDesigner/FlatLaf/blob/main/LICENSE
|
||||
|
||||
JavaEWAH 1.2.3
|
||||
JavaEWAH
|
||||
Apache License 2.0
|
||||
https://github.com/lemire/javaewah/blob/master/LICENSE
|
||||
|
||||
jbr-api 17.1.10.1
|
||||
jbr-api
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/JetBrainsRuntimeApi/blob/main/LICENSE
|
||||
|
||||
jcl-over-slf4j 1.7.36
|
||||
jcl-over-slf4j
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
|
||||
jfa 1.2.0
|
||||
jfa
|
||||
Apache License 2.0
|
||||
https://github.com/0x4a616e/jfa/blob/main/LICENSE
|
||||
|
||||
jgoodies-common 1.8.1
|
||||
jgoodies-common
|
||||
BSD-2-Clause License
|
||||
http://www.opensource.org/licenses/bsd-license.html
|
||||
|
||||
jgoodies-forms 1.9.0
|
||||
jgoodies-forms
|
||||
BSD-2-Clause License
|
||||
http://www.opensource.org/licenses/bsd-license.html
|
||||
|
||||
jna 5.16.0
|
||||
jna
|
||||
Apache License 2.0
|
||||
https://github.com/java-native-access/jna/blob/master/AL2.0
|
||||
|
||||
jna-platform 5.16.0
|
||||
jna-platform
|
||||
Apache License 2.0
|
||||
https://github.com/java-native-access/jna/blob/master/AL2.0
|
||||
|
||||
jnafilechooser-api 1.1.2
|
||||
jnafilechooser-api
|
||||
BSD 3-Clause "New" or "Revised" License
|
||||
https://github.com/steos/jnafilechooser/blob/master/LICENSE
|
||||
|
||||
jnafilechooser-win32 1.1.2
|
||||
jnafilechooser-win32
|
||||
BSD 3-Clause "New" or "Revised" License
|
||||
https://github.com/steos/jnafilechooser/blob/master/LICENSE
|
||||
|
||||
jsvg 1.4.0
|
||||
jsvg
|
||||
MIT License
|
||||
https://github.com/weisJ/jsvg/blob/master/LICENSE
|
||||
|
||||
jSystemThemeDetector 3.9.1
|
||||
jSystemThemeDetector
|
||||
Apache License 2.0
|
||||
https://github.com/Dansoftowner/jSystemThemeDetector/blob/master/LICENSE
|
||||
|
||||
kotlin-logging 1.7.9
|
||||
kotlin-logging
|
||||
Apache License 2.0
|
||||
https://github.com/oshai/kotlin-logging/blob/master/LICENSE
|
||||
|
||||
kotlin-stdlib 2.1.10
|
||||
kotlin-stdlib
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
kotlin-stdlib-jdk7 1.9.10
|
||||
kotlin-stdlib-jdk7
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
kotlin-stdlib-jdk8 1.9.10
|
||||
kotlin-stdlib-jdk8
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
kotlin-stdlib-jdk8 1.9.10
|
||||
kotlin-stdlib-jdk8
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/kotlin/blob/master/license/LICENSE.txt
|
||||
|
||||
restart4j 0.0.1
|
||||
restart4j
|
||||
Apache License 2.0
|
||||
https://github.com/hstyi/restart4j/blob/main/LICENSE
|
||||
|
||||
kotlinx-coroutines-core-jvm 1.10.1
|
||||
kotlinx-coroutines-core
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
kotlinx-coroutines-swing 1.10.1
|
||||
kotlinx-coroutines-swing
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
kotlinx-serialization-core-jvm 1.8.0
|
||||
kotlinx-serialization-json
|
||||
Apache License 2.0
|
||||
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
|
||||
|
||||
kotlinx-serialization-json-jvm 1.8.0
|
||||
Apache License 2.0
|
||||
https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt
|
||||
|
||||
logging-interceptor 4.12.0
|
||||
logging-interceptor
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
okhttp 4.12.0
|
||||
okhttp
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
okio-jvm 3.6.0
|
||||
okio-jvm
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
org.eclipse.jgit.ssh.apache 7.1.0.202411261347-r
|
||||
org.eclipse.jgit.ssh.apache
|
||||
Eclipse Distribution License
|
||||
https://www.eclipse.org/org/documents/edl-v10.php
|
||||
|
||||
org.eclipse.jgit 7.1.0.202411261347-r
|
||||
org.eclipse.jgit.ssh.apache.agent
|
||||
Eclipse Distribution License
|
||||
https://www.eclipse.org/org/documents/edl-v10.php
|
||||
|
||||
oshi-core 6.6.5
|
||||
org.eclipse.jgit
|
||||
Eclipse Distribution License
|
||||
https://www.eclipse.org/org/documents/edl-v10.php
|
||||
|
||||
oshi-core
|
||||
MIT License
|
||||
https://github.com/oshi/oshi/blob/master/LICENSE
|
||||
|
||||
pty4j 0.13.2
|
||||
pty4j
|
||||
Eclipse Public License 1.0
|
||||
https://github.com/JetBrains/pty4j/blob/master/LICENSE
|
||||
|
||||
slf4j-api 2.0.16
|
||||
slf4j-api
|
||||
MIT License
|
||||
https://github.com/qos-ch/slf4j/blob/master/LICENSE.txt
|
||||
|
||||
slf4j-tinylog 2.7.0
|
||||
slf4j-tinylog
|
||||
Apache License 2.0
|
||||
https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt
|
||||
|
||||
sshd-common 2.14.0
|
||||
sshd-common
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
sshd-core 2.14.0
|
||||
sshd-core
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
sshd-osgi 2.14.0
|
||||
sshd-osgi
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
sshd-sftp 2.14.0
|
||||
sshd-sftp
|
||||
Apache License 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
swingx-all 1.6.5-1
|
||||
swingx-all
|
||||
GNU LESSER GENERAL PUBLIC LICENSE v3
|
||||
https://www.gnu.org/licenses/lgpl-3.0
|
||||
|
||||
tinylog-api 2.7.0
|
||||
tinylog-api
|
||||
Apache License 2.0
|
||||
https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt
|
||||
|
||||
tinylog-impl 2.7.0
|
||||
tinylog-impl
|
||||
Apache License 2.0
|
||||
https://github.com/tinylog-org/tinylog/blob/v2.7/license.txt
|
||||
|
||||
versioncompare 1.4.1
|
||||
versioncompare
|
||||
Apache License 2.0
|
||||
https://github.com/G00fY2/version-compare/blob/main/LICENSE
|
||||
|
||||
xodus-compress 2.0.1
|
||||
xodus-compress
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
|
||||
|
||||
xodus-environment 2.0.1
|
||||
xodus-environment
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
|
||||
|
||||
xodus-openAPI 2.0.1
|
||||
xodus-openAPI
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
|
||||
|
||||
xodus-utils 2.0.1
|
||||
xodus-utils
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
|
||||
|
||||
xodus-vfs 2.0.1
|
||||
xodus-vfs
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/xodus/blob/master/LICENSE.txt
|
||||
|
||||
@@ -246,7 +250,7 @@ jediterm
|
||||
Apache License 2.0
|
||||
https://github.com/JetBrains/jediterm/blob/master/LICENSE-APACHE-2.0.txt
|
||||
|
||||
mixpanel-java 1.5.3
|
||||
mixpanel-java
|
||||
Apache License 2.0
|
||||
https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
|
||||
|
||||
@@ -254,6 +258,6 @@ json-20231013
|
||||
Public Domain.
|
||||
https://github.com/stleary/JSON-java/blob/master/LICENSE
|
||||
|
||||
jSerialComm 2.11.0
|
||||
jSerialComm
|
||||
Apache License 2.0
|
||||
https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0
|
||||
144
build.gradle.kts
144
build.gradle.kts
@@ -14,13 +14,14 @@ plugins {
|
||||
java
|
||||
idea
|
||||
application
|
||||
`maven-publish`
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
}
|
||||
|
||||
|
||||
group = "app.termora"
|
||||
version = "1.0.11"
|
||||
version = "1.0.15"
|
||||
|
||||
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
|
||||
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
|
||||
@@ -56,64 +57,67 @@ dependencies {
|
||||
|
||||
// implementation(platform(libs.koin.bom))
|
||||
// implementation(libs.koin.core)
|
||||
implementation(libs.slf4j.api)
|
||||
implementation(libs.pty4j)
|
||||
implementation(libs.slf4j.tinylog)
|
||||
implementation(libs.tinylog.impl)
|
||||
implementation(libs.commons.codec)
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.commons.lang3)
|
||||
implementation(libs.commons.csv)
|
||||
implementation(libs.commons.net)
|
||||
implementation(libs.commons.text)
|
||||
implementation(libs.commons.compress)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
api(libs.slf4j.api)
|
||||
api(libs.pty4j)
|
||||
api(libs.slf4j.tinylog)
|
||||
api(libs.tinylog.impl)
|
||||
api(libs.commons.codec)
|
||||
api(libs.commons.io)
|
||||
api(libs.commons.lang3)
|
||||
api(libs.commons.csv)
|
||||
api(libs.commons.net)
|
||||
api(libs.commons.text)
|
||||
api(libs.commons.compress)
|
||||
api(libs.commons.vfs2) { exclude(group = "*", module = "*") }
|
||||
api(libs.kotlinx.coroutines.swing)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
|
||||
implementation(libs.flatlaf) {
|
||||
api(libs.flatlaf) {
|
||||
artifact {
|
||||
if (useNoNativesFlatLaf) {
|
||||
classifier = "no-natives"
|
||||
}
|
||||
}
|
||||
}
|
||||
implementation(libs.flatlaf.extras) {
|
||||
api(libs.flatlaf.extras) {
|
||||
if (useNoNativesFlatLaf) {
|
||||
exclude(group = "com.formdev", module = "flatlaf")
|
||||
}
|
||||
}
|
||||
implementation(libs.flatlaf.swingx) {
|
||||
api(libs.flatlaf.swingx) {
|
||||
if (useNoNativesFlatLaf) {
|
||||
exclude(group = "com.formdev", module = "flatlaf")
|
||||
}
|
||||
}
|
||||
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.swingx)
|
||||
implementation(libs.jgoodies.forms)
|
||||
implementation(libs.jna)
|
||||
implementation(libs.jna.platform)
|
||||
implementation(libs.versioncompare)
|
||||
implementation(libs.oshi.core)
|
||||
implementation(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") }
|
||||
implementation(libs.jfa) { exclude(group = "*", module = "*") }
|
||||
implementation(libs.jbr.api)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.sshd.core)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.jgit)
|
||||
implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
|
||||
implementation(libs.jnafilechooser)
|
||||
implementation(libs.xodus.vfs)
|
||||
implementation(libs.xodus.openAPI)
|
||||
implementation(libs.xodus.environment)
|
||||
implementation(libs.bip39)
|
||||
implementation(libs.colorpicker)
|
||||
implementation(libs.mixpanel)
|
||||
implementation(libs.jSerialComm)
|
||||
implementation(libs.ini4j)
|
||||
implementation(libs.restart4j)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.swingx)
|
||||
api(libs.jgoodies.forms)
|
||||
api(libs.jna)
|
||||
api(libs.jna.platform)
|
||||
api(libs.versioncompare)
|
||||
api(libs.oshi.core)
|
||||
api(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") }
|
||||
api(libs.jfa) { exclude(group = "*", module = "*") }
|
||||
api(libs.jbr.api)
|
||||
api(libs.okhttp)
|
||||
api(libs.okhttp.logging)
|
||||
api(libs.sshd.core)
|
||||
api(libs.commonmark)
|
||||
api(libs.jgit)
|
||||
api(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
|
||||
api(libs.jgit.agent) { exclude(group = "*", module = "sshd-osgi") }
|
||||
api(libs.eddsa)
|
||||
api(libs.jnafilechooser)
|
||||
api(libs.xodus.vfs)
|
||||
api(libs.xodus.openAPI)
|
||||
api(libs.xodus.environment)
|
||||
api(libs.bip39)
|
||||
api(libs.colorpicker)
|
||||
api(libs.mixpanel)
|
||||
api(libs.jSerialComm)
|
||||
api(libs.ini4j)
|
||||
api(libs.restart4j)
|
||||
}
|
||||
|
||||
application {
|
||||
@@ -144,6 +148,37 @@ application {
|
||||
mainClass = "app.termora.MainKt"
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("mavenJava") {
|
||||
from(components["java"])
|
||||
pom {
|
||||
name = project.name
|
||||
description = "Termora is a terminal emulator and SSH client for Windows, macOS and Linux"
|
||||
url = "https://github.com/TermoraDev/termora"
|
||||
|
||||
licenses {
|
||||
license {
|
||||
name = "AGPL-3.0"
|
||||
url = "https://opensource.org/license/agpl-v3"
|
||||
}
|
||||
}
|
||||
|
||||
developers {
|
||||
developer {
|
||||
name = "hstyi"
|
||||
url = "https://github.com/hstyi"
|
||||
}
|
||||
}
|
||||
|
||||
scm {
|
||||
url = "https://github.com/TermoraDev/termora"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -455,33 +490,26 @@ tasks.register("dist") {
|
||||
|
||||
tasks.register("check-license") {
|
||||
doLast {
|
||||
val thirdParty = mutableMapOf<String, String>()
|
||||
val iterator = File(projectDir, "THIRDPARTY").readLines().iterator()
|
||||
val thirdPartyNames = mutableSetOf<String>()
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
val nameWithVersion = iterator.next()
|
||||
if (nameWithVersion.isBlank()) {
|
||||
val name = iterator.next()
|
||||
if (name.isBlank()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// ignore license name
|
||||
iterator.next()
|
||||
// ignore license url
|
||||
iterator.next()
|
||||
|
||||
val license = iterator.next()
|
||||
thirdParty[nameWithVersion.replace(StringUtils.SPACE, "-")] = license
|
||||
thirdPartyNames.add(nameWithVersion.split(StringUtils.SPACE).first())
|
||||
thirdPartyNames.add(name)
|
||||
}
|
||||
|
||||
for (file in configurations.runtimeClasspath.get()) {
|
||||
val name = file.nameWithoutExtension
|
||||
if (!thirdParty.containsKey(name)) {
|
||||
if (logger.isWarnEnabled) {
|
||||
logger.warn("$name does not exist in third-party")
|
||||
}
|
||||
if (!thirdPartyNames.contains(name)) {
|
||||
throw GradleException("$name No license found")
|
||||
}
|
||||
for (dependency in configurations.runtimeClasspath.get().allDependencies) {
|
||||
if (!thirdPartyNames.contains(dependency.name)) {
|
||||
throw GradleException("${dependency.name} No license found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,46 @@
|
||||
[versions]
|
||||
kotlin = "2.1.10"
|
||||
slf4j = "2.0.16"
|
||||
pty4j = "0.13.2"
|
||||
kotlin = "2.1.21"
|
||||
slf4j = "2.0.17"
|
||||
pty4j = "0.13.4"
|
||||
tinylog = "2.7.0"
|
||||
kotlinx-coroutines = "1.10.1"
|
||||
flatlaf = "3.5.4"
|
||||
trove4j = "1.0.20200330"
|
||||
kotlinx-serialization-json = "1.8.0"
|
||||
kotlinx-coroutines = "1.10.2"
|
||||
flatlaf = "3.6"
|
||||
kotlinx-serialization-json = "1.8.1"
|
||||
commons-codec = "1.18.0"
|
||||
commons-lang3 = "3.17.0"
|
||||
commons-csv = "1.13.0"
|
||||
commons-csv = "1.14.0"
|
||||
commons-net = "3.11.1"
|
||||
commons-text = "1.13.0"
|
||||
commons-text = "1.13.1"
|
||||
commons-compress = "1.27.1"
|
||||
koin-bom = "4.0.0"
|
||||
commons-vfs2="2.10.0"
|
||||
swingx = "1.6.5-1"
|
||||
jgoodies-forms = "1.9.0"
|
||||
jfa = "1.2.0"
|
||||
oshi = "6.6.5"
|
||||
oshi = "6.8.1"
|
||||
versioncompare = "1.4.1"
|
||||
jna = "5.16.0"
|
||||
jna = "5.17.0"
|
||||
jSystemThemeDetector = "3.9.1"
|
||||
commons-io = "2.18.0"
|
||||
commons-io = "2.19.0"
|
||||
jbr-api = "17.1.10.1"
|
||||
leveldb = "0.12"
|
||||
guava = "33.3.1-jre"
|
||||
credential-secure-storage = "1.0.3"
|
||||
hutool = "5.8.34"
|
||||
jsch = "0.2.21"
|
||||
hutool = "5.8.37"
|
||||
jsch = "0.2.26"
|
||||
okhttp = "4.12.0"
|
||||
bcprov = "1.79"
|
||||
sshj = "0.39.0"
|
||||
sshd-core = "2.14.0"
|
||||
jgit = "7.1.0.202411261347-r"
|
||||
sshd-core = "2.15.0"
|
||||
jgit = "7.2.0.202503040940-r"
|
||||
commonmark = "0.24.0"
|
||||
jnafilechooser = "1.1.2"
|
||||
xodus = "2.0.1"
|
||||
bip39 = "1.0.8"
|
||||
bip39 = "1.0.9"
|
||||
colorpicker = "2.0.1"
|
||||
rhino = "1.7.15"
|
||||
rhino = "1.8.0"
|
||||
delight-rhino-sandbox = "0.0.17"
|
||||
testcontainers = "1.20.4"
|
||||
testcontainers = "1.21.0"
|
||||
mixpanel = "1.5.3"
|
||||
jSerialComm = "2.11.0"
|
||||
ini4j = "0.5.5-2"
|
||||
restart4j = "0.0.1"
|
||||
eddsa = "0.3.0"
|
||||
|
||||
[libraries]
|
||||
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
@@ -59,15 +55,13 @@ commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version.
|
||||
commons-csv = { group = "org.apache.commons", name = "commons-csv", version.ref = "commons-csv" }
|
||||
commons-text = { group = "org.apache.commons", name = "commons-text", version.ref = "commons-text" }
|
||||
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commons-compress" }
|
||||
commons-vfs2 = { group = "org.apache.commons", name = "commons-vfs2", version.ref = "commons-vfs2" }
|
||||
pty4j = { group = "org.jetbrains.pty4j", name = "pty4j", version.ref = "pty4j" }
|
||||
ini4j = { module = "org.jetbrains.intellij.deps:ini4j", version.ref = "ini4j" }
|
||||
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
|
||||
flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" }
|
||||
trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" }
|
||||
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
|
||||
testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" }
|
||||
testcontainers = { module = "org.testcontainers:testcontainers" }
|
||||
koin-core = { module = "io.insert-koin:koin-core" }
|
||||
swingx = { module = "org.swinglabs.swingx:swingx-all", version.ref = "swingx" }
|
||||
jgoodies-forms = { module = "com.jgoodies:jgoodies-forms", version.ref = "jgoodies-forms" }
|
||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||
@@ -80,31 +74,27 @@ commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" }
|
||||
restart4j = { module = "com.github.hstyi:restart4j", version.ref = "restart4j" }
|
||||
jbr-api = { module = "com.jetbrains:jbr-api", version.ref = "jbr-api" }
|
||||
flatlaf-swingx = { module = "com.formdev:flatlaf-swingx", version.ref = "flatlaf" }
|
||||
leveldb = { module = "org.iq80.leveldb:leveldb", version.ref = "leveldb" }
|
||||
guava = { module = "com.google.guava:guava", version.ref = "guava" }
|
||||
hutool = { module = "cn.hutool:hutool-all", version.ref = "hutool" }
|
||||
credential-secure-storage = { module = "com.microsoft:credential-secure-storage", version.ref = "credential-secure-storage" }
|
||||
jsch = { module = "com.github.mwiede:jsch", version.ref = "jsch" }
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||
bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" }
|
||||
sshj = { module = "com.hierynomus:sshj", version.ref = "sshj" }
|
||||
sshd-core = { module = "org.apache.sshd:sshd-core", version.ref = "sshd-core" }
|
||||
jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" }
|
||||
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
|
||||
jgit-sshd = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache", version.ref = "jgit" }
|
||||
jgit-agent = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache.agent", version.ref = "jgit" }
|
||||
xodus-openAPI = { module = "org.jetbrains.xodus:xodus-openAPI", version.ref = "xodus" }
|
||||
xodus-entity-store = { module = "org.jetbrains.xodus:xodus-entity-store", version.ref = "xodus" }
|
||||
xodus-environment = { module = "org.jetbrains.xodus:xodus-environment", version.ref = "xodus" }
|
||||
xodus-crypto = { module = "org.jetbrains.xodus:xodus-crypto", version.ref = "xodus" }
|
||||
xodus-vfs = { module = "org.jetbrains.xodus:xodus-vfs", version.ref = "xodus" }
|
||||
jnafilechooser = { module = "com.github.steos.jnafilechooser:jnafilechooser-api", version.ref = "jnafilechooser" }
|
||||
bip39 = { module = "cash.z.ecc.android:kotlin-bip39-jvm", version.ref = "bip39" }
|
||||
bip39 = { module = "cash.z.ecc.android:kotlin-bip39", version.ref = "bip39" }
|
||||
rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
|
||||
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
|
||||
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
|
||||
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
|
||||
jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" }
|
||||
eddsa = { module = "net.i2p.crypto:eddsa", version.ref = "eddsa" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
|
||||
}
|
||||
rootProject.name = "termora"
|
||||
|
||||
|
||||
70
src/main/java/app/termora/CombinedKeyIdentityProvider.java
Normal file
70
src/main/java/app/termora/CombinedKeyIdentityProvider.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package app.termora;
|
||||
|
||||
import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
|
||||
import org.apache.sshd.common.session.SessionContext;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyPair;
|
||||
import java.util.*;
|
||||
|
||||
@Deprecated
|
||||
public class CombinedKeyIdentityProvider implements KeyIdentityProvider {
|
||||
|
||||
private final List<KeyIdentityProvider> providers = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public Iterable<KeyPair> loadKeys(SessionContext context) {
|
||||
return () -> new Iterator<>() {
|
||||
|
||||
private final Iterator<KeyIdentityProvider> factories = providers
|
||||
.iterator();
|
||||
private Iterator<KeyPair> current;
|
||||
|
||||
private Boolean hasElement;
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
if (hasElement != null) {
|
||||
return hasElement;
|
||||
}
|
||||
while (current == null || !current.hasNext()) {
|
||||
if (factories.hasNext()) {
|
||||
try {
|
||||
current = factories.next().loadKeys(context)
|
||||
.iterator();
|
||||
} catch (IOException | GeneralSecurityException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
} else {
|
||||
current = null;
|
||||
hasElement = Boolean.FALSE;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
hasElement = Boolean.TRUE;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyPair next() {
|
||||
if ((hasElement == null && !hasNext()) || !hasElement) {
|
||||
throw new NoSuchElementException();
|
||||
}
|
||||
hasElement = null;
|
||||
KeyPair result;
|
||||
try {
|
||||
result = current.next();
|
||||
} catch (NoSuchElementException e) {
|
||||
result = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
public void addKeyKeyIdentityProvider(KeyIdentityProvider provider) {
|
||||
providers.add(Objects.requireNonNull(provider));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import static com.formdev.flatlaf.util.UIScale.scale;
|
||||
/**
|
||||
* 如果要升级 FlatLaf 需要检查是否兼容
|
||||
*/
|
||||
@Deprecated
|
||||
public class MyFlatTabbedPaneUI extends FlatTabbedPaneUI {
|
||||
@Override
|
||||
protected void paintContentBorder(Graphics g, int tabPlacement, int selectedIndex) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package app.termora
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jthemedetecor.util.OsInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -123,19 +122,18 @@ object Application {
|
||||
return "Termora"
|
||||
}
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
fun browse(uri: URI, async: Boolean = true) {
|
||||
// https://github.com/TermoraDev/termora/issues/178
|
||||
if (SystemInfo.isWindows && uri.scheme == "file") {
|
||||
if (async) {
|
||||
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { tryBrowse(uri) }
|
||||
} else {
|
||||
tryBrowse(uri)
|
||||
}
|
||||
} else if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
Desktop.getDesktop().browse(uri)
|
||||
} else if (async) {
|
||||
GlobalScope.launch(Dispatchers.IO) { tryBrowse(uri) }
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { tryBrowse(uri) }
|
||||
} else {
|
||||
tryBrowse(uri)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.termora
|
||||
|
||||
import app.termora.actions.ActionManager
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.vfs2.sftp.MySftpFileProvider
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatSystemProperties
|
||||
import com.formdev.flatlaf.extras.FlatDesktop
|
||||
@@ -12,16 +13,29 @@ import com.jthemedetecor.OsThemeDetector
|
||||
import com.mixpanel.mixpanelapi.ClientDelivery
|
||||
import com.mixpanel.mixpanelapi.MessageBuilder
|
||||
import com.mixpanel.mixpanelapi.MixpanelAPI
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.LocaleUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.cache.WeakRefFilesCache
|
||||
import org.apache.commons.vfs2.impl.DefaultFileSystemManager
|
||||
import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider
|
||||
import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.MenuItem
|
||||
import java.awt.PopupMenu
|
||||
import java.awt.SystemTray
|
||||
import java.awt.TrayIcon
|
||||
import java.awt.desktop.AppReopenedEvent
|
||||
import java.awt.desktop.AppReopenedListener
|
||||
import java.awt.desktop.SystemEventListener
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.WindowEvent
|
||||
import java.util.*
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.*
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
@@ -44,11 +58,20 @@ class ApplicationRunner {
|
||||
// 统计
|
||||
val enableAnalytics = measureTimeMillis { enableAnalytics() }
|
||||
|
||||
// init ActionManager、KeymapManager
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
// init ActionManager、KeymapManager、VFS
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
ActionManager.getInstance()
|
||||
KeymapManager.getInstance()
|
||||
|
||||
val fileSystemManager = DefaultFileSystemManager()
|
||||
fileSystemManager.addProvider("sftp", MySftpFileProvider())
|
||||
fileSystemManager.addProvider("file", DefaultLocalFileProvider())
|
||||
fileSystemManager.filesCache = WeakRefFilesCache()
|
||||
fileSystemManager.init()
|
||||
VFS.setManager(fileSystemManager)
|
||||
|
||||
// async init
|
||||
BackgroundManager.getInstance().getBackgroundImage()
|
||||
}
|
||||
|
||||
// 设置 LAF
|
||||
@@ -79,9 +102,8 @@ class ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
private fun clearTemporary() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
// 启动时清除
|
||||
FileUtils.cleanDirectory(Application.getTemporaryDir())
|
||||
}
|
||||
@@ -101,15 +123,66 @@ class ApplicationRunner {
|
||||
|
||||
TermoraFrameManager.getInstance().createWindow().isVisible = true
|
||||
|
||||
if (SystemUtils.IS_OS_MAC_OSX) {
|
||||
SwingUtilities.invokeLater { FlatDesktop.setQuitHandler { quitHandler() } }
|
||||
if (SystemInfo.isMacOS) {
|
||||
SwingUtilities.invokeLater {
|
||||
|
||||
try {
|
||||
// 设置 Dock
|
||||
setupMacOSDock()
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Command + Q
|
||||
FlatDesktop.setQuitHandler { quitHandler() }
|
||||
}
|
||||
} else if (SystemInfo.isWindows) {
|
||||
// 设置托盘
|
||||
SwingUtilities.invokeLater { setupSystemTray() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSystemTray() {
|
||||
if (!SystemInfo.isWindows || !SystemTray.isSupported()) return
|
||||
|
||||
val tray = SystemTray.getSystemTray()
|
||||
val image = ImageIO.read(TermoraFrame::class.java.getResourceAsStream("/icons/termora_16x16.png"))
|
||||
val trayIcon = TrayIcon(image)
|
||||
val popupMenu = PopupMenu()
|
||||
trayIcon.popupMenu = popupMenu
|
||||
trayIcon.toolTip = Application.getName()
|
||||
|
||||
// PopupMenu 不支持中文
|
||||
val exitMenu = MenuItem("Exit")
|
||||
exitMenu.addActionListener { SwingUtilities.invokeLater { quitHandler() } }
|
||||
popupMenu.add(exitMenu)
|
||||
|
||||
// double click
|
||||
trayIcon.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
TermoraFrameManager.getInstance().tick()
|
||||
}
|
||||
})
|
||||
|
||||
tray.add(trayIcon)
|
||||
|
||||
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
|
||||
override fun dispose() {
|
||||
tray.remove(trayIcon)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun quitHandler() {
|
||||
for (frame in TermoraFrameManager.getInstance().getWindows()) {
|
||||
frame.dispose()
|
||||
val windows = TermoraFrameManager.getInstance().getWindows()
|
||||
|
||||
for (frame in windows) {
|
||||
frame.dispatchEvent(WindowEvent(frame, WindowEvent.WINDOW_CLOSED))
|
||||
}
|
||||
|
||||
Disposer.dispose(TermoraFrameManager.getInstance())
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
@@ -191,7 +264,35 @@ class ApplicationRunner {
|
||||
|
||||
UIManager.put("List.selectionArc", UIManager.getInt("Component.arc"))
|
||||
|
||||
}
|
||||
|
||||
private fun setupMacOSDock() {
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
val cls = Class.forName("com.apple.eawt.Application")
|
||||
val app = cls.getMethod("getApplication").invoke(null)
|
||||
val addAppEventListener = cls.getMethod("addAppEventListener", SystemEventListener::class.java)
|
||||
|
||||
addAppEventListener.invoke(app, object : AppReopenedListener {
|
||||
override fun appReopened(e: AppReopenedEvent) {
|
||||
val manager = TermoraFrameManager.getInstance()
|
||||
if (manager.getWindows().isEmpty()) {
|
||||
manager.createWindow().isVisible = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 当应用程序销毁时,驻守线程也可以退出了
|
||||
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
|
||||
override fun dispose() {
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
})
|
||||
|
||||
// 驻守线程,不然当所有窗口都关闭时,程序会自动退出
|
||||
// wait application exit
|
||||
Thread.ofPlatform().daemon(false)
|
||||
.priority(Thread.MIN_PRIORITY)
|
||||
.start { countDownLatch.await() }
|
||||
}
|
||||
|
||||
private fun printSystemInfo() {
|
||||
@@ -232,13 +333,12 @@ class ApplicationRunner {
|
||||
/**
|
||||
* 统计 https://mixpanel.com
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun enableAnalytics() {
|
||||
if (Application.isUnknownVersion()) {
|
||||
return
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val properties = JSONObject()
|
||||
properties.put("os", SystemUtils.OS_NAME)
|
||||
|
||||
88
src/main/kotlin/app/termora/BackgroundManager.kt
Normal file
88
src/main/kotlin/app/termora/BackgroundManager.kt
Normal file
@@ -0,0 +1,88 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.JPopupMenu
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class BackgroundManager private constructor() {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(BackgroundManager::class.java)
|
||||
fun getInstance(): BackgroundManager {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(BackgroundManager::class) { BackgroundManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val appearance get() = Database.getDatabase().appearance
|
||||
private var bufferedImage: BufferedImage? = null
|
||||
private var imageFilepath = StringUtils.EMPTY
|
||||
|
||||
fun setBackgroundImage(file: File) {
|
||||
synchronized(this) {
|
||||
try {
|
||||
bufferedImage = file.inputStream().use { ImageIO.read(it) }
|
||||
imageFilepath = file.absolutePath
|
||||
appearance.backgroundImage = file.absolutePath
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
for (window in TermoraFrameManager.getInstance().getWindows()) {
|
||||
SwingUtilities.updateComponentTreeUI(window)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getBackgroundImage(): BufferedImage? {
|
||||
val bg = doGetBackgroundImage()
|
||||
if (bg == null) {
|
||||
if (JPopupMenu.getDefaultLightWeightPopupEnabled()) {
|
||||
return null
|
||||
} else {
|
||||
JPopupMenu.setDefaultLightWeightPopupEnabled(true)
|
||||
}
|
||||
} else {
|
||||
if (JPopupMenu.getDefaultLightWeightPopupEnabled()) {
|
||||
JPopupMenu.setDefaultLightWeightPopupEnabled(false)
|
||||
}
|
||||
}
|
||||
return bg
|
||||
}
|
||||
|
||||
private fun doGetBackgroundImage(): BufferedImage? {
|
||||
synchronized(this) {
|
||||
if (bufferedImage == null || imageFilepath.isEmpty()) {
|
||||
if (appearance.backgroundImage.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val file = File(appearance.backgroundImage)
|
||||
if (file.exists()) {
|
||||
setBackgroundImage(file)
|
||||
}
|
||||
}
|
||||
|
||||
return bufferedImage
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBackgroundImage() {
|
||||
synchronized(this) {
|
||||
bufferedImage = null
|
||||
imageFilepath = StringUtils.EMPTY
|
||||
appearance.backgroundImage = StringUtils.EMPTY
|
||||
SwingUtilities.invokeLater {
|
||||
for (window in TermoraFrameManager.getInstance().getWindows()) {
|
||||
SwingUtilities.updateComponentTreeUI(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,12 @@ import app.termora.keymap.Keymap
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import app.termora.macro.Macro
|
||||
import app.termora.snippet.Snippet
|
||||
import app.termora.sync.SyncManager
|
||||
import app.termora.sync.SyncType
|
||||
import app.termora.terminal.CursorStyle
|
||||
import jetbrains.exodus.bindings.StringBinding
|
||||
import jetbrains.exodus.env.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
@@ -30,6 +30,7 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
private const val KEYWORD_HIGHLIGHT_STORE = "KeywordHighlight"
|
||||
private const val MACRO_STORE = "Macro"
|
||||
private const val KEY_PAIR_STORE = "KeyPair"
|
||||
private const val DELETED_DATA_STORE = "DeletedData"
|
||||
private val log = LoggerFactory.getLogger(Database::class.java)
|
||||
|
||||
|
||||
@@ -142,6 +143,37 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeHost(id: String) {
|
||||
env.executeInTransaction {
|
||||
delete(it, HOST_STORE, id)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed host: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addDeletedData(deletedData: DeletedData) {
|
||||
val text = ohMyJson.encodeToString(deletedData)
|
||||
env.executeInTransaction {
|
||||
put(it, DELETED_DATA_STORE, deletedData.id, text)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Added DeletedData: ${deletedData.id} , $text")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getDeletedData(): Collection<DeletedData> {
|
||||
return env.computeInTransaction { tx ->
|
||||
openCursor<DeletedData?>(tx, DELETED_DATA_STORE) { _, value ->
|
||||
try {
|
||||
ohMyJson.decodeFromString(value)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}.values.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun addSnippet(snippet: Snippet) {
|
||||
var text = ohMyJson.encodeToString(snippet)
|
||||
if (doorman.isWorking()) {
|
||||
@@ -155,6 +187,14 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSnippet(id: String) {
|
||||
env.executeInTransaction {
|
||||
delete(it, SNIPPET_STORE, id)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed snippet: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSnippets(): Collection<Snippet> {
|
||||
val isWorking = doorman.isWorking()
|
||||
@@ -249,6 +289,18 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
val k = StringBinding.stringToEntry(key)
|
||||
val v = StringBinding.stringToEntry(value)
|
||||
store.put(tx, k, v)
|
||||
|
||||
// 数据变动时触发一次同步
|
||||
if (name == HOST_STORE ||
|
||||
name == KEYMAP_STORE ||
|
||||
name == SNIPPET_STORE ||
|
||||
name == KEYWORD_HIGHLIGHT_STORE ||
|
||||
name == MACRO_STORE ||
|
||||
name == KEY_PAIR_STORE ||
|
||||
name == DELETED_DATA_STORE
|
||||
) {
|
||||
SyncManager.getInstance().triggerOnChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete(tx: Transaction, name: String, key: String) {
|
||||
@@ -308,8 +360,7 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
private val properties = Collections.synchronizedMap(mutableMapOf<String, String>())
|
||||
|
||||
init {
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) { properties.putAll(getProperties()) }
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { properties.putAll(getProperties()) }
|
||||
}
|
||||
|
||||
protected open fun getString(key: String): String? {
|
||||
@@ -381,6 +432,13 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
protected inner class DoublePropertyDelegate(defaultValue: Double) :
|
||||
PropertyDelegate<Double>(defaultValue) {
|
||||
override fun convertValue(value: String): Double {
|
||||
return value.toDoubleOrNull() ?: initializer.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected inner class LongPropertyDelegate(defaultValue: Long) :
|
||||
PropertyDelegate<Long>(defaultValue) {
|
||||
@@ -465,6 +523,11 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
*/
|
||||
var beep by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 超链接
|
||||
*/
|
||||
var hyperlink by BooleanPropertyDelegate(true)
|
||||
|
||||
/**
|
||||
* 光标闪烁
|
||||
*/
|
||||
@@ -580,6 +643,16 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
var darkTheme by StringPropertyDelegate("Dark")
|
||||
var lightTheme by StringPropertyDelegate("Light")
|
||||
|
||||
/**
|
||||
* 允许后台运行,也就是托盘
|
||||
*/
|
||||
var backgroundRunning by BooleanPropertyDelegate(false)
|
||||
|
||||
/**
|
||||
* 背景图片的地址
|
||||
*/
|
||||
var backgroundImage by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
|
||||
/**
|
||||
* 语言
|
||||
*/
|
||||
@@ -587,6 +660,11 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
I18n.containsLanguage(Locale.getDefault()) ?: Locale.US.toString()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 透明度
|
||||
*/
|
||||
var opacity by DoublePropertyDelegate(1.0)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -662,6 +740,11 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
* 最后同步时间
|
||||
*/
|
||||
var lastSyncTime by LongPropertyDelegate(0L)
|
||||
|
||||
/**
|
||||
* 同步策略,为空就是默认手动
|
||||
*/
|
||||
var policy by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
|
||||
52
src/main/kotlin/app/termora/DeleteDataManager.kt
Normal file
52
src/main/kotlin/app/termora/DeleteDataManager.kt
Normal file
@@ -0,0 +1,52 @@
|
||||
package app.termora
|
||||
|
||||
/**
|
||||
* 仅标记
|
||||
*/
|
||||
class DeleteDataManager private constructor() {
|
||||
companion object {
|
||||
fun getInstance(): DeleteDataManager {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(DeleteDataManager::class) { DeleteDataManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val data = mutableMapOf<String, DeletedData>()
|
||||
private val database get() = Database.getDatabase()
|
||||
|
||||
fun removeHost(id: String, deleteDate: Long = System.currentTimeMillis()) {
|
||||
addDeletedData(DeletedData(id, "Host", deleteDate))
|
||||
}
|
||||
|
||||
fun removeKeymap(id: String, deleteDate: Long = System.currentTimeMillis()) {
|
||||
addDeletedData(DeletedData(id, "Keymap", deleteDate))
|
||||
}
|
||||
|
||||
fun removeKeyPair(id: String, deleteDate: Long = System.currentTimeMillis()) {
|
||||
addDeletedData(DeletedData(id, "KeyPair", deleteDate))
|
||||
}
|
||||
|
||||
fun removeKeywordHighlight(id: String, deleteDate: Long = System.currentTimeMillis()) {
|
||||
addDeletedData(DeletedData(id, "KeywordHighlight", deleteDate))
|
||||
}
|
||||
|
||||
fun removeMacro(id: String, deleteDate: Long = System.currentTimeMillis()) {
|
||||
addDeletedData(DeletedData(id, "Macro", deleteDate))
|
||||
}
|
||||
|
||||
fun removeSnippet(id: String, deleteDate: Long = System.currentTimeMillis()) {
|
||||
addDeletedData(DeletedData(id, "Snippet", deleteDate))
|
||||
}
|
||||
|
||||
private fun addDeletedData(deletedData: DeletedData) {
|
||||
if (data.containsKey(deletedData.id)) return
|
||||
data[deletedData.id] = deletedData
|
||||
database.addDeletedData(deletedData)
|
||||
}
|
||||
|
||||
fun getDeletedData(): List<DeletedData> {
|
||||
if (data.isEmpty()) {
|
||||
data.putAll(database.getDeletedData().associateBy { it.id })
|
||||
}
|
||||
return data.values.sortedBy { it.deleteDate }
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
|
||||
return
|
||||
}
|
||||
|
||||
doCancelAction()
|
||||
SwingUtilities.invokeLater { doCancelAction() }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
||||
init {
|
||||
generalOption.portTextField.value = host.port
|
||||
@@ -13,6 +16,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
||||
generalOption.passwordTextField.text = host.authentication.password
|
||||
} else if (host.authentication.type == AuthenticationType.PublicKey) {
|
||||
generalOption.publicKeyComboBox.selectedItem = host.authentication.password
|
||||
} else if (host.authentication.type == AuthenticationType.SSHAgent) {
|
||||
generalOption.sshAgentComboBox.selectedItem = host.authentication.password
|
||||
}
|
||||
|
||||
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
|
||||
@@ -28,6 +33,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
||||
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
|
||||
|
||||
tunnelingOption.tunnelings.addAll(host.tunnelings)
|
||||
tunnelingOption.x11ForwardingCheckBox.isSelected = host.options.enableX11Forwarding
|
||||
tunnelingOption.x11ServerTextField.text = StringUtils.defaultIfBlank(host.options.x11Forwarding, "localhost:0")
|
||||
|
||||
if (host.options.jumpHosts.isNotEmpty()) {
|
||||
val hosts = HostManager.getInstance().hosts().associateBy { it.id }
|
||||
|
||||
@@ -25,6 +25,7 @@ enum class Protocol {
|
||||
SSH,
|
||||
Local,
|
||||
Serial,
|
||||
RDP,
|
||||
|
||||
/**
|
||||
* 交互式的 SFTP,此协议只在系统内部交互不应该暴露给用户也不应该持久化
|
||||
@@ -38,6 +39,7 @@ enum class AuthenticationType {
|
||||
No,
|
||||
Password,
|
||||
PublicKey,
|
||||
SSHAgent,
|
||||
KeyboardInteractive,
|
||||
}
|
||||
|
||||
@@ -137,6 +139,16 @@ data class Options(
|
||||
* SFTP 默认目录
|
||||
*/
|
||||
val sftpDefaultDirectory: String = StringUtils.EMPTY,
|
||||
|
||||
/**
|
||||
* X11 Forwarding
|
||||
*/
|
||||
val enableX11Forwarding: Boolean = false,
|
||||
|
||||
/**
|
||||
* X11 Server,Format: host.port. default: localhost:0
|
||||
*/
|
||||
val x11Forwarding: String = StringUtils.EMPTY,
|
||||
) {
|
||||
companion object {
|
||||
val Default = Options()
|
||||
@@ -214,6 +226,27 @@ data class EncryptedHost(
|
||||
var updateDate: Long = 0L,
|
||||
)
|
||||
|
||||
/**
|
||||
* 被删除的数据
|
||||
*/
|
||||
@Serializable
|
||||
data class DeletedData(
|
||||
/**
|
||||
* 被删除的 ID
|
||||
*/
|
||||
val id: String = StringUtils.EMPTY,
|
||||
|
||||
/**
|
||||
* 数据类型:Host、Keymap、KeyPair、KeywordHighlight、Macro、Snippet
|
||||
*/
|
||||
val type: String = StringUtils.EMPTY,
|
||||
|
||||
/**
|
||||
* 被删除的时间
|
||||
*/
|
||||
val deleteDate: Long,
|
||||
)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class Host(
|
||||
|
||||
@@ -2,15 +2,17 @@ package app.termora
|
||||
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
|
||||
class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
|
||||
@@ -52,9 +54,9 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
|
||||
putValue(NAME, "${I18n.getString("termora.new-host.test-connection")}...")
|
||||
isEnabled = false
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
testConnection(pane.getHost())
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
// 因为测试连接的时候从数据库读取会导致失效,所以这里生成随机ID
|
||||
testConnection(pane.getHost().copy(id = UUID.randomUUID().toSimpleString()))
|
||||
withContext(Dispatchers.Swing) {
|
||||
putValue(NAME, I18n.getString("termora.new-host.test-connection"))
|
||||
isEnabled = true
|
||||
@@ -103,8 +105,7 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
|
||||
var client: SshClient? = null
|
||||
var session: ClientSession? = null
|
||||
try {
|
||||
client = SshClients.openClient(host)
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
client = SshClients.openClient(host, this)
|
||||
session = SshClients.openSession(host, client)
|
||||
} finally {
|
||||
session?.close()
|
||||
|
||||
@@ -16,14 +16,20 @@ class HostManager private constructor() {
|
||||
*/
|
||||
fun addHost(host: Host) {
|
||||
assertEventDispatchThread()
|
||||
database.addHost(host)
|
||||
if (host.deleted) {
|
||||
hosts.entries.removeIf { it.value.id == host.id || it.value.parentId == host.id }
|
||||
removeHost(host.id)
|
||||
} else {
|
||||
database.addHost(host)
|
||||
hosts[host.id] = host
|
||||
}
|
||||
}
|
||||
|
||||
fun removeHost(id: String) {
|
||||
hosts.entries.removeIf { it.value.id == id || it.value.parentId == id }
|
||||
database.removeHost(id)
|
||||
DeleteDataManager.getInstance().removeHost(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 第一次调用从数据库中获取,后续从缓存中获取
|
||||
*/
|
||||
|
||||
@@ -6,14 +6,17 @@ import com.fazecast.jSerialComm.SerialPort
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatComboBox
|
||||
import com.formdev.flatlaf.ui.FlatTextBorder
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.eclipse.jgit.internal.transport.sshd.agent.connector.PageantConnector
|
||||
import org.eclipse.jgit.internal.transport.sshd.agent.connector.UnixDomainSocketConnector
|
||||
import org.eclipse.jgit.internal.transport.sshd.agent.connector.WinPipeConnector
|
||||
import java.awt.*
|
||||
import java.awt.event.*
|
||||
import java.nio.charset.Charset
|
||||
@@ -21,7 +24,7 @@ import javax.swing.*
|
||||
import javax.swing.table.DefaultTableCellRenderer
|
||||
import javax.swing.table.DefaultTableModel
|
||||
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
open class HostOptionsPane : OptionsPane() {
|
||||
protected val tunnelingOption = TunnelingOption()
|
||||
protected val generalOption = GeneralOption()
|
||||
@@ -52,18 +55,23 @@ open class HostOptionsPane : OptionsPane() {
|
||||
val port = (generalOption.portTextField.value ?: 22) as Int
|
||||
var authentication = Authentication.No
|
||||
var proxy = Proxy.No
|
||||
val authenticationType = generalOption.authenticationTypeComboBox.selectedItem as AuthenticationType
|
||||
|
||||
|
||||
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
|
||||
if (authenticationType == AuthenticationType.Password) {
|
||||
authentication = authentication.copy(
|
||||
type = AuthenticationType.Password,
|
||||
type = authenticationType,
|
||||
password = String(generalOption.passwordTextField.password)
|
||||
)
|
||||
} else if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
|
||||
} else if (authenticationType == AuthenticationType.PublicKey) {
|
||||
authentication = authentication.copy(
|
||||
type = AuthenticationType.PublicKey,
|
||||
type = authenticationType,
|
||||
password = generalOption.publicKeyComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
|
||||
)
|
||||
} else if (authenticationType == AuthenticationType.SSHAgent) {
|
||||
authentication = authentication.copy(
|
||||
type = authenticationType,
|
||||
password = generalOption.sshAgentComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
|
||||
)
|
||||
}
|
||||
|
||||
if (proxyOption.proxyTypeComboBox.selectedItem != ProxyType.No) {
|
||||
@@ -94,7 +102,9 @@ open class HostOptionsPane : OptionsPane() {
|
||||
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
|
||||
jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
|
||||
serialComm = serialComm,
|
||||
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text
|
||||
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text,
|
||||
enableX11Forwarding = tunnelingOption.x11ForwardingCheckBox.isSelected,
|
||||
x11Forwarding = tunnelingOption.x11ServerTextField.text,
|
||||
)
|
||||
|
||||
return Host(
|
||||
@@ -160,6 +170,17 @@ open class HostOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
// tunnel
|
||||
if (tunnelingOption.x11ForwardingCheckBox.isSelected) {
|
||||
if (validateField(tunnelingOption.x11ServerTextField)) {
|
||||
return false
|
||||
}
|
||||
val segments = tunnelingOption.x11ServerTextField.text.split(":")
|
||||
if (segments.size != 2 || segments[1].toIntOrNull() == null) {
|
||||
setOutlineError(tunnelingOption.x11ServerTextField)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -169,14 +190,18 @@ open class HostOptionsPane : OptionsPane() {
|
||||
*/
|
||||
private fun validateField(textField: JTextField): Boolean {
|
||||
if (textField.isEnabled && textField.text.isBlank()) {
|
||||
selectOptionJComponent(textField)
|
||||
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
|
||||
textField.requestFocusInWindow()
|
||||
setOutlineError(textField)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun setOutlineError(textField: JTextField) {
|
||||
selectOptionJComponent(textField)
|
||||
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
|
||||
textField.requestFocusInWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 true 表示有错误
|
||||
*/
|
||||
@@ -200,6 +225,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
private val passwordPanel = JPanel(BorderLayout())
|
||||
private val chooseKeyBtn = JButton(Icons.greyKey)
|
||||
val passwordTextField = OutlinePasswordField(255)
|
||||
val sshAgentComboBox = OutlineComboBox<String>()
|
||||
val publicKeyComboBox = OutlineComboBox<String>()
|
||||
val remarkTextArea = FixedLengthTextArea(512)
|
||||
val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
|
||||
@@ -215,6 +241,10 @@ open class HostOptionsPane : OptionsPane() {
|
||||
publicKeyComboBox.isEditable = false
|
||||
chooseKeyBtn.isFocusable = false
|
||||
|
||||
// 只有 Windows 允许修改
|
||||
sshAgentComboBox.isEditable = SystemInfo.isWindows
|
||||
sshAgentComboBox.isEnabled = SystemInfo.isWindows
|
||||
|
||||
protocolTypeComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
@@ -290,10 +320,22 @@ open class HostOptionsPane : OptionsPane() {
|
||||
protocolTypeComboBox.addItem(Protocol.SSH)
|
||||
protocolTypeComboBox.addItem(Protocol.Local)
|
||||
protocolTypeComboBox.addItem(Protocol.Serial)
|
||||
protocolTypeComboBox.addItem(Protocol.RDP)
|
||||
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.No)
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.Password)
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.PublicKey)
|
||||
authenticationTypeComboBox.addItem(AuthenticationType.SSHAgent)
|
||||
|
||||
if (SystemInfo.isWindows) {
|
||||
// 不要修改 addItem 的顺序,因为第一个是默认的
|
||||
sshAgentComboBox.addItem(PageantConnector.DESCRIPTOR.identityAgent)
|
||||
sshAgentComboBox.addItem(WinPipeConnector.DESCRIPTOR.identityAgent)
|
||||
sshAgentComboBox.placeholderText = PageantConnector.DESCRIPTOR.identityAgent
|
||||
} else {
|
||||
sshAgentComboBox.addItem(UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
|
||||
sshAgentComboBox.placeholderText = UnixDomainSocketConnector.DESCRIPTOR.identityAgent
|
||||
}
|
||||
|
||||
authenticationTypeComboBox.selectedItem = AuthenticationType.Password
|
||||
|
||||
@@ -457,6 +499,8 @@ open class HostOptionsPane : OptionsPane() {
|
||||
.add(chooseKeyBtn).xy(3, 1)
|
||||
.build(), BorderLayout.CENTER
|
||||
)
|
||||
} else if (authenticationTypeComboBox.selectedItem == AuthenticationType.SSHAgent) {
|
||||
passwordPanel.add(sshAgentComboBox, BorderLayout.CENTER)
|
||||
} else {
|
||||
passwordPanel.add(passwordTextField, BorderLayout.CENTER)
|
||||
}
|
||||
@@ -722,6 +766,8 @@ open class HostOptionsPane : OptionsPane() {
|
||||
|
||||
protected inner class TunnelingOption : JPanel(BorderLayout()), Option {
|
||||
val tunnelings = mutableListOf<Tunneling>()
|
||||
val x11ForwardingCheckBox = JCheckBox("X DISPLAY:")
|
||||
val x11ServerTextField = OutlineTextField(255)
|
||||
|
||||
private val model = object : DefaultTableModel() {
|
||||
override fun getRowCount(): Int {
|
||||
@@ -796,13 +842,36 @@ open class HostOptionsPane : OptionsPane() {
|
||||
box.add(Box.createHorizontalStrut(4))
|
||||
box.add(deleteBtn)
|
||||
|
||||
add(JLabel("TCP/IP Forwarding:"), BorderLayout.NORTH)
|
||||
add(scrollPane, BorderLayout.CENTER)
|
||||
add(box, BorderLayout.SOUTH)
|
||||
x11ForwardingCheckBox.isFocusable = false
|
||||
|
||||
if (x11ServerTextField.text.isBlank()) {
|
||||
x11ServerTextField.text = "localhost:0"
|
||||
}
|
||||
|
||||
val x11Forwarding = Box.createHorizontalBox()
|
||||
x11Forwarding.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createTitledBorder("X11 Forwarding"),
|
||||
BorderFactory.createEmptyBorder(4, 4, 4, 4)
|
||||
)
|
||||
x11Forwarding.add(x11ForwardingCheckBox)
|
||||
x11Forwarding.add(x11ServerTextField)
|
||||
|
||||
x11ServerTextField.isEnabled = x11ForwardingCheckBox.isSelected
|
||||
|
||||
val panel = JPanel(BorderLayout())
|
||||
panel.add(JLabel("TCP/IP Forwarding:"), BorderLayout.NORTH)
|
||||
panel.add(scrollPane, BorderLayout.CENTER)
|
||||
panel.add(box, BorderLayout.SOUTH)
|
||||
panel.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
|
||||
|
||||
add(panel, BorderLayout.CENTER)
|
||||
add(x11Forwarding, BorderLayout.SOUTH)
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
x11ForwardingCheckBox.addChangeListener { x11ServerTextField.isEnabled = x11ForwardingCheckBox.isSelected }
|
||||
|
||||
addBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent?) {
|
||||
val dialog = PortForwardingDialog(SwingUtilities.getWindowAncestor(this@HostOptionsPane))
|
||||
@@ -1054,8 +1123,7 @@ open class HostOptionsPane : OptionsPane() {
|
||||
addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentShown(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
for (commPort in SerialPort.getCommPorts()) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
serialPortComboBox.addItem(commPort.systemPortName)
|
||||
|
||||
@@ -5,6 +5,7 @@ import app.termora.actions.DataProviders
|
||||
import app.termora.terminal.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import java.beans.PropertyChangeEvent
|
||||
@@ -19,7 +20,7 @@ abstract class HostTerminalTab(
|
||||
val Host = DataKey(app.termora.Host::class)
|
||||
}
|
||||
|
||||
protected val coroutineScope by lazy { CoroutineScope(Dispatchers.Swing) }
|
||||
protected val coroutineScope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Swing) }
|
||||
protected val terminalModel get() = terminal.getTerminalModel()
|
||||
protected var unread = false
|
||||
set(value) {
|
||||
|
||||
@@ -49,6 +49,7 @@ class HostTreeNode(host: Host) : SimpleTreeNode<Host>(host) {
|
||||
return when (host.protocol) {
|
||||
Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
|
||||
Protocol.Serial -> if (selected && hasFocus) Icons.plugin.dark else Icons.plugin
|
||||
Protocol.RDP -> if (selected && hasFocus) Icons.microsoftWindows.dark else Icons.microsoftWindows
|
||||
else -> if (selected && hasFocus) Icons.terminal.dark else Icons.terminal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ object Icons {
|
||||
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
|
||||
val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }
|
||||
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
|
||||
val settingSync by lazy { DynamicIcon("icons/settingSync.svg", "icons/settingSync_dark.svg") }
|
||||
val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") }
|
||||
val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") }
|
||||
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
|
||||
@@ -63,6 +64,7 @@ object Icons {
|
||||
val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") }
|
||||
val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") }
|
||||
val microsoft by lazy { DynamicIcon("icons/microsoft.svg", "icons/microsoft_dark.svg") }
|
||||
val microsoftWindows by lazy { DynamicIcon("icons/microsoftWindows.svg", "icons/microsoftWindows_dark.svg") }
|
||||
val tencent by lazy { DynamicIcon("icons/tencent.svg") }
|
||||
val google by lazy { DynamicIcon("icons/google-small.svg") }
|
||||
val aliyun by lazy { DynamicIcon("icons/aliyun.svg") }
|
||||
@@ -93,6 +95,7 @@ object Icons {
|
||||
val sortedSet by lazy { DynamicIcon("icons/sortedSet.svg", "icons/sortedSet_dark.svg") }
|
||||
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") }
|
||||
val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
|
||||
val file by lazy { DynamicIcon("icons/file.svg", "icons/file_dark.svg") }
|
||||
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
|
||||
val left by lazy { DynamicIcon("icons/left.svg", "icons/left_dark.svg") }
|
||||
val right by lazy { DynamicIcon("icons/right.svg", "icons/right_dark.svg") }
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.PtyConnectorDelegate
|
||||
import app.termora.terminal.PtyProcessConnector
|
||||
import org.apache.commons.io.Charsets
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class LocalTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
PtyHostTerminalTab(windowScope, host) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(LocalTerminalTab::class.java)
|
||||
}
|
||||
|
||||
override suspend fun openPtyConnector(): PtyConnector {
|
||||
val winSize = terminalPanel.winSize()
|
||||
val ptyConnector = PtyConnectorFactory.getInstance().createPtyConnector(
|
||||
@@ -18,4 +28,42 @@ class LocalTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
return ptyConnector
|
||||
}
|
||||
|
||||
|
||||
override fun willBeClose(): Boolean {
|
||||
val ptyProcessConnector = getPtyProcessConnector() ?: return true
|
||||
val process = ptyProcessConnector.process
|
||||
var consoleProcessCount = 0
|
||||
|
||||
try {
|
||||
val processHandle = ProcessHandle.of(process.pid()).getOrNull()
|
||||
if (processHandle != null) {
|
||||
consoleProcessCount = processHandle.children().count().toInt()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有正在运行的进程
|
||||
if (consoleProcessCount < 1) return true
|
||||
|
||||
val owner = SwingUtilities.getWindowAncestor(terminalPanel) ?: return true
|
||||
return OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
I18n.getString("termora.tabbed.local-tab.close-prompt"),
|
||||
messageType = JOptionPane.INFORMATION_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
) == JOptionPane.OK_OPTION
|
||||
}
|
||||
|
||||
|
||||
private fun getPtyProcessConnector(): PtyProcessConnector? {
|
||||
var p = getPtyConnector() as PtyConnector?
|
||||
while (p != null) {
|
||||
if (p is PtyProcessConnector) return p
|
||||
if (p is PtyConnectorDelegate) p = p.ptyConnector
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.ui.FlatRootPaneUI
|
||||
import com.formdev.flatlaf.ui.FlatTitlePane
|
||||
|
||||
class MyFlatRootPaneUI : FlatRootPaneUI() {
|
||||
|
||||
fun getTitlePane(): FlatTitlePane? {
|
||||
return super.titlePane
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import java.awt.event.*
|
||||
import java.awt.image.BufferedImage
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import javax.swing.plaf.TabbedPaneUI
|
||||
import kotlin.math.abs
|
||||
|
||||
class MyTabbedPane : FlatTabbedPane() {
|
||||
@@ -21,18 +20,12 @@ class MyTabbedPane : FlatTabbedPane() {
|
||||
private val owner
|
||||
get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
|
||||
.getData(DataProviders.TermoraFrame) as TermoraFrame
|
||||
private val myUI = MyFlatTabbedPaneUI()
|
||||
|
||||
init {
|
||||
isFocusable = false
|
||||
super.setUI(myUI)
|
||||
initEvents()
|
||||
}
|
||||
|
||||
override fun setUI(ui: TabbedPaneUI?) {
|
||||
super.setUI(myUI)
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
styleMap = mapOf(
|
||||
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
|
||||
|
||||
@@ -28,6 +28,8 @@ import java.io.*
|
||||
import java.util.*
|
||||
import java.util.function.Function
|
||||
import javax.swing.*
|
||||
import javax.swing.event.PopupMenuEvent
|
||||
import javax.swing.event.PopupMenuListener
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
import javax.swing.tree.TreePath
|
||||
import javax.swing.tree.TreeSelectionModel
|
||||
@@ -35,6 +37,7 @@ import javax.xml.parsers.DocumentBuilderFactory
|
||||
import javax.xml.xpath.XPathConstants
|
||||
import javax.xml.xpath.XPathFactory
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
class NewHostTree : SimpleTree() {
|
||||
|
||||
companion object {
|
||||
@@ -50,7 +53,7 @@ class NewHostTree : SimpleTree() {
|
||||
private var isShowMoreInfo
|
||||
get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
|
||||
set(value) = properties.putString("HostTree.showMoreInfo", value.toString())
|
||||
|
||||
private var isPopupMenu = false
|
||||
override val model = NewHostTreeModel()
|
||||
|
||||
/**
|
||||
@@ -95,7 +98,7 @@ class NewHostTree : SimpleTree() {
|
||||
// 是否显示更多信息
|
||||
if (isShowMoreInfo) {
|
||||
val color = if (sel) {
|
||||
if (tree.hasFocus()) {
|
||||
if (tree.hasFocus() || isPopupMenu) {
|
||||
UIManager.getColor("textHighlightText")
|
||||
} else {
|
||||
this.foreground
|
||||
@@ -108,20 +111,20 @@ class NewHostTree : SimpleTree() {
|
||||
"""<font color=rgb(${color.red},${color.green},${color.blue})>${it}</font>"""
|
||||
}
|
||||
|
||||
if (host.protocol == Protocol.SSH) {
|
||||
text =
|
||||
"<html>${host.name} ${fontTag.apply("${host.username}@${host.host}")}</html>"
|
||||
// @formatter:off
|
||||
if (host.protocol == Protocol.SSH || host.protocol == Protocol.RDP) {
|
||||
text = "<html>${host.name} ${fontTag.apply("${host.username}@${host.host}")}</html>"
|
||||
} else if (host.protocol == Protocol.Serial) {
|
||||
text =
|
||||
"<html>${host.name} ${fontTag.apply(host.options.serialComm.port)}</html>"
|
||||
text = "<html>${host.name} ${fontTag.apply(host.options.serialComm.port)}</html>"
|
||||
} else if (host.protocol == Protocol.Folder) {
|
||||
text = "<html>${host.name}${fontTag.apply(" (${node.childCount})")}</html>"
|
||||
text = "<html>${host.name}${fontTag.apply(" (${node.getAllChildren().size})")}</html>"
|
||||
}
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
|
||||
|
||||
icon = node.getIcon(sel, expanded, hasFocus)
|
||||
icon = node.getIcon(sel, expanded, tree.hasFocus() || isPopupMenu)
|
||||
return c
|
||||
}
|
||||
})
|
||||
@@ -135,6 +138,9 @@ class NewHostTree : SimpleTree() {
|
||||
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val lastNode = lastSelectedPathComponent as? HostTreeNode ?: return
|
||||
if (lastNode.host.protocol != Protocol.Folder) {
|
||||
val path = tree.getClosestPathForLocation(e.x, e.y) ?: return
|
||||
val bounds = tree.getRowBounds(tree.getRowForPath(path)) ?: return
|
||||
if ((e.y >= bounds.y && e.y < (bounds.y + bounds.height)).not()) return
|
||||
openHostAction?.actionPerformed(OpenHostActionEvent(e.source, lastNode.host, e))
|
||||
}
|
||||
}
|
||||
@@ -327,6 +333,21 @@ class NewHostTree : SimpleTree() {
|
||||
openWithSFTP.isEnabled = fullNodes.map { it.host }.any { it.protocol == Protocol.SSH }
|
||||
openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled
|
||||
openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled }
|
||||
|
||||
popupMenu.addPopupMenuListener(object : PopupMenuListener {
|
||||
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
|
||||
isPopupMenu = true
|
||||
}
|
||||
|
||||
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
|
||||
isPopupMenu = false
|
||||
}
|
||||
|
||||
override fun popupMenuCanceled(e: PopupMenuEvent?) {
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
popupMenu.show(this, evt.x, evt.y)
|
||||
}
|
||||
|
||||
|
||||
7
src/main/kotlin/app/termora/NotifyListener.kt
Normal file
7
src/main/kotlin/app/termora/NotifyListener.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
import java.util.*
|
||||
|
||||
interface NotifyListener : EventListener {
|
||||
fun addNotify()
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatTextPane
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jetbrains.JBR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.BorderLayout
|
||||
@@ -20,6 +22,8 @@ import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object OptionPane {
|
||||
private val coroutineScope = swingCoroutineScope
|
||||
|
||||
fun showConfirmDialog(
|
||||
parentComponent: Component?,
|
||||
message: Any,
|
||||
@@ -29,6 +33,7 @@ object OptionPane {
|
||||
icon: Icon? = null,
|
||||
options: Array<Any>? = null,
|
||||
initialValue: Any? = null,
|
||||
customizeDialog: (JDialog) -> Unit = {},
|
||||
): Int {
|
||||
|
||||
val panel = if (message is JComponent) {
|
||||
@@ -47,6 +52,9 @@ object OptionPane {
|
||||
override fun selectInitialValue() {
|
||||
super.selectInitialValue()
|
||||
if (message is JComponent) {
|
||||
if (message.getClientProperty("SKIP_requestFocusInWindow") == true) {
|
||||
return
|
||||
}
|
||||
message.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
@@ -58,6 +66,7 @@ object OptionPane {
|
||||
}
|
||||
})
|
||||
dialog.setLocationRelativeTo(parentComponent)
|
||||
customizeDialog.invoke(dialog)
|
||||
dialog.isVisible = true
|
||||
dialog.dispose()
|
||||
val selectedValue = pane.value
|
||||
@@ -99,9 +108,8 @@ object OptionPane {
|
||||
val dialog = initDialog(pane.createDialog(parentComponent, title))
|
||||
if (duration.inWholeMilliseconds > 0) {
|
||||
dialog.addWindowListener(object : WindowAdapter() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun windowOpened(e: WindowEvent) {
|
||||
GlobalScope.launch(Dispatchers.Swing) {
|
||||
coroutineScope.launch(Dispatchers.Swing) {
|
||||
delay(duration.inWholeMilliseconds)
|
||||
if (dialog.isVisible) {
|
||||
dialog.isVisible = false
|
||||
|
||||
@@ -16,7 +16,7 @@ import kotlin.math.max
|
||||
class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(owner) {
|
||||
|
||||
private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
|
||||
private val rememberCheckBox = JCheckBox("Remember")
|
||||
private val rememberCheckBox = JCheckBox(I18n.getString("termora.new-host.general.remember"))
|
||||
private val passwordPanel = JPanel(BorderLayout())
|
||||
private val passwordPasswordField = OutlinePasswordField()
|
||||
private val usernameTextField = OutlineTextField()
|
||||
@@ -34,8 +34,8 @@ class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(own
|
||||
pack()
|
||||
|
||||
size = Dimension(max(380, size.width), size.height)
|
||||
|
||||
setLocationRelativeTo(null)
|
||||
preferredSize = size
|
||||
minimumSize = size
|
||||
|
||||
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
@@ -65,6 +65,10 @@ class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(own
|
||||
}
|
||||
}
|
||||
|
||||
if (host.authentication.type != AuthenticationType.No) {
|
||||
authenticationTypeComboBox.selectedItem = host.authentication.type
|
||||
}
|
||||
|
||||
usernameTextField.text = host.username
|
||||
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
|
||||
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
|
||||
private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand
|
||||
private val defaultDirectory get() = Database.getDatabase().sftp.defaultDirectory
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(terminalPanel)
|
||||
|
||||
init {
|
||||
terminalPanel.dropFiles = true
|
||||
@@ -67,7 +68,7 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
|
||||
)
|
||||
)
|
||||
|
||||
val sshClient = SshClients.openClient(host).apply { sshClient = this }
|
||||
val sshClient = SshClients.openClient(host, owner).apply { sshClient = this }
|
||||
val sshSession = SshClients.openSession(host, sshClient).apply { sshSession = this }
|
||||
|
||||
// 打开通道
|
||||
|
||||
@@ -4,7 +4,6 @@ import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.actions.TabReconnectAction
|
||||
import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import app.termora.keymap.KeyShortcut
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.terminal.ControlCharacters
|
||||
@@ -53,6 +52,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
|
||||
init {
|
||||
terminalPanel.dropFiles = false
|
||||
terminalPanel.dataProviderSupport.addData(DataProviders.TerminalTab, this)
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
@@ -89,35 +89,8 @@ 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(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 client = SshClients.openClient(host, owner).also { sshClient = it }
|
||||
val sessionListener = MySessionListener()
|
||||
val channelListener = MyChannelListener()
|
||||
|
||||
@@ -250,6 +223,11 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
|
||||
}
|
||||
}
|
||||
|
||||
override fun willBeClose(): Boolean {
|
||||
// 保存窗口状态
|
||||
terminalPanel.storeVisualWindows(host.id)
|
||||
return super.willBeClose()
|
||||
}
|
||||
|
||||
private inner class MySessionListener : SessionListener, Disposable {
|
||||
override fun sessionEvent(session: Session, event: Event) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package app.termora
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Component
|
||||
import java.awt.Window
|
||||
@@ -8,6 +13,8 @@ import javax.swing.JPopupMenu
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
val swingCoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Swing)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
open class Scope(
|
||||
private val beans: MutableMap<KClass<*>, Any> = ConcurrentHashMap(),
|
||||
@@ -144,6 +151,7 @@ class ApplicationScope private constructor() : Scope() {
|
||||
}
|
||||
|
||||
fun windowScopes(): List<WindowScope> {
|
||||
if (scopes.isEmpty()) return emptyList()
|
||||
return scopes.values.toList()
|
||||
}
|
||||
|
||||
@@ -151,6 +159,7 @@ class ApplicationScope private constructor() : Scope() {
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("ApplicationScope disposed")
|
||||
}
|
||||
swingCoroutineScope.cancel()
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,7 @@ import app.termora.native.FileChooser
|
||||
import app.termora.sftp.SFTPTab
|
||||
import app.termora.snippet.Snippet
|
||||
import app.termora.snippet.SnippetManager
|
||||
import app.termora.sync.SyncConfig
|
||||
import app.termora.sync.SyncRange
|
||||
import app.termora.sync.SyncType
|
||||
import app.termora.sync.SyncerProvider
|
||||
import app.termora.sync.*
|
||||
import app.termora.terminal.CursorStyle
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
@@ -36,10 +33,14 @@ import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import com.jthemedetecor.OsThemeDetector
|
||||
import com.sun.jna.LastErrorException
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.*
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
@@ -58,12 +59,14 @@ import java.awt.event.ItemListener
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.*
|
||||
import java.util.function.Consumer
|
||||
import javax.swing.*
|
||||
import javax.swing.JSpinner.NumberEditor
|
||||
import javax.swing.event.DocumentEvent
|
||||
import javax.swing.event.PopupMenuEvent
|
||||
import javax.swing.event.PopupMenuListener
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
class SettingsOptionsPane : OptionsPane() {
|
||||
@@ -79,7 +82,6 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SettingsOptionsPane::class.java)
|
||||
private val localShells by lazy { loadShells() }
|
||||
var pulled = false
|
||||
|
||||
private fun loadShells(): List<String> {
|
||||
val shells = mutableListOf<String>()
|
||||
@@ -129,9 +131,15 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
val themeManager = ThemeManager.getInstance()
|
||||
val themeComboBox = FlatComboBox<String>()
|
||||
val languageComboBox = FlatComboBox<String>()
|
||||
val backgroundComBoBox = YesOrNoComboBox()
|
||||
val followSystemCheckBox = JCheckBox(I18n.getString("termora.settings.appearance.follow-system"))
|
||||
val preferredThemeBtn = JButton(Icons.settings)
|
||||
val opacitySpinner = NumberSpinner(100, 0, 100)
|
||||
val backgroundImageTextField = OutlineTextField()
|
||||
|
||||
private val appearance get() = database.appearance
|
||||
private val backgroundButton = JButton(Icons.folder)
|
||||
private val backgroundClearButton = FlatButton()
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -140,8 +148,38 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
private fun initView() {
|
||||
|
||||
backgroundComBoBox.isEnabled = SystemInfo.isWindows || SystemInfo.isMacOS
|
||||
backgroundImageTextField.isEditable = false
|
||||
backgroundImageTextField.trailingComponent = backgroundButton
|
||||
backgroundImageTextField.text = FilenameUtils.getName(appearance.backgroundImage)
|
||||
backgroundImageTextField.document.addDocumentListener(object : DocumentAdaptor() {
|
||||
override fun changedUpdate(e: DocumentEvent) {
|
||||
backgroundClearButton.isEnabled = backgroundImageTextField.text.isNotBlank()
|
||||
}
|
||||
})
|
||||
|
||||
backgroundClearButton.isFocusable = false
|
||||
backgroundClearButton.isEnabled = backgroundImageTextField.text.isNotBlank()
|
||||
backgroundClearButton.icon = Icons.delete
|
||||
backgroundClearButton.buttonType = FlatButton.ButtonType.toolBarButton
|
||||
|
||||
|
||||
opacitySpinner.isEnabled = SystemInfo.isMacOS || SystemInfo.isWindows
|
||||
opacitySpinner.model = object : SpinnerNumberModel(appearance.opacity, 0.1, 1.0, 0.1) {
|
||||
override fun getNextValue(): Any {
|
||||
return super.getNextValue() ?: maximum
|
||||
}
|
||||
|
||||
override fun getPreviousValue(): Any {
|
||||
return super.getPreviousValue() ?: minimum
|
||||
}
|
||||
}
|
||||
opacitySpinner.editor = NumberEditor(opacitySpinner, "#.##")
|
||||
opacitySpinner.model.stepSize = 0.05
|
||||
|
||||
followSystemCheckBox.isSelected = appearance.followSystem
|
||||
preferredThemeBtn.isEnabled = followSystemCheckBox.isSelected
|
||||
backgroundComBoBox.selectedItem = appearance.backgroundRunning
|
||||
|
||||
themeComboBox.isEnabled = !followSystemCheckBox.isSelected
|
||||
themeManager.themes.keys.forEach { themeComboBox.addItem(it) }
|
||||
@@ -178,6 +216,20 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
opacitySpinner.addChangeListener {
|
||||
val opacity = opacitySpinner.value
|
||||
if (opacity is Double) {
|
||||
TermoraFrameManager.getInstance().setOpacity(opacity)
|
||||
appearance.opacity = opacity
|
||||
}
|
||||
}
|
||||
|
||||
backgroundComBoBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
appearance.backgroundRunning = backgroundComBoBox.selectedItem as Boolean
|
||||
}
|
||||
}
|
||||
|
||||
followSystemCheckBox.addActionListener {
|
||||
appearance.followSystem = followSystemCheckBox.isSelected
|
||||
themeComboBox.isEnabled = !followSystemCheckBox.isSelected
|
||||
@@ -207,6 +259,46 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
|
||||
preferredThemeBtn.addActionListener { showPreferredThemeContextmenu() }
|
||||
|
||||
backgroundButton.addActionListener {
|
||||
val chooser = FileChooser()
|
||||
chooser.osxAllowedFileTypes = listOf("png", "jpg", "jpeg")
|
||||
chooser.allowsMultiSelection = false
|
||||
chooser.win32Filters.add(Pair("Image files", listOf("png", "jpg", "jpeg")))
|
||||
chooser.fileSelectionMode = JFileChooser.FILES_ONLY
|
||||
chooser.showOpenDialog(owner).thenAccept {
|
||||
if (it.isNotEmpty()) {
|
||||
onSelectedBackgroundImage(it.first())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backgroundClearButton.addActionListener {
|
||||
BackgroundManager.getInstance().clearBackgroundImage()
|
||||
backgroundImageTextField.text = StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSelectedBackgroundImage(file: File) {
|
||||
try {
|
||||
val destFile = FileUtils.getFile(Application.getBaseDataDir(), "background", file.name)
|
||||
FileUtils.forceMkdirParent(destFile)
|
||||
FileUtils.deleteQuietly(destFile)
|
||||
FileUtils.copyFile(file, destFile, StandardCopyOption.REPLACE_EXISTING)
|
||||
backgroundImageTextField.text = destFile.name
|
||||
BackgroundManager.getInstance().setBackgroundImage(destFile)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
SwingUtilities.invokeLater {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
@@ -276,7 +368,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private fun getFormPanel(): JPanel {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, $formMargin, default, default:grow",
|
||||
"pref, $formMargin, pref, $formMargin"
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
val box = FlatToolBar()
|
||||
box.add(followSystemCheckBox)
|
||||
@@ -285,7 +377,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
return FormBuilder.create().layout(layout)
|
||||
val builder = FormBuilder.create().layout(layout)
|
||||
.add("${I18n.getString("termora.settings.appearance.theme")}:").xy(1, rows)
|
||||
.add(themeComboBox).xy(3, rows)
|
||||
.add(box).xy(5, rows).apply { rows += step }
|
||||
@@ -296,7 +388,22 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
Application.browse(URI.create("https://github.com/TermoraDev/termora/tree/main/src/main/resources/i18n"))
|
||||
}
|
||||
})).xy(5, rows).apply { rows += step }
|
||||
.build()
|
||||
|
||||
|
||||
val bgClearBox = Box.createHorizontalBox()
|
||||
bgClearBox.add(backgroundClearButton)
|
||||
builder.add("${I18n.getString("termora.settings.appearance.background-image")}:").xy(1, rows)
|
||||
.add(backgroundImageTextField).xy(3, rows)
|
||||
.add(bgClearBox).xy(5, rows)
|
||||
.apply { rows += step }
|
||||
|
||||
builder.add("${I18n.getString("termora.settings.appearance.opacity")}:").xy(1, rows)
|
||||
.add(opacitySpinner).xy(3, rows).apply { rows += step }
|
||||
|
||||
builder.add("${I18n.getString("termora.settings.appearance.background-running")}:").xy(1, rows)
|
||||
.add(backgroundComBoBox).xy(3, rows)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -315,6 +422,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private val selectCopyComboBox = YesOrNoComboBox()
|
||||
private val autoCloseTabComboBox = YesOrNoComboBox()
|
||||
private val floatingToolbarComboBox = YesOrNoComboBox()
|
||||
private val hyperlinkComboBox = YesOrNoComboBox()
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -392,6 +500,13 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
hyperlinkComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.hyperlink = hyperlinkComboBox.selectedItem as Boolean
|
||||
TerminalPanelFactory.getInstance().repaintAll()
|
||||
}
|
||||
}
|
||||
|
||||
cursorBlinkComboBox.addItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
terminalSetting.cursorBlink = cursorBlinkComboBox.selectedItem as Boolean
|
||||
@@ -408,8 +523,8 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
|
||||
private fun fireFontChanged() {
|
||||
TerminalPanelFactory.getInstance()
|
||||
.fireResize()
|
||||
TerminalPanelFactory.getInstance()
|
||||
.fireResize()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
@@ -470,7 +585,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
shellComboBox.selectedItem = terminalSetting.localShell
|
||||
|
||||
val fonts = linkedSetOf<String>("JetBrains Mono", "Source Code Pro", "Monospaced")
|
||||
val fonts = linkedSetOf("JetBrains Mono", "Source Code Pro", "Monospaced")
|
||||
FontUtils.getAllFonts().forEach {
|
||||
if (!fonts.contains(it.family)) {
|
||||
fonts.addLast(it.family)
|
||||
@@ -484,6 +599,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
fontComboBox.selectedItem = terminalSetting.font
|
||||
debugComboBox.selectedItem = terminalSetting.debug
|
||||
beepComboBox.selectedItem = terminalSetting.beep
|
||||
hyperlinkComboBox.selectedItem = terminalSetting.hyperlink
|
||||
cursorBlinkComboBox.selectedItem = terminalSetting.cursorBlink
|
||||
cursorStyleComboBox.selectedItem = terminalSetting.cursor
|
||||
selectCopyComboBox.selectedItem = terminalSetting.selectCopy
|
||||
@@ -506,7 +622,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, $formMargin, pref, $formMargin, pref"
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val beepBtn = JButton(Icons.run)
|
||||
@@ -529,6 +645,8 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
.add("${I18n.getString("termora.settings.terminal.beep")}:").xy(1, rows)
|
||||
.add(beepComboBox).xy(3, rows)
|
||||
.add(beepBtn).xy(5, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.hyperlink")}:").xy(1, rows)
|
||||
.add(hyperlinkComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.select-copy")}:").xy(1, rows)
|
||||
.add(selectCopyComboBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows)
|
||||
@@ -553,11 +671,11 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
val typeComboBox = FlatComboBox<SyncType>()
|
||||
val tokenTextField = OutlinePasswordField(255)
|
||||
val gistTextField = OutlineTextField(255)
|
||||
val policyComboBox = JComboBox<SyncPolicy>()
|
||||
val domainTextField = OutlineTextField(255)
|
||||
val uploadConfigButton = JButton(I18n.getString("termora.settings.sync.push"), Icons.upload)
|
||||
val syncConfigButton = JButton(I18n.getString("termora.settings.sync"), Icons.settingSync)
|
||||
val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export)
|
||||
val importConfigButton = JButton(I18n.getString("termora.settings.sync.import"), Icons.import)
|
||||
val downloadConfigButton = JButton(I18n.getString("termora.settings.sync.pull"), Icons.download)
|
||||
val lastSyncTimeLabel = JLabel()
|
||||
val sync get() = database.sync
|
||||
val hostsCheckBox = JCheckBox(I18n.getString("termora.welcome.my-hosts"))
|
||||
@@ -575,19 +693,23 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
add(getCenterComponent(), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun initEvents() {
|
||||
downloadConfigButton.addActionListener {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
pushOrPull(false)
|
||||
syncConfigButton.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (typeComboBox.selectedItem == SyncType.WebDAV) {
|
||||
if (tokenTextField.password.isEmpty()) {
|
||||
tokenTextField.outline = FlatClientProperties.OUTLINE_ERROR
|
||||
tokenTextField.requestFocusInWindow()
|
||||
return
|
||||
} else if (gistTextField.text.isEmpty()) {
|
||||
gistTextField.outline = FlatClientProperties.OUTLINE_ERROR
|
||||
gistTextField.requestFocusInWindow()
|
||||
return
|
||||
}
|
||||
}
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { sync() }
|
||||
}
|
||||
}
|
||||
|
||||
uploadConfigButton.addActionListener {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
pushOrPull(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
typeComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
@@ -606,6 +728,12 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
policyComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
sync.policy = (policyComboBox.selectedItem as SyncPolicy).name
|
||||
}
|
||||
}
|
||||
|
||||
tokenTextField.document.addDocumentListener(object : DocumentAdaptor() {
|
||||
override fun changedUpdate(e: DocumentEvent) {
|
||||
sync.token = String(tokenTextField.password)
|
||||
@@ -626,6 +754,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
visitGistBtn.addActionListener {
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab) {
|
||||
if (domainTextField.text.isNotBlank()) {
|
||||
@@ -672,17 +801,47 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
}
|
||||
|
||||
private suspend fun sync() {
|
||||
|
||||
// 如果 gist 为空说明要创建一个 gist
|
||||
if (gistTextField.text.isBlank()) {
|
||||
if (!pushOrPull(true)) return
|
||||
} else {
|
||||
if (!pushOrPull(false)) return
|
||||
if (!pushOrPull(true)) return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
if (hostsCheckBox.isSelected) {
|
||||
for (window in TermoraFrameManager.getInstance().getWindows()) {
|
||||
visit(window.rootPane) {
|
||||
if (it is NewHostTree) it.refreshNode()
|
||||
}
|
||||
}
|
||||
}
|
||||
OptionPane.showMessageDialog(owner, message = I18n.getString("termora.settings.sync.done"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun visit(c: JComponent, consumer: Consumer<JComponent>) {
|
||||
for (e in c.components) {
|
||||
if (e is JComponent) {
|
||||
consumer.accept(e)
|
||||
visit(e, consumer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshButtons() {
|
||||
sync.rangeKeyPairs = keysCheckBox.isSelected
|
||||
sync.rangeHosts = hostsCheckBox.isSelected
|
||||
sync.rangeSnippets = snippetsCheckBox.isSelected
|
||||
sync.rangeKeywordHighlights = keywordHighlightsCheckBox.isSelected
|
||||
|
||||
downloadConfigButton.isEnabled = keysCheckBox.isSelected || hostsCheckBox.isSelected
|
||||
syncConfigButton.isEnabled = keysCheckBox.isSelected || hostsCheckBox.isSelected
|
||||
|| keywordHighlightsCheckBox.isSelected
|
||||
uploadConfigButton.isEnabled = downloadConfigButton.isEnabled
|
||||
exportConfigButton.isEnabled = downloadConfigButton.isEnabled
|
||||
importConfigButton.isEnabled = downloadConfigButton.isEnabled
|
||||
exportConfigButton.isEnabled = syncConfigButton.isEnabled
|
||||
importConfigButton.isEnabled = syncConfigButton.isEnabled
|
||||
}
|
||||
|
||||
private fun export() {
|
||||
@@ -1008,8 +1167,11 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true 同步成功
|
||||
*/
|
||||
@Suppress("DuplicatedCode")
|
||||
private suspend fun pushOrPull(push: Boolean) {
|
||||
private suspend fun pushOrPull(push: Boolean): Boolean {
|
||||
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab) {
|
||||
if (domainTextField.text.isBlank()) {
|
||||
@@ -1017,7 +1179,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
domainTextField.outline = "error"
|
||||
domainTextField.requestFocusInWindow()
|
||||
}
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1026,7 +1188,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
tokenTextField.outline = "error"
|
||||
tokenTextField.requestFocusInWindow()
|
||||
}
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if (gistTextField.text.isBlank() && !push) {
|
||||
@@ -1034,39 +1196,13 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
gistTextField.outline = "error"
|
||||
gistTextField.requestFocusInWindow()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 没有拉取过 && 是推送 && gistId 不为空
|
||||
if (!pulled && push && gistTextField.text.isNotBlank()) {
|
||||
val code = withContext(Dispatchers.Swing) {
|
||||
// 提示第一次推送
|
||||
OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
I18n.getString("termora.settings.sync.push-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE,
|
||||
optionType = JOptionPane.YES_NO_CANCEL_OPTION,
|
||||
options = arrayOf(
|
||||
uploadConfigButton.text,
|
||||
downloadConfigButton.text,
|
||||
I18n.getString("termora.cancel")
|
||||
),
|
||||
initialValue = I18n.getString("termora.cancel")
|
||||
)
|
||||
}
|
||||
when (code) {
|
||||
-1, JOptionPane.CANCEL_OPTION -> return
|
||||
JOptionPane.NO_OPTION -> pushOrPull(false) // pull
|
||||
JOptionPane.YES_OPTION -> pulled = true // force push
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
exportConfigButton.isEnabled = false
|
||||
importConfigButton.isEnabled = false
|
||||
downloadConfigButton.isEnabled = false
|
||||
uploadConfigButton.isEnabled = false
|
||||
syncConfigButton.isEnabled = false
|
||||
typeComboBox.isEnabled = false
|
||||
gistTextField.isEnabled = false
|
||||
tokenTextField.isEnabled = false
|
||||
@@ -1077,19 +1213,14 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
hostsCheckBox.isEnabled = false
|
||||
snippetsCheckBox.isEnabled = false
|
||||
domainTextField.isEnabled = false
|
||||
|
||||
if (push) {
|
||||
uploadConfigButton.text = "${I18n.getString("termora.settings.sync.push")}..."
|
||||
} else {
|
||||
downloadConfigButton.text = "${I18n.getString("termora.settings.sync.pull")}..."
|
||||
}
|
||||
syncConfigButton.text = "${I18n.getString("termora.settings.sync")}..."
|
||||
}
|
||||
|
||||
val syncConfig = getSyncConfig()
|
||||
|
||||
// sync
|
||||
val syncResult = kotlin.runCatching {
|
||||
val syncer = SyncerProvider.getInstance().getSyncer(syncConfig.type)
|
||||
val syncer = SyncManager.getInstance()
|
||||
if (push) {
|
||||
syncer.push(syncConfig)
|
||||
} else {
|
||||
@@ -1099,10 +1230,9 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
// 恢复状态
|
||||
withContext(Dispatchers.Swing) {
|
||||
downloadConfigButton.isEnabled = true
|
||||
syncConfigButton.isEnabled = true
|
||||
exportConfigButton.isEnabled = true
|
||||
importConfigButton.isEnabled = true
|
||||
uploadConfigButton.isEnabled = true
|
||||
keysCheckBox.isEnabled = true
|
||||
hostsCheckBox.isEnabled = true
|
||||
snippetsCheckBox.isEnabled = true
|
||||
@@ -1113,11 +1243,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
tokenTextField.isEnabled = true
|
||||
domainTextField.isEnabled = true
|
||||
keywordHighlightsCheckBox.isEnabled = true
|
||||
if (push) {
|
||||
uploadConfigButton.text = I18n.getString("termora.settings.sync.push")
|
||||
} else {
|
||||
downloadConfigButton.text = I18n.getString("termora.settings.sync.pull")
|
||||
}
|
||||
syncConfigButton.text = I18n.getString("termora.settings.sync")
|
||||
}
|
||||
|
||||
// 如果失败,提示错误
|
||||
@@ -1137,10 +1263,8 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(owner, message, messageType = JOptionPane.ERROR_MESSAGE)
|
||||
}
|
||||
} else {
|
||||
// pulled
|
||||
if (!pulled) pulled = !push
|
||||
|
||||
} else {
|
||||
withContext(Dispatchers.Swing) {
|
||||
val now = System.currentTimeMillis()
|
||||
sync.lastSyncTime = now
|
||||
@@ -1149,14 +1273,10 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
if (push && gistTextField.text.isBlank()) {
|
||||
gistTextField.text = syncResult.map { it.config }.getOrDefault(syncConfig).gistId
|
||||
}
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
message = I18n.getString("termora.settings.sync.done"),
|
||||
duration = 1500.milliseconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return syncResult.isSuccess
|
||||
|
||||
}
|
||||
|
||||
@@ -1166,6 +1286,9 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
typeComboBox.addItem(SyncType.Gitee)
|
||||
typeComboBox.addItem(SyncType.WebDAV)
|
||||
|
||||
policyComboBox.addItem(SyncPolicy.Manual)
|
||||
policyComboBox.addItem(SyncPolicy.OnChange)
|
||||
|
||||
hostsCheckBox.isFocusable = false
|
||||
snippetsCheckBox.isFocusable = false
|
||||
keysCheckBox.isFocusable = false
|
||||
@@ -1180,6 +1303,12 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
macrosCheckBox.isSelected = sync.rangeMacros
|
||||
keymapCheckBox.isSelected = sync.rangeKeymap
|
||||
|
||||
if (sync.policy == SyncPolicy.Manual.name) {
|
||||
policyComboBox.selectedItem = SyncPolicy.Manual
|
||||
} else if (sync.policy == SyncPolicy.OnChange.name) {
|
||||
policyComboBox.selectedItem = SyncPolicy.OnChange
|
||||
}
|
||||
|
||||
typeComboBox.selectedItem = sync.type
|
||||
gistTextField.text = sync.gist
|
||||
tokenTextField.text = sync.token
|
||||
@@ -1227,6 +1356,23 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
policyComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
var text = value?.toString() ?: StringUtils.EMPTY
|
||||
if (value == SyncPolicy.Manual) {
|
||||
text = I18n.getString("termora.settings.sync.policy.manual")
|
||||
} else if (value == SyncPolicy.OnChange) {
|
||||
text = I18n.getString("termora.settings.sync.policy.on-change")
|
||||
}
|
||||
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
|
||||
}
|
||||
}
|
||||
|
||||
val lastSyncTime = sync.lastSyncTime
|
||||
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${
|
||||
@@ -1237,6 +1383,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
refreshButtons()
|
||||
|
||||
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
@@ -1254,7 +1401,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, 30dlu",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val rangeBox = FormBuilder.create()
|
||||
@@ -1304,20 +1451,26 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
gistTextField.trailingComponent = visitGistBtn
|
||||
}
|
||||
|
||||
val syncPolicyBox = Box.createHorizontalBox()
|
||||
syncPolicyBox.add(policyComboBox)
|
||||
syncPolicyBox.add(Box.createHorizontalGlue())
|
||||
syncPolicyBox.add(Box.createHorizontalGlue())
|
||||
|
||||
builder.add("${tokenText}:").xy(1, rows)
|
||||
.add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${gistText}:").xy(1, rows)
|
||||
.add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.sync.policy")}:").xy(1, rows)
|
||||
.add(syncPolicyBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows)
|
||||
.add(rangeBox).xy(3, rows).apply { rows += step }
|
||||
// Sync buttons
|
||||
.add(
|
||||
FormBuilder.create()
|
||||
.layout(FormLayout("pref, 2dlu, pref, 2dlu, pref, 2dlu, pref", "pref"))
|
||||
.add(uploadConfigButton).xy(1, 1)
|
||||
.add(downloadConfigButton).xy(3, 1)
|
||||
.add(exportConfigButton).xy(5, 1)
|
||||
.add(importConfigButton).xy(7, 1)
|
||||
.layout(FormLayout("pref, 2dlu, pref, 2dlu, pref", "pref"))
|
||||
.add(syncConfigButton).xy(1, 1)
|
||||
.add(exportConfigButton).xy(3, 1)
|
||||
.add(importConfigButton).xy(5, 1)
|
||||
.build()
|
||||
).xy(3, rows, "center, fill").apply { rows += step }
|
||||
.add(lastSyncTimeLabel).xy(3, rows, "center, fill").apply { rows += step }
|
||||
|
||||
@@ -44,7 +44,7 @@ open class SimpleTree : JXTree() {
|
||||
): Component {
|
||||
val node = value as SimpleTreeNode<*>
|
||||
val c = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus)
|
||||
icon = node.getIcon(sel, expanded, hasFocus)
|
||||
icon = node.getIcon(sel, expanded, tree.hasFocus())
|
||||
return c
|
||||
}
|
||||
})
|
||||
@@ -197,6 +197,7 @@ open class SimpleTree : JXTree() {
|
||||
}
|
||||
|
||||
override fun importData(support: TransferSupport): Boolean {
|
||||
if (!support.isDrop) return false
|
||||
val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false
|
||||
val node = dropLocation.path.lastPathComponent as? SimpleTreeNode<*> ?: return false
|
||||
val nodes = (support.transferable.getTransferData(MoveNodeTransferable.dataFlavor) as? List<*>)
|
||||
@@ -277,7 +278,7 @@ open class SimpleTree : JXTree() {
|
||||
|
||||
protected open fun onRenamed(node: SimpleTreeNode<*>, text: String) {}
|
||||
|
||||
protected open fun refreshNode(node: SimpleTreeNode<*>) {
|
||||
open fun refreshNode(node: SimpleTreeNode<*> = model.root) {
|
||||
val state = TreeUtils.saveExpansionState(tree)
|
||||
val rows = selectionRows
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ package app.termora
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import app.termora.keymgr.OhKeyPairKeyPairProvider
|
||||
import app.termora.terminal.TerminalSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import app.termora.x11.X11ChannelFactory
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.util.FontUtils
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.sshd.client.ClientBuilder
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.auth.password.UserAuthPasswordFactory
|
||||
import org.apache.sshd.client.channel.ChannelShell
|
||||
import org.apache.sshd.client.channel.ClientChannelEvent
|
||||
import org.apache.sshd.client.config.hosts.HostConfigEntry
|
||||
@@ -19,44 +23,72 @@ import org.apache.sshd.client.kex.DHGClient
|
||||
import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier
|
||||
import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
|
||||
import org.apache.sshd.client.keyverifier.ServerKeyVerifier
|
||||
import org.apache.sshd.client.session.ClientProxyConnector
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.client.session.ClientSessionImpl
|
||||
import org.apache.sshd.client.session.SessionFactory
|
||||
import org.apache.sshd.common.AttributeRepository
|
||||
import org.apache.sshd.common.SshConstants
|
||||
import org.apache.sshd.common.SshException
|
||||
import org.apache.sshd.common.channel.ChannelFactory
|
||||
import org.apache.sshd.common.channel.PtyChannelConfiguration
|
||||
import org.apache.sshd.common.channel.PtyChannelConfigurationHolder
|
||||
import org.apache.sshd.common.cipher.CipherNone
|
||||
import org.apache.sshd.common.compression.BuiltinCompressions
|
||||
import org.apache.sshd.common.config.keys.KeyRandomArt
|
||||
import org.apache.sshd.common.config.keys.KeyUtils
|
||||
import org.apache.sshd.common.future.CloseFuture
|
||||
import org.apache.sshd.common.future.SshFutureListener
|
||||
import org.apache.sshd.common.global.KeepAliveHandler
|
||||
import org.apache.sshd.common.io.IoConnectFuture
|
||||
import org.apache.sshd.common.io.IoConnector
|
||||
import org.apache.sshd.common.io.IoServiceEventListener
|
||||
import org.apache.sshd.common.io.IoSession
|
||||
import org.apache.sshd.common.kex.BuiltinDHFactories
|
||||
import org.apache.sshd.common.keyprovider.KeyIdentityProvider
|
||||
import org.apache.sshd.common.session.Session
|
||||
import org.apache.sshd.common.session.SessionListener
|
||||
import org.apache.sshd.common.signature.BuiltinSignatures
|
||||
import org.apache.sshd.common.util.net.SshdSocketAddress
|
||||
import org.apache.sshd.core.CoreModuleProperties
|
||||
import org.apache.sshd.server.forward.AcceptAllForwardingFilter
|
||||
import org.apache.sshd.server.forward.RejectAllForwardingFilter
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitSshClient
|
||||
import org.eclipse.jgit.internal.transport.sshd.agent.JGitSshAgentFactory
|
||||
import org.eclipse.jgit.internal.transport.sshd.agent.connector.PageantConnector
|
||||
import org.eclipse.jgit.internal.transport.sshd.agent.connector.UnixDomainSocketConnector
|
||||
import org.eclipse.jgit.internal.transport.sshd.proxy.AbstractClientProxyConnector
|
||||
import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector
|
||||
import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector
|
||||
import org.eclipse.jgit.transport.CredentialsProvider
|
||||
import org.eclipse.jgit.transport.SshConstants.IDENTITY_AGENT
|
||||
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
|
||||
import org.eclipse.jgit.transport.sshd.ProxyData
|
||||
import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Font
|
||||
import java.awt.Window
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.SocketAddress
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.security.PublicKey
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
object SshClients {
|
||||
|
||||
val HOST_KEY = AttributeRepository.AttributeKey<Host>()
|
||||
|
||||
private val timeout = Duration.ofSeconds(30)
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
|
||||
|
||||
/**
|
||||
@@ -79,6 +111,12 @@ object SshClients {
|
||||
env.putAll(host.options.envs())
|
||||
|
||||
val channel = session.createShellChannel(configuration, env)
|
||||
if (host.options.enableX11Forwarding) {
|
||||
if (channel is app.termora.x11.ChannelShell) {
|
||||
channel.xForwarding = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel.open().verify(timeout).await()) {
|
||||
throw SshException("Failed to open Shell")
|
||||
}
|
||||
@@ -119,16 +157,16 @@ object SshClients {
|
||||
* 打开一个会话
|
||||
*/
|
||||
fun openSession(host: Host, client: SshClient): ClientSession {
|
||||
|
||||
val h = hostManager.getHost(host.id) ?: host
|
||||
|
||||
// 如果没有跳板机直接连接
|
||||
if (host.options.jumpHosts.isEmpty()) {
|
||||
return doOpenSession(host, client)
|
||||
if (h.options.jumpHosts.isEmpty()) {
|
||||
return doOpenSession(h, client)
|
||||
}
|
||||
|
||||
val jumpHosts = mutableListOf<Host>()
|
||||
val hosts = HostManager.getInstance().hosts().associateBy { it.id }
|
||||
for (jumpHostId in host.options.jumpHosts) {
|
||||
for (jumpHostId in h.options.jumpHosts) {
|
||||
val e = hosts[jumpHostId]
|
||||
if (e == null) {
|
||||
if (log.isWarnEnabled) {
|
||||
@@ -140,7 +178,7 @@ object SshClients {
|
||||
}
|
||||
|
||||
// 最后一跳是目标机器
|
||||
jumpHosts.add(host)
|
||||
jumpHosts.add(h)
|
||||
|
||||
val sessions = mutableListOf<ClientSession>()
|
||||
for (i in 0 until jumpHosts.size) {
|
||||
@@ -186,18 +224,50 @@ object SshClients {
|
||||
entry.username = host.username
|
||||
entry.hostName = host.host
|
||||
entry.setProperty("Middleware", middleware.toString())
|
||||
entry.setProperty("Host", host.id)
|
||||
|
||||
val session = client.connect(entry)
|
||||
.verify(timeout).session
|
||||
// 设置代理
|
||||
// configureProxy(entry, host, client)
|
||||
|
||||
// ssh-agent
|
||||
if (host.authentication.type == AuthenticationType.SSHAgent) {
|
||||
if (host.authentication.password.isNotBlank())
|
||||
entry.setProperty(IDENTITY_AGENT, host.authentication.password)
|
||||
else if (SystemInfo.isWindows)
|
||||
entry.setProperty(IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent)
|
||||
else
|
||||
entry.setProperty(IDENTITY_AGENT, UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
|
||||
}
|
||||
|
||||
val session = client.connect(entry).verify(timeout).session
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
session.addPasswordIdentity(host.authentication.password)
|
||||
} else if (host.authentication.type == AuthenticationType.PublicKey) {
|
||||
session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password)
|
||||
}
|
||||
|
||||
val verifyTimeout = Duration.ofSeconds(timeout.seconds * 5)
|
||||
if (!session.auth().verify(verifyTimeout).await(verifyTimeout)) {
|
||||
throw SshException("Authentication failed")
|
||||
if (host.options.enableX11Forwarding) {
|
||||
val segments = host.options.x11Forwarding.split(":")
|
||||
if (segments.size == 2) {
|
||||
val x11Host = segments[0]
|
||||
val x11Port = segments[1].toIntOrNull()
|
||||
if (x11Port != null) {
|
||||
CoreModuleProperties.X11_BIND_HOST.set(session, x11Host)
|
||||
CoreModuleProperties.X11_BASE_PORT.set(session, 6000 + x11Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!session.auth().verify(timeout).await(timeout)) {
|
||||
throw SshException("Authentication failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e !is SshException || e.disconnectCode != SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e
|
||||
val owner = client.properties["owner"] as Window? ?: throw e
|
||||
val authentication = ask(host, owner) ?: throw e
|
||||
if (authentication.type == AuthenticationType.No) throw e
|
||||
return doOpenSession(host.copy(authentication = authentication), client)
|
||||
}
|
||||
|
||||
session.setAttribute(HOST_KEY, host)
|
||||
@@ -241,27 +311,13 @@ object SshClients {
|
||||
return sshdSocketAddress
|
||||
}
|
||||
|
||||
suspend fun openClient(host: Host, owner: Window): Pair<SshClient, Host> {
|
||||
val client = openClient(host)
|
||||
var myHost = host
|
||||
withContext(Dispatchers.Swing) {
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
|
||||
// 弹出授权框
|
||||
if (host.authentication.type == AuthenticationType.No) {
|
||||
val dialog = RequestAuthenticationDialog(owner, host)
|
||||
val authentication = dialog.getAuthentication()
|
||||
myHost = myHost.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
// save
|
||||
if (dialog.isRemembered()) {
|
||||
HostManager.getInstance().addHost(myHost)
|
||||
}
|
||||
}
|
||||
}
|
||||
return client to myHost
|
||||
fun openClient(host: Host, owner: Window): SshClient {
|
||||
val h = hostManager.getHost(host.id) ?: host
|
||||
val client = openClient(h)
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
|
||||
client.properties["owner"] = owner
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,11 +326,12 @@ object SshClients {
|
||||
fun openClient(host: Host): SshClient {
|
||||
val builder = ClientBuilder.builder()
|
||||
builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE))
|
||||
.factory { JGitSshClient() }
|
||||
.factory { MyJGitSshClient() }
|
||||
|
||||
val keyExchangeFactories = ClientBuilder.setUpDefaultKeyExchanges(true).toMutableList()
|
||||
|
||||
// https://github.com/TermoraDev/termora/issues/123
|
||||
@Suppress("DEPRECATION")
|
||||
keyExchangeFactories.addAll(
|
||||
listOf(
|
||||
DHGClient.newFactory(BuiltinDHFactories.dhg1),
|
||||
@@ -284,6 +341,24 @@ object SshClients {
|
||||
)
|
||||
builder.keyExchangeFactories(keyExchangeFactories)
|
||||
|
||||
val compressionFactories = ClientBuilder.setUpDefaultCompressionFactories(true).toMutableList()
|
||||
for (compression in listOf(
|
||||
BuiltinCompressions.none,
|
||||
BuiltinCompressions.zlib,
|
||||
BuiltinCompressions.delayedZlib
|
||||
)) {
|
||||
if (compressionFactories.contains(compression)) continue
|
||||
compressionFactories.add(compression)
|
||||
}
|
||||
builder.compressionFactories(compressionFactories)
|
||||
|
||||
val signatureFactories = ClientBuilder.setUpDefaultSignatureFactories(true).toMutableList()
|
||||
for (signature in BuiltinSignatures.entries) {
|
||||
if (signatureFactories.contains(signature)) continue
|
||||
signatureFactories.add(signature)
|
||||
}
|
||||
builder.signatureFactories(signatureFactories)
|
||||
|
||||
if (host.tunnelings.isEmpty() && host.options.jumpHosts.isEmpty()) {
|
||||
builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE)
|
||||
} else {
|
||||
@@ -292,106 +367,338 @@ object SshClients {
|
||||
|
||||
builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY)
|
||||
|
||||
val channelFactories = mutableListOf<ChannelFactory>()
|
||||
channelFactories.addAll(ClientBuilder.DEFAULT_CHANNEL_FACTORIES)
|
||||
channelFactories.add(X11ChannelFactory.INSTANCE)
|
||||
builder.channelFactories(channelFactories)
|
||||
|
||||
val sshClient = builder.build() as JGitSshClient
|
||||
|
||||
// https://github.com/TermoraDev/termora/issues/180
|
||||
// JGit 会尝试读取本地的私钥或缓存的私钥
|
||||
sshClient.keyIdentityProvider = KeyIdentityProvider { mutableListOf() }
|
||||
|
||||
// 设置优先级
|
||||
if (host.authentication.type == AuthenticationType.PublicKey || host.authentication.type == AuthenticationType.SSHAgent) {
|
||||
if (host.authentication.type == AuthenticationType.SSHAgent) {
|
||||
// ssh-agent
|
||||
sshClient.agentFactory = JGitSshAgentFactory(ConnectorFactory.getDefault(), null)
|
||||
}
|
||||
CoreModuleProperties.PREFERRED_AUTHS.set(
|
||||
sshClient,
|
||||
listOf(
|
||||
UserAuthPasswordFactory.PUBLIC_KEY,
|
||||
UserAuthPasswordFactory.PASSWORD,
|
||||
UserAuthPasswordFactory.KB_INTERACTIVE
|
||||
).joinToString(",")
|
||||
)
|
||||
} else {
|
||||
CoreModuleProperties.PREFERRED_AUTHS.set(
|
||||
sshClient,
|
||||
listOf(
|
||||
UserAuthPasswordFactory.PASSWORD,
|
||||
UserAuthPasswordFactory.PUBLIC_KEY,
|
||||
UserAuthPasswordFactory.KB_INTERACTIVE
|
||||
).joinToString(",")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val heartbeatInterval = max(host.options.heartbeatInterval, 3)
|
||||
CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong()))
|
||||
CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true)
|
||||
|
||||
sshClient.setKeyPasswordProviderFactory { IdentityPasswordProvider(CredentialsProvider.getDefault()) }
|
||||
|
||||
if (host.proxy.type != ProxyType.No) {
|
||||
sshClient.setProxyDatabase {
|
||||
if (host.proxy.authenticationType == AuthenticationType.No) ProxyData(
|
||||
Proxy(
|
||||
if (host.proxy.type == ProxyType.SOCKS5) Proxy.Type.SOCKS else Proxy.Type.HTTP,
|
||||
InetSocketAddress(host.proxy.host, host.proxy.port)
|
||||
)
|
||||
)
|
||||
else
|
||||
ProxyData(
|
||||
Proxy(
|
||||
if (host.proxy.type == ProxyType.SOCKS5) Proxy.Type.SOCKS else Proxy.Type.HTTP,
|
||||
InetSocketAddress(host.proxy.host, host.proxy.port)
|
||||
),
|
||||
host.proxy.username,
|
||||
host.proxy.password.toCharArray(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sshClient.start()
|
||||
return sshClient
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
|
||||
override fun verifyServerKey(
|
||||
clientSession: ClientSession,
|
||||
remoteAddress: SocketAddress,
|
||||
serverKey: PublicKey
|
||||
): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun acceptModifiedServerKey(
|
||||
clientSession: ClientSession?,
|
||||
remoteAddress: SocketAddress?,
|
||||
entry: KnownHostEntry?,
|
||||
expected: PublicKey?,
|
||||
actual: PublicKey?
|
||||
): Boolean {
|
||||
val result = AtomicBoolean(false)
|
||||
|
||||
private fun ask(host: Host, owner: Window): Authentication? {
|
||||
val ref = AtomicReference<Authentication>(null)
|
||||
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
|
||||
val dialog = RequestAuthenticationDialog(owner, host)
|
||||
dialog.setLocationRelativeTo(owner)
|
||||
val authentication = dialog.getAuthentication().apply { ref.set(this) }
|
||||
// save
|
||||
if (dialog.isRemembered()) {
|
||||
hostManager.addHost(
|
||||
host.copy(
|
||||
authentication = authentication,
|
||||
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts)
|
||||
return ref.get()
|
||||
}
|
||||
|
||||
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
|
||||
override fun verifyServerKey(
|
||||
clientSession: ClientSession,
|
||||
remoteAddress: SocketAddress,
|
||||
serverKey: PublicKey
|
||||
): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun acceptModifiedServerKey(
|
||||
clientSession: ClientSession?,
|
||||
remoteAddress: SocketAddress?,
|
||||
entry: KnownHostEntry?,
|
||||
expected: PublicKey?,
|
||||
actual: PublicKey?
|
||||
): Boolean {
|
||||
val result = AtomicBoolean(false)
|
||||
SwingUtilities.invokeAndWait { result.set(ask(remoteAddress, expected, actual) == JOptionPane.OK_OPTION) }
|
||||
return result.get()
|
||||
}
|
||||
|
||||
private fun ask(
|
||||
remoteAddress: SocketAddress?,
|
||||
expected: PublicKey?,
|
||||
actual: PublicKey?
|
||||
): Int {
|
||||
val formMargin = "7dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow",
|
||||
"pref, 12dlu, pref, 4dlu, pref, 2dlu, pref, $formMargin, pref, $formMargin, pref, pref, 12dlu, pref"
|
||||
)
|
||||
|
||||
val errorColor = if (FlatLaf.isLafDark()) UIManager.getColor("Component.warning.focusedBorderColor") else
|
||||
UIManager.getColor("Component.error.focusedBorderColor")
|
||||
val font = FontUtils.getCompositeFont("JetBrains Mono", Font.PLAIN, 12)
|
||||
val artBox = Box.createHorizontalBox()
|
||||
artBox.add(Box.createHorizontalGlue())
|
||||
val expectedBox = Box.createVerticalBox()
|
||||
for (line in KeyRandomArt(expected).toString().lines()) {
|
||||
val label = JLabel(line)
|
||||
label.font = font
|
||||
expectedBox.add(label)
|
||||
}
|
||||
artBox.add(expectedBox)
|
||||
artBox.add(Box.createHorizontalGlue())
|
||||
val actualBox = Box.createVerticalBox()
|
||||
for (line in KeyRandomArt(actual).toString().lines()) {
|
||||
val label = JLabel(line)
|
||||
label.foreground = errorColor
|
||||
label.font = font
|
||||
actualBox.add(label)
|
||||
}
|
||||
artBox.add(actualBox)
|
||||
artBox.add(Box.createHorizontalGlue())
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
|
||||
// @formatter:off
|
||||
val address = remoteAddress.toString().replace("/", StringUtils.EMPTY)
|
||||
val panel = FormBuilder.create().layout(layout)
|
||||
.add("<html><b>${I18n.getString("termora.host.modified-server-key.title", address)}</b></html>").xy(1, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.host.modified-server-key.thumbprint")}:").xy(1, rows).apply { rows += step }
|
||||
.add(" ${I18n.getString("termora.host.modified-server-key.expected")}: ${KeyUtils.getFingerPrint(expected)}").xy(1, rows).apply { rows += step }
|
||||
.add("<html> ${I18n.getString("termora.host.modified-server-key.actual")}: <font color=rgb(${errorColor.red},${errorColor.green},${errorColor.blue})>${KeyUtils.getFingerPrint(actual)}</font></html>").xy(1, rows).apply { rows += step }
|
||||
.addSeparator(StringUtils.EMPTY).xy(1, rows).apply { rows += step }
|
||||
.add(artBox).xy(1, rows).apply { rows += step }
|
||||
.addSeparator(StringUtils.EMPTY).xy(1, rows).apply { rows += 1 }
|
||||
.add(I18n.getString("termora.host.modified-server-key.are-you-sure")).xy(1, rows).apply { rows += step }
|
||||
.build()
|
||||
// @formatter:on
|
||||
|
||||
return OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
panel,
|
||||
"SSH Security Warning",
|
||||
messageType = JOptionPane.WARNING_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private class DialogServerKeyVerifier(
|
||||
owner: Window,
|
||||
) : KnownHostsServerKeyVerifier(
|
||||
MyDialogServerKeyVerifier(owner),
|
||||
Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts")
|
||||
) {
|
||||
init {
|
||||
modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor
|
||||
}
|
||||
|
||||
override fun updateKnownHostsFile(
|
||||
clientSession: ClientSession?,
|
||||
remoteAddress: SocketAddress?,
|
||||
serverKey: PublicKey?,
|
||||
file: Path?,
|
||||
knownHosts: Collection<HostEntryPair?>?
|
||||
): KnownHostEntry? {
|
||||
if (clientSession is JGitClientSession) {
|
||||
if (isMiddleware(clientSession)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private class MyJGitSshClient : JGitSshClient() {
|
||||
companion object {
|
||||
private val HOST_CONFIG_ENTRY: AttributeRepository.AttributeKey<HostConfigEntry> by lazy {
|
||||
JGitSshClient::class.java.getDeclaredField("HOST_CONFIG_ENTRY").apply { isAccessible = true }
|
||||
.get(null) as AttributeRepository.AttributeKey<HostConfigEntry>
|
||||
}
|
||||
private const val CLIENT_PROXY_CONNECTOR = "ClientProxyConnectorId"
|
||||
}
|
||||
|
||||
private val sshClient = this
|
||||
private val clientProxyConnectors = ConcurrentHashMap<String, ClientProxyConnector>()
|
||||
|
||||
|
||||
override fun createConnector(): IoConnector {
|
||||
return MyIoConnector(this, super.createConnector())
|
||||
}
|
||||
|
||||
override fun createSessionFactory(): SessionFactory {
|
||||
return object : SessionFactory(sshClient) {
|
||||
override fun doCreateSession(ioSession: IoSession): ClientSessionImpl {
|
||||
return object : JGitClientSession(sshClient, ioSession) {
|
||||
override fun getClientProxyConnector(): ClientProxyConnector? {
|
||||
val entry = getAttribute(HOST_CONFIG_ENTRY) ?: return null
|
||||
val clientProxyConnectorId = entry.getProperty(CLIENT_PROXY_CONNECTOR) ?: return null
|
||||
val clientProxyConnector = sshClient.clientProxyConnectors[clientProxyConnectorId]
|
||||
|
||||
if (clientProxyConnector != null) {
|
||||
addSessionListener(object : SessionListener {
|
||||
override fun sessionClosed(session: Session) {
|
||||
clientProxyConnectors.remove(clientProxyConnectorId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return clientProxyConnector
|
||||
}
|
||||
|
||||
override fun createShellChannel(
|
||||
ptyConfig: PtyChannelConfigurationHolder?,
|
||||
env: MutableMap<String, *>?
|
||||
): ChannelShell {
|
||||
if (inCipher is CipherNone || outCipher is CipherNone)
|
||||
throw IllegalStateException("Interactive channels are not supported with none cipher")
|
||||
val channel = app.termora.x11.ChannelShell(ptyConfig, env)
|
||||
val id = connectionService.registerChannel(channel)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("createShellChannel({}) created id={} - PTY={}", this, id, ptyConfig)
|
||||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setClientProxyConnector(proxyConnector: ClientProxyConnector?) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
private class MyIoConnector(private val sshClient: MyJGitSshClient, private val ioConnector: IoConnector) :
|
||||
IoConnector {
|
||||
override fun close(immediately: Boolean): CloseFuture {
|
||||
return ioConnector.close(immediately)
|
||||
}
|
||||
|
||||
override fun addCloseFutureListener(listener: SshFutureListener<CloseFuture>?) {
|
||||
return ioConnector.addCloseFutureListener(listener)
|
||||
}
|
||||
|
||||
override fun removeCloseFutureListener(listener: SshFutureListener<CloseFuture>?) {
|
||||
return ioConnector.removeCloseFutureListener(listener)
|
||||
}
|
||||
|
||||
override fun isClosed(): Boolean {
|
||||
return ioConnector.isClosed
|
||||
}
|
||||
|
||||
override fun isClosing(): Boolean {
|
||||
return ioConnector.isClosing
|
||||
}
|
||||
|
||||
override fun getIoServiceEventListener(): IoServiceEventListener {
|
||||
return ioConnector.ioServiceEventListener
|
||||
}
|
||||
|
||||
override fun setIoServiceEventListener(listener: IoServiceEventListener?) {
|
||||
return ioConnector.setIoServiceEventListener(listener)
|
||||
}
|
||||
|
||||
override fun getManagedSessions(): MutableMap<Long, IoSession> {
|
||||
return ioConnector.managedSessions
|
||||
}
|
||||
|
||||
override fun connect(
|
||||
targetAddress: SocketAddress,
|
||||
context: AttributeRepository?,
|
||||
localAddress: SocketAddress?
|
||||
): IoConnectFuture {
|
||||
var tAddress = targetAddress
|
||||
val entry = context?.getAttribute(HOST_CONFIG_ENTRY)
|
||||
if (entry != null) {
|
||||
val host = hostManager.getHost(entry.getProperty("Host") ?: StringUtils.EMPTY)
|
||||
if (host != null) {
|
||||
tAddress = configureProxy(entry, host, tAddress)
|
||||
}
|
||||
}
|
||||
return ioConnector.connect(tAddress, context, localAddress)
|
||||
}
|
||||
|
||||
private fun configureProxy(
|
||||
entry: HostConfigEntry,
|
||||
host: Host,
|
||||
targetAddress: SocketAddress
|
||||
): SocketAddress {
|
||||
if (host.proxy.type == ProxyType.No) return targetAddress
|
||||
val address = targetAddress as? InetSocketAddress ?: return targetAddress
|
||||
if (address.hostString == (SshdSocketAddress.LOCALHOST_IPV4)) return targetAddress
|
||||
|
||||
// 获取代理连接器
|
||||
val clientProxyConnector = getClientProxyConnector(host, address) ?: return targetAddress
|
||||
|
||||
val id = UUID.randomUUID().toSimpleString()
|
||||
entry.setProperty(CLIENT_PROXY_CONNECTOR, id)
|
||||
sshClient.clientProxyConnectors[id] = clientProxyConnector
|
||||
|
||||
return InetSocketAddress(host.proxy.host, host.proxy.port)
|
||||
}
|
||||
|
||||
private fun getClientProxyConnector(
|
||||
host: Host,
|
||||
remoteAddress: InetSocketAddress
|
||||
): AbstractClientProxyConnector? {
|
||||
if (host.proxy.type == ProxyType.HTTP) {
|
||||
return HttpClientConnector(
|
||||
InetSocketAddress(host.proxy.host, host.proxy.port),
|
||||
remoteAddress,
|
||||
host.proxy.username.ifBlank { null },
|
||||
if (host.proxy.password.isBlank()) null else host.proxy.password.toCharArray()
|
||||
)
|
||||
} else if (host.proxy.type == ProxyType.SOCKS5) {
|
||||
return Socks5ClientConnector(
|
||||
InetSocketAddress(host.proxy.host, host.proxy.port),
|
||||
remoteAddress,
|
||||
host.proxy.username.ifBlank { null },
|
||||
if (host.proxy.password.isBlank()) null else host.proxy.password.toCharArray()
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.actions.MultipleAction
|
||||
import app.termora.highlight.KeywordHighlightPaintListener
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.PtyConnector
|
||||
import app.termora.terminal.Terminal
|
||||
import app.termora.terminal.panel.TerminalHyperlinkPaintListener
|
||||
@@ -40,6 +41,10 @@ class TerminalPanelFactory : Disposable {
|
||||
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
|
||||
val writer = MyTerminalWriter(ptyConnector)
|
||||
val terminalPanel = TerminalPanel(terminal, writer)
|
||||
|
||||
// processDeviceStatusReport
|
||||
terminal.getTerminalModel().setData(DataKey.TerminalWriter, writer)
|
||||
|
||||
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
|
||||
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
|
||||
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
|
||||
@@ -89,7 +94,7 @@ class TerminalPanelFactory : Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
|
||||
@@ -43,6 +43,9 @@ interface TerminalTab : Disposable, DataProvider {
|
||||
*/
|
||||
fun canClose(): Boolean = true
|
||||
|
||||
/**
|
||||
* 返回 true 表示可以关闭
|
||||
*/
|
||||
fun willBeClose(): Boolean = true
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,11 +7,13 @@ import app.termora.actions.DataProviders
|
||||
import app.termora.sftp.SFTPTab
|
||||
import app.termora.terminal.DataKey
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.ui.FlatRootPaneUI
|
||||
import com.formdev.flatlaf.ui.FlatTitlePane
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jetbrains.JBR
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Insets
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.event.MouseListener
|
||||
@@ -24,6 +26,7 @@ import javax.swing.SwingUtilities
|
||||
import javax.swing.SwingUtilities.isEventDispatchThread
|
||||
import javax.swing.UIManager
|
||||
|
||||
|
||||
fun assertEventDispatchThread() {
|
||||
if (!isEventDispatchThread()) throw WrongThreadException("AWT EventQueue")
|
||||
}
|
||||
@@ -40,7 +43,7 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val welcomePanel = WelcomePanel(windowScope)
|
||||
private val sftp get() = Database.getDatabase().sftp
|
||||
private val myUI = MyFlatRootPaneUI()
|
||||
private var notifyListeners = emptyArray<NotifyListener>()
|
||||
|
||||
|
||||
init {
|
||||
@@ -60,10 +63,9 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
}
|
||||
|
||||
override fun mouseDragged(e: MouseEvent) {
|
||||
val mouseLayer = getMouseLayer() ?: return
|
||||
getMouseMotionListener()?.mouseDragged(
|
||||
MouseEvent(
|
||||
mouseLayer,
|
||||
e.component,
|
||||
e.id,
|
||||
e.`when`,
|
||||
e.modifiersEx,
|
||||
@@ -84,19 +86,19 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
return getHandler() as? MouseMotionListener
|
||||
}
|
||||
|
||||
private fun getMouseLayer(): JComponent? {
|
||||
val titlePane = myUI.getTitlePane() ?: return null
|
||||
val handlerField = titlePane.javaClass.getDeclaredField("mouseLayer") ?: return null
|
||||
handlerField.isAccessible = true
|
||||
return handlerField.get(titlePane) as? JComponent
|
||||
}
|
||||
|
||||
private fun getHandler(): Any? {
|
||||
val titlePane = myUI.getTitlePane() ?: return null
|
||||
val titlePane = getTitlePane() ?: return null
|
||||
val handlerField = titlePane.javaClass.getDeclaredField("handler") ?: return null
|
||||
handlerField.isAccessible = true
|
||||
return handlerField.get(titlePane)
|
||||
}
|
||||
|
||||
private fun getTitlePane(): FlatTitlePane? {
|
||||
val ui = rootPane.ui as? FlatRootPaneUI ?: return null
|
||||
val titlePaneField = ui.javaClass.getDeclaredField("titlePane")
|
||||
titlePaneField.isAccessible = true
|
||||
return titlePaneField.get(ui) as? FlatTitlePane
|
||||
}
|
||||
}
|
||||
toolbar.getJToolBar().addMouseListener(mouseAdapter)
|
||||
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
|
||||
@@ -170,7 +172,6 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
// Windows 10 会有1像素误差
|
||||
tabbedPane.tabAreaInsets = Insets(if (SystemInfo.isWindows_11_orLater) 1 else 2, 2, 0, 0)
|
||||
} else if (SystemInfo.isLinux) {
|
||||
rootPane.setUI(myUI)
|
||||
tabbedPane.tabAreaInsets = Insets(1, 2, 0, 0)
|
||||
}
|
||||
|
||||
@@ -210,6 +211,11 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
}
|
||||
}
|
||||
|
||||
val glassPane = GlassPane()
|
||||
rootPane.glassPane = glassPane
|
||||
glassPane.isOpaque = false
|
||||
glassPane.isVisible = true
|
||||
|
||||
|
||||
Disposer.register(windowScope, terminalTabbed)
|
||||
add(terminalTabbed, BorderLayout.CENTER)
|
||||
@@ -239,4 +245,31 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
return id.hashCode()
|
||||
}
|
||||
|
||||
fun addNotifyListener(listener: NotifyListener) {
|
||||
notifyListeners += listener
|
||||
}
|
||||
|
||||
fun removeNotifyListener(listener: NotifyListener) {
|
||||
notifyListeners = ArrayUtils.removeElements(notifyListeners, listener)
|
||||
}
|
||||
|
||||
override fun addNotify() {
|
||||
super.addNotify()
|
||||
notifyListeners.forEach { it.addNotify() }
|
||||
}
|
||||
|
||||
|
||||
private class GlassPane : JComponent() {
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val img = BackgroundManager.getInstance().getBackgroundImage() ?: return
|
||||
val g2d = g as Graphics2D
|
||||
g2d.composite = AlphaComposite.getInstance(
|
||||
AlphaComposite.SRC_OVER,
|
||||
if (FlatLaf.isLafDark()) 0.2f else 0.1f
|
||||
)
|
||||
g2d.drawImage(img, 0, 0, width, height, null)
|
||||
g2d.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.native.osx.NativeMacLibrary
|
||||
import com.formdev.flatlaf.ui.FlatNativeWindowsLibrary
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.platform.win32.User32
|
||||
import com.sun.jna.platform.win32.WinDef
|
||||
import com.sun.jna.platform.win32.WinUser.*
|
||||
import de.jangassen.jfa.ThreadUtils
|
||||
import de.jangassen.jfa.foundation.Foundation
|
||||
import de.jangassen.jfa.foundation.ID
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Frame
|
||||
import java.awt.Window
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.JFrame
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
@@ -13,7 +24,8 @@ import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
|
||||
import kotlin.math.max
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class TermoraFrameManager {
|
||||
|
||||
class TermoraFrameManager : Disposable {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TermoraFrameManager::class.java)
|
||||
@@ -26,6 +38,8 @@ class TermoraFrameManager {
|
||||
|
||||
private val frames = mutableListOf<TermoraFrame>()
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val isDisposed = AtomicBoolean(false)
|
||||
private val isBackgroundRunning get() = Database.getDatabase().appearance.backgroundRunning
|
||||
|
||||
fun createWindow(): TermoraFrame {
|
||||
val frame = TermoraFrame().apply { registerCloseCallback(this) }
|
||||
@@ -50,6 +64,15 @@ class TermoraFrameManager {
|
||||
}
|
||||
}
|
||||
|
||||
frame.addNotifyListener(object : NotifyListener {
|
||||
private val opacity get() = Database.getDatabase().appearance.opacity
|
||||
override fun addNotify() {
|
||||
val opacity = this.opacity
|
||||
if (opacity >= 1.0) return
|
||||
setOpacity(frame, opacity)
|
||||
}
|
||||
})
|
||||
|
||||
return frame.apply { frames.add(this) }
|
||||
}
|
||||
|
||||
@@ -59,6 +82,7 @@ class TermoraFrameManager {
|
||||
|
||||
|
||||
private fun registerCloseCallback(window: TermoraFrame) {
|
||||
val manager = this
|
||||
window.addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
|
||||
@@ -74,24 +98,49 @@ class TermoraFrameManager {
|
||||
Disposer.dispose(windowScope)
|
||||
|
||||
val windowScopes = ApplicationScope.windowScopes()
|
||||
if (windowScopes.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经没有 Window 域了,那么就可以退出程序了
|
||||
if (windowScopes.isEmpty()) {
|
||||
this@TermoraFrameManager.dispose()
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
Disposer.dispose(manager)
|
||||
} else if (SystemInfo.isMacOS) {
|
||||
// 如果 macOS 开启了后台运行,那么尽管所有窗口都没了,也不会退出
|
||||
if (isBackgroundRunning) {
|
||||
return
|
||||
}
|
||||
Disposer.dispose(manager)
|
||||
}
|
||||
}
|
||||
|
||||
override fun windowClosing(e: WindowEvent) {
|
||||
if (ApplicationScope.windowScopes().size == 1) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
window,
|
||||
I18n.getString("termora.quit-confirm", Application.getName()),
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
window.dispose()
|
||||
}
|
||||
} else {
|
||||
if (ApplicationScope.windowScopes().size != 1) {
|
||||
window.dispose()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果 Windows 开启了后台运行,那么最小化
|
||||
if (SystemInfo.isWindows && isBackgroundRunning) {
|
||||
// 最小化
|
||||
window.extendedState = window.extendedState or JFrame.ICONIFIED
|
||||
// 隐藏
|
||||
window.isVisible = false
|
||||
return
|
||||
}
|
||||
|
||||
// 如果 macOS 已经开启了后台运行,那么直接销毁,因为会有一个进程驻守
|
||||
if (SystemInfo.isMacOS && isBackgroundRunning) {
|
||||
window.dispose()
|
||||
return
|
||||
}
|
||||
|
||||
val option = OptionPane.showConfirmDialog(
|
||||
window,
|
||||
I18n.getString("termora.quit-confirm", Application.getName()),
|
||||
optionType = JOptionPane.YES_NO_OPTION,
|
||||
)
|
||||
if (option == JOptionPane.YES_OPTION) {
|
||||
window.dispose()
|
||||
}
|
||||
}
|
||||
@@ -106,6 +155,7 @@ class TermoraFrameManager {
|
||||
if (window.extendedState and JFrame.ICONIFIED == JFrame.ICONIFIED) {
|
||||
window.extendedState = window.extendedState and JFrame.ICONIFIED.inv()
|
||||
}
|
||||
window.isVisible = true
|
||||
}
|
||||
windows.last().toFront()
|
||||
} else {
|
||||
@@ -113,14 +163,16 @@ class TermoraFrameManager {
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispose() {
|
||||
Disposer.dispose(ApplicationScope.forApplicationScope())
|
||||
override fun dispose() {
|
||||
if (isDisposed.compareAndSet(false, true)) {
|
||||
Disposer.dispose(ApplicationScope.forApplicationScope())
|
||||
|
||||
try {
|
||||
Disposer.getTree().assertIsEmpty(true)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
try {
|
||||
Disposer.getTree().assertIsEmpty(true)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +196,31 @@ class TermoraFrameManager {
|
||||
return FrameRectangle(x, y, w, h, s)
|
||||
}
|
||||
|
||||
fun setOpacity(opacity: Double) {
|
||||
if (opacity < 0 || opacity > 1 || SystemInfo.isLinux) return
|
||||
for (window in getWindows()) {
|
||||
setOpacity(window, opacity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setOpacity(window: Window, opacity: Double) {
|
||||
if (SystemInfo.isMacOS) {
|
||||
val nsWindow = ID(NativeMacLibrary.getNSWindow(window) ?: return)
|
||||
ThreadUtils.dispatch_async {
|
||||
Foundation.invoke(nsWindow, "setOpaque:", false)
|
||||
Foundation.invoke(nsWindow, "setAlphaValue:", opacity)
|
||||
}
|
||||
} else if (SystemInfo.isWindows) {
|
||||
val alpha = ((opacity * 255).toInt() and 0xFF).toByte()
|
||||
val hwnd = WinDef.HWND(Pointer.createConstant(FlatNativeWindowsLibrary.getHWND(window)))
|
||||
val exStyle = User32.INSTANCE.GetWindowLong(hwnd, User32.GWL_EXSTYLE)
|
||||
if (exStyle and WS_EX_LAYERED == 0) {
|
||||
User32.INSTANCE.SetWindowLong(hwnd, GWL_EXSTYLE, exStyle or WS_EX_LAYERED)
|
||||
}
|
||||
User32.INSTANCE.SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA)
|
||||
}
|
||||
}
|
||||
|
||||
private data class FrameRectangle(
|
||||
val x: Int, val y: Int, val w: Int, val h: Int, val s: Int
|
||||
) {
|
||||
|
||||
@@ -51,7 +51,7 @@ class OutlineTextArea : FlatTextArea() {
|
||||
}
|
||||
}
|
||||
|
||||
class OutlineComboBox<T> : JComboBox<T>() {
|
||||
class OutlineComboBox<T> : FlatComboBox<T>() {
|
||||
init {
|
||||
addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
@@ -146,7 +146,7 @@ open class EmailFormattedTextField(var maxLength: Int = Int.MAX_VALUE) : Outline
|
||||
}
|
||||
|
||||
|
||||
abstract class NumberSpinner(
|
||||
open class NumberSpinner(
|
||||
value: Int,
|
||||
minimum: Int,
|
||||
maximum: Int,
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.FlatAnimatedLafChange
|
||||
import com.jthemedetecor.OsThemeDetector
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
@@ -76,8 +75,7 @@ class ThemeManager private constructor() {
|
||||
|
||||
|
||||
init {
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
OsThemeDetector.getDetector().registerListener(object : Consumer<Boolean> {
|
||||
override fun accept(isDark: Boolean) {
|
||||
if (!appearance.followSystem) {
|
||||
|
||||
@@ -16,9 +16,7 @@ import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.awt.event.*
|
||||
import javax.swing.*
|
||||
import javax.swing.event.DocumentEvent
|
||||
import kotlin.math.max
|
||||
@@ -219,6 +217,26 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
searchTextField.addKeyListener(object : KeyAdapter() {
|
||||
private val event = ActionEvent(hostTree, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY)
|
||||
private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
|
||||
|
||||
override fun keyPressed(e: KeyEvent) {
|
||||
if (e.keyCode == KeyEvent.VK_DOWN || e.keyCode == KeyEvent.VK_ENTER || e.keyCode == KeyEvent.VK_UP) {
|
||||
when (e.keyCode) {
|
||||
KeyEvent.VK_UP -> hostTree.actionMap.get("selectPrevious")?.actionPerformed(event)
|
||||
KeyEvent.VK_DOWN -> hostTree.actionMap.get("selectNext")?.actionPerformed(event)
|
||||
else -> {
|
||||
for (node in hostTree.getSelectionSimpleTreeNodes(true)) {
|
||||
openHostAction?.actionPerformed(OpenHostActionEvent(hostTree, node.host, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
e.consume()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun perform() {
|
||||
|
||||
@@ -36,6 +36,7 @@ class AppUpdateAction private constructor() : AnAction(
|
||||
StringUtils.EMPTY,
|
||||
Icons.ideUpdate
|
||||
) {
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Swing)
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(AppUpdateAction::class.java)
|
||||
@@ -59,7 +60,6 @@ class AppUpdateAction private constructor() : AnAction(
|
||||
}
|
||||
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun scheduleUpdate() {
|
||||
fixedRateTimer(
|
||||
name = "check-update-timer",
|
||||
@@ -67,7 +67,7 @@ class AppUpdateAction private constructor() : AnAction(
|
||||
period = 5.hours.inWholeMilliseconds, daemon = true
|
||||
) {
|
||||
if (!isRemindMeNextTime) {
|
||||
GlobalScope.launch(Dispatchers.IO) { supervisorScope { launch { checkUpdate() } } }
|
||||
coroutineScope.launch(Dispatchers.IO) { checkUpdate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
package app.termora.actions
|
||||
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.net.URI
|
||||
import java.util.*
|
||||
import javax.swing.JOptionPane
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class OpenHostAction : AnAction() {
|
||||
companion object {
|
||||
@@ -26,10 +39,70 @@ class OpenHostAction : AnAction() {
|
||||
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
|
||||
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
|
||||
Protocol.SFTPPty -> SFTPPtyTerminalTab(windowScope, evt.host)
|
||||
Protocol.RDP -> openRDP(windowScope, evt.host)
|
||||
else -> LocalTerminalTab(windowScope, evt.host)
|
||||
}
|
||||
|
||||
terminalTabbedManager.addTerminalTab(tab)
|
||||
tab.start()
|
||||
if (tab is TerminalTab) {
|
||||
terminalTabbedManager.addTerminalTab(tab)
|
||||
if (tab is PtyHostTerminalTab) {
|
||||
tab.start()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun openRDP(windowScope: WindowScope, host: Host) {
|
||||
if (SystemInfo.isLinux) {
|
||||
OptionPane.showMessageDialog(
|
||||
windowScope.window,
|
||||
"Linux cannot connect to Windows Remote Server, Supported only for macOS and Windows",
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (SystemInfo.isMacOS) {
|
||||
if (!FileUtils.getFile("/Applications/Windows App.app").exists()) {
|
||||
val option = OptionPane.showConfirmDialog(
|
||||
windowScope.window,
|
||||
"If you want to connect to a Windows Remote Server, You have to install the Windows App",
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
)
|
||||
if (option == JOptionPane.OK_OPTION) {
|
||||
Application.browse(URI.create("https://apps.apple.com/app/windows-app/id1295203466"))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.append("full address:s:").append(host.host).append(':').append(host.port).appendLine()
|
||||
sb.append("username:s:").append(host.username).appendLine()
|
||||
|
||||
val file = FileUtils.getFile(Application.getTemporaryDir(), UUID.randomUUID().toSimpleString() + ".rdp")
|
||||
file.outputStream().use { IOUtils.write(sb.toString(), it, Charsets.UTF_8) }
|
||||
|
||||
if (host.authentication.type == AuthenticationType.Password) {
|
||||
val systemClipboard = windowScope.window.toolkit.systemClipboard
|
||||
val password = host.authentication.password
|
||||
systemClipboard.setContents(StringSelection(password), null)
|
||||
// clear password
|
||||
swingCoroutineScope.launch(Dispatchers.IO) {
|
||||
delay(30.seconds)
|
||||
if (systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
|
||||
if (systemClipboard.getData(DataFlavor.stringFlavor) == password) {
|
||||
systemClipboard.setContents(StringSelection(StringUtils.EMPTY), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemInfo.isMacOS) {
|
||||
ProcessBuilder("open", file.absolutePath).start()
|
||||
} else if (SystemInfo.isWindows) {
|
||||
ProcessBuilder("mstsc", file.absolutePath).start()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ class ChooseColorTemplateDialog(owner: Window, title: String) : DialogWrapper(ow
|
||||
val customBtn = JButton("Custom")
|
||||
customBtn.addActionListener {
|
||||
val dialog = MyColorPickerDialog(this)
|
||||
dialog.setLocationRelativeTo(this)
|
||||
dialog.colorPicker.color = defaultColor
|
||||
dialog.isVisible = true
|
||||
val color = dialog.color
|
||||
|
||||
@@ -24,6 +24,11 @@ data class KeywordHighlight(
|
||||
*/
|
||||
val matchCase: Boolean = false,
|
||||
|
||||
/**
|
||||
* 是否是正则表达式
|
||||
*/
|
||||
val regex: Boolean = false,
|
||||
|
||||
/**
|
||||
* 0 是取前景色
|
||||
*/
|
||||
@@ -62,5 +67,10 @@ data class KeywordHighlight(
|
||||
/**
|
||||
* 排序
|
||||
*/
|
||||
val sort: Long = System.currentTimeMillis()
|
||||
val sort: Long = System.currentTimeMillis(),
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
val updateDate: Long = System.currentTimeMillis(),
|
||||
)
|
||||
@@ -20,10 +20,8 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
|
||||
private val model = KeywordHighlightTableModel()
|
||||
private val table = FlatTable()
|
||||
private val keywordHighlightManager by lazy { KeywordHighlightManager.getInstance() }
|
||||
private val colorPalette by lazy {
|
||||
TerminalFactory.getInstance().createTerminal().getTerminalModel()
|
||||
.getColorPalette()
|
||||
}
|
||||
private val terminal by lazy { TerminalFactory.getInstance().createTerminal() }
|
||||
private val colorPalette by lazy { terminal.getTerminalModel().getColorPalette() }
|
||||
|
||||
private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add"))
|
||||
private val editBtn = JButton(I18n.getString("termora.keymgr.edit"))
|
||||
@@ -130,6 +128,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
|
||||
|
||||
addBtn.addActionListener {
|
||||
val dialog = NewKeywordHighlightDialog(this, colorPalette)
|
||||
dialog.setLocationRelativeTo(this)
|
||||
dialog.isVisible = true
|
||||
val keywordHighlight = dialog.keywordHighlight
|
||||
if (keywordHighlight != null) {
|
||||
@@ -143,6 +142,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
|
||||
if (row > -1) {
|
||||
var keywordHighlight = model.getKeywordHighlight(row)
|
||||
val dialog = NewKeywordHighlightDialog(this, colorPalette)
|
||||
dialog.setLocationRelativeTo(this)
|
||||
dialog.keywordTextField.text = keywordHighlight.keyword
|
||||
dialog.descriptionTextField.text = keywordHighlight.description
|
||||
|
||||
@@ -176,6 +176,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
|
||||
dialog.underlineCheckBox.isSelected = keywordHighlight.underline
|
||||
dialog.lineThroughCheckBox.isSelected = keywordHighlight.lineThrough
|
||||
dialog.matchCaseBtn.isSelected = keywordHighlight.matchCase
|
||||
dialog.regexBtn.isSelected = keywordHighlight.regex
|
||||
|
||||
dialog.isVisible = true
|
||||
|
||||
@@ -201,7 +202,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
|
||||
for (row in rows) {
|
||||
val id = model.getKeywordHighlight(row).id
|
||||
keywordHighlightManager.removeKeywordHighlight(id)
|
||||
model.removeRow(row)
|
||||
model.fireTableRowsDeleted(row, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,6 +212,12 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
|
||||
editBtn.isEnabled = table.selectedRowCount > 0
|
||||
deleteBtn.isEnabled = editBtn.isEnabled
|
||||
}
|
||||
|
||||
Disposer.register(disposable, object : Disposable {
|
||||
override fun dispose() {
|
||||
terminal.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.termora.highlight
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.Database
|
||||
import app.termora.DeleteDataManager
|
||||
import app.termora.TerminalPanelFactory
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
@@ -38,6 +39,7 @@ class KeywordHighlightManager private constructor() {
|
||||
database.removeKeywordHighlight(id)
|
||||
keywordHighlights.remove(id)
|
||||
TerminalPanelFactory.getInstance().repaintAll()
|
||||
DeleteDataManager.getInstance().removeKeywordHighlight(id)
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Keyword highlighter removed. {}", id)
|
||||
|
||||
@@ -5,6 +5,7 @@ import app.termora.terminal.*
|
||||
import app.termora.terminal.panel.TerminalDisplay
|
||||
import app.termora.terminal.panel.TerminalPaintListener
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Graphics
|
||||
import kotlin.math.min
|
||||
import kotlin.random.Random
|
||||
@@ -18,9 +19,10 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
|
||||
}
|
||||
|
||||
private val tag = Random.nextInt()
|
||||
private val log = LoggerFactory.getLogger(KeywordHighlightPaintListener::class.java)
|
||||
}
|
||||
|
||||
private val keywordHighlightManager by lazy { KeywordHighlightManager.getInstance() }
|
||||
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
|
||||
|
||||
override fun before(
|
||||
offset: Int,
|
||||
@@ -36,7 +38,8 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
|
||||
}
|
||||
|
||||
val document = terminal.getDocument()
|
||||
val kinds = SubstrFinder(object : Iterator<TerminalLine> {
|
||||
val kinds = mutableListOf<FindKind>()
|
||||
val iterator = object : Iterator<TerminalLine> {
|
||||
private var index = offset + 1
|
||||
private val maxCount = min(index + count, document.getLineCount())
|
||||
override fun hasNext(): Boolean {
|
||||
@@ -46,8 +49,24 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
|
||||
override fun next(): TerminalLine {
|
||||
return document.getLine(index++)
|
||||
}
|
||||
}
|
||||
|
||||
}, CharArraySubstr(highlight.keyword.toCharArray())).find(!highlight.matchCase)
|
||||
if (highlight.regex) {
|
||||
try {
|
||||
val regex = if (highlight.matchCase)
|
||||
highlight.keyword.toRegex()
|
||||
else highlight.keyword.toRegex(RegexOption.IGNORE_CASE)
|
||||
RegexFinder(regex, iterator).find()
|
||||
.apply { kinds.addAll(this) }
|
||||
} catch (e: Exception) {
|
||||
if (log.isDebugEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SubstrFinder(iterator, CharArraySubstr(highlight.keyword.toCharArray())).find(!highlight.matchCase)
|
||||
.apply { kinds.addAll(this) }
|
||||
}
|
||||
|
||||
for (kind in kinds) {
|
||||
terminal.getMarkupModel().addHighlighter(
|
||||
@@ -77,6 +96,74 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
|
||||
terminal.getMarkupModel().removeAllHighlighters(tag)
|
||||
}
|
||||
|
||||
private class RegexFinder(
|
||||
private val regex: Regex,
|
||||
private val iterator: Iterator<TerminalLine>
|
||||
) {
|
||||
private data class Coords(val row: Int, val col: Int)
|
||||
private data class MatchResultWithCoords(
|
||||
val match: String,
|
||||
val coords: List<Coords>
|
||||
)
|
||||
|
||||
fun find(): List<FindKind> {
|
||||
|
||||
val lines = mutableListOf<TerminalLine>()
|
||||
val kinds = mutableListOf<FindKind>()
|
||||
|
||||
for ((index, line) in iterator.withIndex()) {
|
||||
|
||||
lines.add(line)
|
||||
if (line.wrapped) continue
|
||||
|
||||
val data = mutableListOf<MutableList<Char>>()
|
||||
for (e in lines) {
|
||||
data.add(mutableListOf())
|
||||
for (c in e.chars()) {
|
||||
if (c.first.isNull) break
|
||||
data.last().add(c.first)
|
||||
}
|
||||
}
|
||||
|
||||
lines.clear()
|
||||
|
||||
val resultWithCoords = findMatchesWithCoords(data)
|
||||
if (resultWithCoords.isEmpty()) continue
|
||||
val offset = index - data.size + 1
|
||||
|
||||
for (e in resultWithCoords) {
|
||||
val coords = e.coords
|
||||
if (coords.isEmpty()) continue
|
||||
kinds.add(
|
||||
FindKind(
|
||||
startPosition = Position(coords.first().row + offset + 1, coords.first().col + 1),
|
||||
endPosition = Position(coords.last().row + offset + 1, coords.last().col + 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return kinds
|
||||
}
|
||||
|
||||
private fun findMatchesWithCoords(data: List<List<Char>>): List<MatchResultWithCoords> {
|
||||
val flatChars = StringBuilder()
|
||||
val indexMap = mutableListOf<Coords>()
|
||||
|
||||
// 拉平成字符串,并记录每个字符的位置
|
||||
for ((rowIndex, row) in data.withIndex()) {
|
||||
for ((colIndex, char) in row.withIndex()) {
|
||||
flatChars.append(char)
|
||||
indexMap.add(Coords(rowIndex, colIndex))
|
||||
}
|
||||
}
|
||||
|
||||
return regex.findAll(flatChars.toString())
|
||||
.map { MatchResultWithCoords(it.value, indexMap.subList(it.range.first, it.range.last + 1)) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class KeywordHighlightHighlighter(
|
||||
range: HighlighterRange, terminal: Terminal,
|
||||
@@ -93,4 +180,6 @@ class KeywordHighlightPaintListener private constructor() : TerminalPaintListene
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package app.termora.highlight
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.I18n
|
||||
import app.termora.Icons
|
||||
import app.termora.Database
|
||||
import app.termora.*
|
||||
import app.termora.terminal.ColorPalette
|
||||
import app.termora.terminal.TerminalColor
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
@@ -46,6 +42,7 @@ class NewKeywordHighlightDialog(
|
||||
I18n.getString("termora.highlight.background-color")
|
||||
)
|
||||
val matchCaseBtn = JToggleButton(Icons.matchCase)
|
||||
val regexBtn = JToggleButton(Icons.regex)
|
||||
|
||||
|
||||
private val textColorRevert = JButton(Icons.revert)
|
||||
@@ -85,6 +82,7 @@ class NewKeywordHighlightDialog(
|
||||
|
||||
val box = FlatToolBar()
|
||||
box.add(matchCaseBtn)
|
||||
box.add(regexBtn)
|
||||
keywordTextField.trailingComponent = box
|
||||
|
||||
repaintKeywordHighlightView()
|
||||
@@ -187,6 +185,7 @@ class NewKeywordHighlightDialog(
|
||||
}
|
||||
|
||||
private fun createColorPanel(color: Color, title: String): ColorPanel {
|
||||
val owner = this
|
||||
val arc = UIManager.getInt("Component.arc")
|
||||
val lineBorder = FlatLineBorder(Insets(1, 1, 1, 1), DynamicColor.BorderColor, 1f, arc)
|
||||
val colorPanel = ColorPanel(color)
|
||||
@@ -195,7 +194,8 @@ class NewKeywordHighlightDialog(
|
||||
colorPanel.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e)) {
|
||||
val dialog = ChooseColorTemplateDialog(this@NewKeywordHighlightDialog, title)
|
||||
val dialog = ChooseColorTemplateDialog(owner, title)
|
||||
dialog.setLocationRelativeTo(owner)
|
||||
dialog.defaultColor = colorPanel.color
|
||||
dialog.isVisible = true
|
||||
colorPanel.color = dialog.color ?: return
|
||||
@@ -218,6 +218,7 @@ class NewKeywordHighlightDialog(
|
||||
keyword = keywordTextField.text,
|
||||
description = descriptionTextField.text,
|
||||
matchCase = matchCaseBtn.isSelected,
|
||||
regex = regexBtn.isSelected,
|
||||
textColor = if (textColor.colorIndex != -1) textColor.colorIndex else textColor.color.toRGB(),
|
||||
backgroundColor = if (backgroundColor.colorIndex != -1) backgroundColor.colorIndex else backgroundColor.color.toRGB(),
|
||||
bold = boldCheckBox.isSelected,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora.keymap
|
||||
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.event.KeyEvent
|
||||
import javax.swing.KeyStroke
|
||||
@@ -23,7 +24,14 @@ class KeyShortcut(val keyStroke: KeyStroke) : Shortcut() {
|
||||
text = text.replace("MINUS", "-")
|
||||
}
|
||||
|
||||
return text.toCharArray().joinToString(" + ")
|
||||
text = text.toCharArray().joinToString(" + ")
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
text = text.replace("⇧", "Shift")
|
||||
text = text.replace("⌃", "Ctrl")
|
||||
text = text.replace("⌥", "Alt")
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package app.termora.keymap
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.*
|
||||
import javax.swing.KeyStroke
|
||||
|
||||
@@ -12,6 +11,10 @@ open class Keymap(
|
||||
*/
|
||||
private val parent: Keymap?,
|
||||
val isReadonly: Boolean = false,
|
||||
/**
|
||||
* 修改时间
|
||||
*/
|
||||
var updateDate: Long = 0L,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -23,7 +26,8 @@ open class Keymap(
|
||||
val shortcuts = mutableListOf<Keymap>()
|
||||
val name = json["name"]?.jsonPrimitive?.content ?: return null
|
||||
val readonly = json["readonly"]?.jsonPrimitive?.booleanOrNull ?: return null
|
||||
val keymap = Keymap(name, null, readonly)
|
||||
val updateDate = json["updateDate"]?.jsonPrimitive?.longOrNull ?: 0
|
||||
val keymap = Keymap(name, null, readonly, updateDate)
|
||||
|
||||
for (shortcut in (json["shortcuts"]?.jsonArray ?: emptyList()).map { it.jsonObject }) {
|
||||
val keyStroke = shortcut["keyStroke"]?.jsonPrimitive?.contentOrNull ?: continue
|
||||
@@ -40,6 +44,9 @@ open class Keymap(
|
||||
}
|
||||
}
|
||||
|
||||
// 最后设置修改时间
|
||||
keymap.updateDate = updateDate
|
||||
|
||||
shortcuts.add(keymap)
|
||||
return keymap
|
||||
}
|
||||
@@ -51,6 +58,7 @@ open class Keymap(
|
||||
val actionIds = shortcuts.getOrPut(shortcut) { mutableListOf() }
|
||||
actionIds.removeIf { it == actionId }
|
||||
actionIds.add(actionId)
|
||||
updateDate = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
open fun removeAllActionShortcuts(actionId: Any) {
|
||||
@@ -62,6 +70,7 @@ open class Keymap(
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
updateDate = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
open fun getShortcut(actionId: Any): List<Shortcut> {
|
||||
@@ -102,6 +111,7 @@ open class Keymap(
|
||||
return buildJsonObject {
|
||||
put("name", name)
|
||||
put("readonly", isReadonly)
|
||||
put("updateDate", updateDate)
|
||||
parent?.let { put("parent", it.name) }
|
||||
put("shortcuts", buildJsonArray {
|
||||
for (entry in shortcuts.entries) {
|
||||
|
||||
@@ -89,6 +89,7 @@ class KeymapManager private constructor() : Disposable {
|
||||
fun removeKeymap(name: String) {
|
||||
keymaps.remove(name)
|
||||
database.removeKeymap(name)
|
||||
DeleteDataManager.getInstance().removeKeymap(name)
|
||||
}
|
||||
|
||||
private inner class KeymapKeyEventDispatcher : KeyEventDispatcher {
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.termora.keymgr
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.Database
|
||||
import app.termora.DeleteDataManager
|
||||
|
||||
class KeyManager private constructor() {
|
||||
companion object {
|
||||
@@ -29,6 +30,7 @@ class KeyManager private constructor() {
|
||||
fun removeOhKeyPair(id: String) {
|
||||
keyPairs.removeIf { it.id == id }
|
||||
database.removeKeyPair(id)
|
||||
DeleteDataManager.getInstance().removeKeyPair(id)
|
||||
}
|
||||
|
||||
fun getOhKeyPairs(): List<OhKeyPair> {
|
||||
@@ -39,9 +41,4 @@ class KeyManager private constructor() {
|
||||
return keyPairs.findLast { it.id == id }
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
keyPairs.clear()
|
||||
database.removeAllKeyPair()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package app.termora.keymgr
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.AES.decodeBase64
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.native.FileChooser
|
||||
@@ -13,7 +12,6 @@ import com.formdev.flatlaf.ui.FlatTextBorder
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import net.i2p.crypto.eddsa.EdDSAPublicKey
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.io.file.PathUtils
|
||||
@@ -33,7 +31,6 @@ import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.security.KeyPair
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import java.util.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
@@ -313,15 +310,9 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
nameTextField.text = ohKeyPair.name
|
||||
remarkTextField.text = ohKeyPair.remark
|
||||
val baos = ByteArrayOutputStream()
|
||||
if (ohKeyPair.type == "RSA") {
|
||||
OpenSSHKeyPairResourceWriter.INSTANCE
|
||||
.writePublicKey(RSA.generatePublic(ohKeyPair.publicKey.decodeBase64()), null, baos)
|
||||
} else if (ohKeyPair.type == "ED25519") {
|
||||
OpenSSHKeyPairResourceWriter.INSTANCE.writePublicKey(
|
||||
EdDSAPublicKey(X509EncodedKeySpec(ohKeyPair.publicKey.decodeBase64())),
|
||||
null, baos
|
||||
)
|
||||
}
|
||||
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(ohKeyPair)
|
||||
OpenSSHKeyPairResourceWriter.INSTANCE
|
||||
.writePublicKey(keyPair.public, null, baos)
|
||||
publicKeyTextArea.text = baos.toString()
|
||||
savePublicKeyBtn.isEnabled = true
|
||||
} else {
|
||||
|
||||
@@ -15,6 +15,7 @@ data class OhKeyPair(
|
||||
val remark: String,
|
||||
val length: Int,
|
||||
val sort: Long,
|
||||
val updateDate: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
companion object {
|
||||
val empty = OhKeyPair(String(), String(), String(), String(), String(), String(), 0, 0)
|
||||
|
||||
@@ -2,10 +2,9 @@ package app.termora.keymgr
|
||||
|
||||
import app.termora.AES.decodeBase64
|
||||
import app.termora.RSA
|
||||
import net.i2p.crypto.eddsa.EdDSAPrivateKey
|
||||
import net.i2p.crypto.eddsa.EdDSAPublicKey
|
||||
import org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider
|
||||
import org.apache.sshd.common.session.SessionContext
|
||||
import org.apache.sshd.common.util.security.eddsa.Ed25519PublicKeyDecoder
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.security.Key
|
||||
import java.security.KeyPair
|
||||
@@ -25,7 +24,7 @@ class OhKeyPairKeyPairProvider(private val id: String) : AbstractResourceKeyPair
|
||||
val publicKey = cache.getOrPut(ohKeyPair.publicKey) {
|
||||
when (ohKeyPair.type) {
|
||||
"RSA" -> RSA.generatePublic(ohKeyPair.publicKey.decodeBase64())
|
||||
"ED25519" -> EdDSAPublicKey(X509EncodedKeySpec(ohKeyPair.publicKey.decodeBase64()))
|
||||
"ED25519" -> Ed25519PublicKeyDecoder.INSTANCE.generatePublicKey((X509EncodedKeySpec(ohKeyPair.publicKey.decodeBase64())))
|
||||
else -> throw UnsupportedOperationException("${ohKeyPair.type} is not supported")
|
||||
}
|
||||
} as PublicKey
|
||||
@@ -33,7 +32,7 @@ class OhKeyPairKeyPairProvider(private val id: String) : AbstractResourceKeyPair
|
||||
val privateKey = cache.getOrPut(ohKeyPair.privateKey) {
|
||||
when (ohKeyPair.type) {
|
||||
"RSA" -> RSA.generatePrivate(ohKeyPair.privateKey.decodeBase64())
|
||||
"ED25519" -> EdDSAPrivateKey(PKCS8EncodedKeySpec(ohKeyPair.privateKey.decodeBase64()))
|
||||
"ED25519" -> Ed25519PublicKeyDecoder.INSTANCE.generatePrivateKey(PKCS8EncodedKeySpec(ohKeyPair.privateKey.decodeBase64()))
|
||||
else -> throw UnsupportedOperationException("${ohKeyPair.type} is not supported")
|
||||
}
|
||||
} as PrivateKey
|
||||
|
||||
@@ -43,7 +43,7 @@ class SSHCopyIdDialog(
|
||||
terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate())
|
||||
.apply { enableFloatingToolbar = false }
|
||||
}
|
||||
private val coroutineScope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 100, UIManager.getInt("Dialog.height") - 100)
|
||||
@@ -144,7 +144,7 @@ class SSHCopyIdDialog(
|
||||
}
|
||||
|
||||
try {
|
||||
val client = SshClients.openClient(host).apply { myClient = this }
|
||||
val client = SshClients.openClient(host, this).apply { myClient = this }
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
val session = SshClients.openSession(host, client).apply { mySession = this }
|
||||
val channel =
|
||||
|
||||
@@ -19,6 +19,11 @@ data class Macro(
|
||||
* 越大越靠前
|
||||
*/
|
||||
val sort: Long = System.currentTimeMillis(),
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
val updateDate: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val macroByteArray by lazy { macro.decodeBase64() }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.termora.macro
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.Database
|
||||
import app.termora.DeleteDataManager
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
@@ -38,6 +39,7 @@ class MacroManager private constructor() {
|
||||
fun removeMacro(id: String) {
|
||||
database.removeMacro(id)
|
||||
macros.remove(id)
|
||||
DeleteDataManager.getInstance().removeMacro(id)
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Removed macro $id")
|
||||
|
||||
9
src/main/kotlin/app/termora/sftp/FileSystemProvider.kt
Normal file
9
src/main/kotlin/app/termora/sftp/FileSystemProvider.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import org.apache.commons.vfs2.FileSystem
|
||||
|
||||
|
||||
interface FileSystemProvider {
|
||||
fun getFileSystem(): FileSystem
|
||||
fun setFileSystem(fileSystem: FileSystem)
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import app.termora.Icons
|
||||
import app.termora.assertEventDispatchThread
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatTextField
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.provider.local.LocalFileSystem
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
@@ -14,8 +19,7 @@ import java.awt.event.ActionEvent
|
||||
import java.awt.event.ActionListener
|
||||
import java.awt.event.ItemEvent
|
||||
import java.awt.event.ItemListener
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.FileSystems
|
||||
import javax.swing.*
|
||||
import javax.swing.event.PopupMenuEvent
|
||||
import javax.swing.event.PopupMenuListener
|
||||
@@ -23,8 +27,8 @@ import javax.swing.filechooser.FileSystemView
|
||||
import kotlin.io.path.absolutePathString
|
||||
|
||||
class FileSystemViewNav(
|
||||
private val fileSystem: FileSystem,
|
||||
private val homeDirectory: Path
|
||||
private val fileSystemProvider: FileSystemProvider,
|
||||
private val homeDirectory: FileObject
|
||||
) : JPanel(BorderLayout()) {
|
||||
|
||||
companion object {
|
||||
@@ -38,7 +42,7 @@ class FileSystemViewNav(
|
||||
private val history = linkedSetOf<String>()
|
||||
private val layeredPane = LayeredPane()
|
||||
private val downBtn = JButton(Icons.chevronDown)
|
||||
private val comboBox = object : JComboBox<Path>() {
|
||||
private val comboBox = object : JComboBox<FileObject>() {
|
||||
override fun getLocationOnScreen(): Point {
|
||||
val point = super.getLocationOnScreen()
|
||||
point.y -= 1
|
||||
@@ -80,7 +84,7 @@ class FileSystemViewNav(
|
||||
): Component {
|
||||
val c = super.getListCellRendererComponent(
|
||||
list,
|
||||
value,
|
||||
if (value is FileObject) formatDisplayPath(value) else value.toString(),
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
@@ -99,12 +103,12 @@ class FileSystemViewNav(
|
||||
add(layeredPane, BorderLayout.CENTER)
|
||||
|
||||
|
||||
if (fileSystem.isWindows()) {
|
||||
if (SystemInfo.isWindows && fileSystemProvider.getFileSystem() is LocalFileSystem) {
|
||||
try {
|
||||
for (root in fileSystemView.roots) {
|
||||
history.add(root.absolutePath)
|
||||
}
|
||||
for (rootDirectory in fileSystem.rootDirectories) {
|
||||
for (rootDirectory in FileSystems.getDefault().rootDirectories) {
|
||||
history.add(rootDirectory.absolutePathString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -115,12 +119,16 @@ class FileSystemViewNav(
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDisplayPath(file: FileObject): String {
|
||||
return file.absolutePathString()
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
val itemListener = ItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
val item = comboBox.selectedItem
|
||||
if (item is Path) {
|
||||
if (item is FileObject) {
|
||||
changeSelectedPath(item)
|
||||
}
|
||||
}
|
||||
@@ -166,8 +174,17 @@ class FileSystemViewNav(
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val name = textField.text.trim()
|
||||
if (name.isBlank()) return
|
||||
val fileSystem = fileSystemProvider.getFileSystem()
|
||||
try {
|
||||
changeSelectedPath(fileSystem.getPath(name))
|
||||
if (fileSystem is LocalFileSystem && SystemUtils.IS_OS_WINDOWS) {
|
||||
val file = VFS.getManager().resolveFile("file://${name}")
|
||||
if (!StringUtils.equals(file.fileSystem.rootURI, fileSystemProvider.getFileSystem().rootURI)) {
|
||||
fileSystemProvider.setFileSystem(file.fileSystem)
|
||||
}
|
||||
changeSelectedPath(file)
|
||||
} else {
|
||||
changeSelectedPath(fileSystem.resolveFile(name))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
@@ -180,9 +197,14 @@ class FileSystemViewNav(
|
||||
private fun showComboBoxPopup() {
|
||||
|
||||
comboBox.removeAllItems()
|
||||
val fileSystem = fileSystemProvider.getFileSystem()
|
||||
|
||||
for (text in history) {
|
||||
val path = fileSystem.getPath(text)
|
||||
val path = if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
|
||||
VFS.getManager().resolveFile("file://${text}")
|
||||
} else {
|
||||
fileSystem.resolveFile(text)
|
||||
}
|
||||
comboBox.addItem(path)
|
||||
if (text == textField.text) {
|
||||
comboBox.selectedItem = path
|
||||
@@ -218,15 +240,22 @@ class FileSystemViewNav(
|
||||
}
|
||||
}
|
||||
|
||||
fun getSelectedPath(): Path {
|
||||
return textField.getClientProperty(PATH) as Path
|
||||
fun getSelectedPath(): FileObject {
|
||||
return textField.getClientProperty(PATH) as FileObject
|
||||
}
|
||||
|
||||
fun changeSelectedPath(path: Path) {
|
||||
fun changeSelectedPath(file: FileObject) {
|
||||
assertEventDispatchThread()
|
||||
|
||||
textField.text = path.absolutePathString()
|
||||
textField.putClientProperty(PATH, path)
|
||||
textField.text = formatDisplayPath(file)
|
||||
textField.putClientProperty(PATH, file)
|
||||
|
||||
val fileSystem = fileSystemProvider.getFileSystem()
|
||||
if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
|
||||
if (!StringUtils.equals(fileSystem.rootURI, file.fileSystem.rootURI)) {
|
||||
fileSystemProvider.setFileSystem(file.fileSystem)
|
||||
}
|
||||
}
|
||||
|
||||
for (listener in listenerList.getListeners(ActionListener::class.java)) {
|
||||
listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))
|
||||
|
||||
@@ -3,35 +3,38 @@ package app.termora.sftp
|
||||
import app.termora.*
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.vfs2.sftp.MySftpFileSystem
|
||||
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||
import kotlinx.coroutines.*
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.FileSystem
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.provider.local.LocalFileSystem
|
||||
import org.jdesktop.swingx.JXBusyLabel
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.event.*
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.function.Consumer
|
||||
import javax.swing.*
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.io.path.name
|
||||
|
||||
class FileSystemViewPanel(
|
||||
val host: Host,
|
||||
val fileSystem: FileSystem,
|
||||
private var fileSystem: FileSystem,
|
||||
private val transportManager: TransportManager,
|
||||
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) : JPanel(BorderLayout()), Disposable, DataProvider {
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) : JPanel(BorderLayout()), Disposable, DataProvider, FileSystemProvider {
|
||||
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val sftp get() = Database.getDatabase().sftp
|
||||
private val table = FileSystemViewTable(fileSystem, transportManager, coroutineScope)
|
||||
private val table = FileSystemViewTable(this, transportManager, coroutineScope)
|
||||
private val disposed = AtomicBoolean(false)
|
||||
private var nextReloadTicks = emptyArray<Consumer<Unit>>()
|
||||
private val isLoading = AtomicBoolean(false)
|
||||
@@ -39,7 +42,7 @@ class FileSystemViewPanel(
|
||||
private val loadingPanel = LoadingPanel()
|
||||
private val layeredPane = LayeredPane()
|
||||
private val homeDirectory = getHomeDirectory()
|
||||
private val nav = FileSystemViewNav(fileSystem, homeDirectory)
|
||||
private val nav = FileSystemViewNav(this, homeDirectory)
|
||||
private var workdir = homeDirectory
|
||||
private val model get() = table.model as FileSystemViewTableModel
|
||||
private val showHiddenFilesKey = "termora.transport.host.${host.id}.show-hidden-files"
|
||||
@@ -100,7 +103,7 @@ class FileSystemViewPanel(
|
||||
override fun onTransportChanged(transport: Transport) {
|
||||
val path = transport.target.parent ?: return
|
||||
if (path.fileSystem != fileSystem) return
|
||||
if (path.absolutePathString() != workdir.absolutePathString()) return
|
||||
if (path.name.path != workdir.name.path) return
|
||||
// 立即刷新
|
||||
reload(true)
|
||||
}
|
||||
@@ -123,19 +126,19 @@ class FileSystemViewPanel(
|
||||
|
||||
private fun enterTableSelectionFolder(row: Int = table.selectedRow) {
|
||||
if (row < 0 || isLoading.get()) return
|
||||
val attr = model.getAttr(row)
|
||||
if (attr.isFile) return
|
||||
val file = model.getFileObject(row)
|
||||
if (file.isFile) return
|
||||
|
||||
// 当前工作目录
|
||||
val workdir = getWorkdir()
|
||||
|
||||
// 返回上级之后,选中上级目录
|
||||
if (attr.name == "..") {
|
||||
if (row == 0 && model.hasParent) {
|
||||
val workdirName = workdir.name
|
||||
nextReloadTickSelection(workdirName)
|
||||
nextReloadTickSelection(workdirName.baseName)
|
||||
}
|
||||
|
||||
changeWorkdir(attr.path)
|
||||
changeWorkdir(file)
|
||||
|
||||
}
|
||||
|
||||
@@ -169,13 +172,21 @@ class FileSystemViewPanel(
|
||||
bookmarkBtn.addActionListener { e ->
|
||||
if (e.actionCommand.isNullOrBlank()) {
|
||||
if (bookmarkBtn.isBookmark) {
|
||||
bookmarkBtn.deleteBookmark(workdir.toString())
|
||||
bookmarkBtn.deleteBookmark(workdir.absolutePathString())
|
||||
} else {
|
||||
bookmarkBtn.addBookmark(workdir.toString())
|
||||
bookmarkBtn.addBookmark(workdir.absolutePathString())
|
||||
}
|
||||
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
|
||||
} else {
|
||||
changeWorkdir(fileSystem.getPath(e.actionCommand))
|
||||
if (fileSystem is LocalFileSystem && SystemUtils.IS_OS_WINDOWS) {
|
||||
val file = VFS.getManager().resolveFile("file://${e.actionCommand}")
|
||||
if (!StringUtils.equals(file.fileSystem.rootURI, fileSystem.rootURI)) {
|
||||
fileSystem = file.fileSystem
|
||||
}
|
||||
changeWorkdir(file)
|
||||
} else {
|
||||
changeWorkdir(fileSystem.resolveFile(e.actionCommand))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,14 +205,12 @@ class FileSystemViewPanel(
|
||||
button.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (model.rowCount < 1) return
|
||||
val attr = model.getAttr(0)
|
||||
if (attr !is FileSystemViewTableModel.ParentAttr) return
|
||||
enterTableSelectionFolder(0)
|
||||
if (model.hasParent) enterTableSelectionFolder(0)
|
||||
}
|
||||
})
|
||||
|
||||
addPropertyChangeListener("workdir") {
|
||||
button.isEnabled = model.rowCount > 0 && model.getAttr(0) is FileSystemViewTableModel.ParentAttr
|
||||
button.isEnabled = model.rowCount > 0 && model.hasParent
|
||||
}
|
||||
|
||||
return button
|
||||
@@ -211,7 +220,7 @@ class FileSystemViewPanel(
|
||||
// 创建成功之后需要修改和选中
|
||||
registerNextReloadTick {
|
||||
for (i in 0 until table.rowCount) {
|
||||
if (model.getAttr(i).name == name) {
|
||||
if (model.getFileObject(i).name.baseName == name) {
|
||||
table.addRowSelectionInterval(i, i)
|
||||
table.scrollRectToVisible(table.getCellRect(i, 0, true))
|
||||
consumer.accept(i)
|
||||
@@ -221,18 +230,19 @@ class FileSystemViewPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeWorkdir(workdir: Path) {
|
||||
private fun changeWorkdir(workdir: FileObject) {
|
||||
assertEventDispatchThread()
|
||||
nav.changeSelectedPath(workdir)
|
||||
}
|
||||
|
||||
fun renameTo(oldPath: Path, newPath: Path) {
|
||||
fun renameTo(oldPath: FileObject, newPath: FileObject) {
|
||||
|
||||
// 新建文件夹
|
||||
coroutineScope.launch {
|
||||
|
||||
if (requestLoading()) {
|
||||
try {
|
||||
Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE)
|
||||
oldPath.moveTo(newPath)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
@@ -247,7 +257,7 @@ class FileSystemViewPanel(
|
||||
}
|
||||
|
||||
// 创建成功之后需要选中
|
||||
nextReloadTickSelection(newPath.name)
|
||||
nextReloadTickSelection(newPath.name.baseName)
|
||||
|
||||
// 立即刷新
|
||||
reload()
|
||||
@@ -258,7 +268,7 @@ class FileSystemViewPanel(
|
||||
coroutineScope.launch {
|
||||
if (requestLoading()) {
|
||||
try {
|
||||
doNewFolderOrFile(getWorkdir().resolve(name), isFile)
|
||||
doNewFolderOrFile(getWorkdir().resolveFile(name), isFile)
|
||||
} finally {
|
||||
stopLoading()
|
||||
}
|
||||
@@ -273,9 +283,9 @@ class FileSystemViewPanel(
|
||||
}
|
||||
|
||||
|
||||
private suspend fun doNewFolderOrFile(path: Path, isFile: Boolean) {
|
||||
private suspend fun doNewFolderOrFile(path: FileObject, isFile: Boolean) {
|
||||
|
||||
if (Files.exists(path)) {
|
||||
if (path.exists()) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
@@ -288,7 +298,7 @@ class FileSystemViewPanel(
|
||||
|
||||
// 创建文件夹
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { if (isFile) Files.createFile(path) else Files.createDirectories(path) }.onFailure {
|
||||
runCatching { if (isFile) path.createFile() else path.createFolder() }.onFailure {
|
||||
withContext(Dispatchers.Swing) {
|
||||
if (it is Exception) {
|
||||
OptionPane.showMessageDialog(
|
||||
@@ -329,7 +339,7 @@ class FileSystemViewPanel(
|
||||
|
||||
fun reload(rememberSelection: Boolean = false) {
|
||||
if (!requestLoading()) return
|
||||
if (fileSystem.isSFTP()) loadingPanel.start()
|
||||
if (fileSystem is MySftpFileSystem) loadingPanel.start()
|
||||
val oldWorkdir = workdir
|
||||
val path = nav.getSelectedPath()
|
||||
|
||||
@@ -338,7 +348,7 @@ class FileSystemViewPanel(
|
||||
|
||||
if (rememberSelection) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
table.selectedRows.sortedDescending().map { model.getAttr(it).name }
|
||||
table.selectedRows.sortedDescending().map { model.getFileObject(it).name.baseName }
|
||||
.forEach { nextReloadTickSelection(it) }
|
||||
}
|
||||
}
|
||||
@@ -347,7 +357,7 @@ class FileSystemViewPanel(
|
||||
if (it is Exception) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, ExceptionUtils.getMessage(it),
|
||||
owner, ExceptionUtils.getRootCauseMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
@@ -367,34 +377,41 @@ class FileSystemViewPanel(
|
||||
|
||||
} finally {
|
||||
stopLoading()
|
||||
if (fileSystem.isSFTP()) {
|
||||
if (fileSystem is MySftpFileSystem) {
|
||||
withContext(Dispatchers.Swing) { loadingPanel.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getHomeDirectory(): Path {
|
||||
if (fileSystem.isSFTP()) {
|
||||
val fs = fileSystem as SftpFileSystem
|
||||
val host = fs.session.getAttribute(SshClients.HOST_KEY) ?: return fs.defaultDir
|
||||
private fun getHomeDirectory(): FileObject {
|
||||
val fileSystem = this.fileSystem
|
||||
if (fileSystem is MySftpFileSystem) {
|
||||
val host = fileSystem.getClientSession().getAttribute(SshClients.HOST_KEY)
|
||||
?: return fileSystem.resolveFile(fileSystem.getDefaultDir())
|
||||
val defaultDirectory = host.options.sftpDefaultDirectory
|
||||
if (defaultDirectory.isNotBlank()) {
|
||||
return runCatching { fs.getPath(defaultDirectory) }
|
||||
.getOrElse { fs.defaultDir }
|
||||
return fileSystem.resolveFile(defaultDirectory)
|
||||
}
|
||||
return fs.defaultDir
|
||||
return fileSystem.resolveFile(fileSystem.getDefaultDir())
|
||||
}
|
||||
|
||||
if (sftp.defaultDirectory.isNotBlank()) {
|
||||
return runCatching { fileSystem.getPath(sftp.defaultDirectory) }
|
||||
.getOrElse { fileSystem.getPath(SystemUtils.USER_HOME) }
|
||||
val resolveFile = if (fileSystem is LocalFileSystem && SystemInfo.isWindows) {
|
||||
VFS.getManager().resolveFile("file://${sftp.defaultDirectory}")
|
||||
} else {
|
||||
fileSystem.resolveFile("file://${sftp.defaultDirectory}")
|
||||
}
|
||||
if (resolveFile.exists()) {
|
||||
setFileSystem(resolveFile.fileSystem)
|
||||
return resolveFile
|
||||
}
|
||||
}
|
||||
|
||||
return fileSystem.getPath(SystemUtils.USER_HOME)
|
||||
return fileSystem.resolveFile("file://${SystemUtils.USER_HOME}")
|
||||
}
|
||||
|
||||
fun getWorkdir(): Path {
|
||||
fun getWorkdir(): FileObject {
|
||||
return workdir
|
||||
}
|
||||
|
||||
@@ -422,6 +439,7 @@ class FileSystemViewPanel(
|
||||
child.changeStatus(TransportStatus.Failed)
|
||||
}
|
||||
}
|
||||
fileSystem.fileSystemManager.filesCache.clear(fileSystem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,6 +448,14 @@ class FileSystemViewPanel(
|
||||
return if (dataKey == SFTPDataProviders.FileSystemViewTable) table as T else null
|
||||
}
|
||||
|
||||
override fun getFileSystem(): FileSystem {
|
||||
return fileSystem
|
||||
}
|
||||
|
||||
override fun setFileSystem(fileSystem: FileSystem) {
|
||||
this.fileSystem = fileSystem
|
||||
}
|
||||
|
||||
private class LoadingPanel : JPanel() {
|
||||
private val busyLabel = JXBusyLabel()
|
||||
|
||||
|
||||
@@ -3,21 +3,27 @@ package app.termora.sftp
|
||||
import app.termora.*
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.SettingsAction
|
||||
import app.termora.sftp.FileSystemViewTable.AskTransfer.Action
|
||||
import app.termora.vfs2.VFSWalker
|
||||
import app.termora.vfs2.sftp.MySftpFileObject
|
||||
import app.termora.vfs2.sftp.MySftpFileSystem
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
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.apache.sshd.sftp.client.fs.SftpPosixFileAttributes
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.provider.local.LocalFileSystem
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.Insets
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
@@ -27,7 +33,10 @@ import java.awt.event.*
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.*
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.FileVisitor
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.text.MessageFormat
|
||||
import java.util.*
|
||||
@@ -36,13 +45,29 @@ import java.util.regex.Pattern
|
||||
import javax.swing.*
|
||||
import javax.swing.table.DefaultTableCellRenderer
|
||||
import kotlin.collections.ArrayDeque
|
||||
import kotlin.io.path.*
|
||||
import kotlin.collections.List
|
||||
import kotlin.collections.all
|
||||
import kotlin.collections.contains
|
||||
import kotlin.collections.filter
|
||||
import kotlin.collections.filterIsInstance
|
||||
import kotlin.collections.find
|
||||
import kotlin.collections.forEach
|
||||
import kotlin.collections.isEmpty
|
||||
import kotlin.collections.isNotEmpty
|
||||
import kotlin.collections.last
|
||||
import kotlin.collections.listOf
|
||||
import kotlin.collections.map
|
||||
import kotlin.collections.mapOf
|
||||
import kotlin.collections.mutableListOf
|
||||
import kotlin.collections.sortedArray
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
@Suppress("DuplicatedCode", "CascadeIf")
|
||||
class FileSystemViewTable(
|
||||
private val fileSystem: FileSystem,
|
||||
private val fileSystemProvider: FileSystemProvider,
|
||||
private val transportManager: TransportManager,
|
||||
private val coroutineScope: CoroutineScope
|
||||
) : JTable(), Disposable {
|
||||
@@ -99,8 +124,8 @@ class FileSystemViewTable(
|
||||
): Component {
|
||||
foreground = null
|
||||
val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
|
||||
icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getAttr(row).icon else null
|
||||
foreground = if (!isSelected && model.getAttr(row).isHidden)
|
||||
icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getFileIcon(row) else null
|
||||
foreground = if (!isSelected && model.getFileObject(row).isHidden)
|
||||
UIManager.getColor("textInactiveText") else foreground
|
||||
return c
|
||||
}
|
||||
@@ -131,14 +156,14 @@ class FileSystemViewTable(
|
||||
table.requestFocusInWindow()
|
||||
}
|
||||
|
||||
showContextMenu(rows, e)
|
||||
showContextMenu(rows.sortedArray(), e)
|
||||
} else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val row = table.selectedRow
|
||||
if (row <= 0 || row >= table.rowCount) return
|
||||
val attr = model.getAttr(row)
|
||||
if (attr.isDirectory) return
|
||||
val file = model.getFileObject(row)
|
||||
if (file.isFolder) return
|
||||
// 传输
|
||||
transfer(arrayOf(attr))
|
||||
transfer(listOf(file))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -150,8 +175,7 @@ class FileSystemViewTable(
|
||||
if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) {
|
||||
val rows = selectedRows
|
||||
if (rows.contains(0)) return
|
||||
val attrs = rows.map { model.getAttr(it) }.toTypedArray()
|
||||
val files = attrs.map { it.path }.toTypedArray()
|
||||
val files = rows.map { model.getFileObject(it) }
|
||||
deletePaths(files, false)
|
||||
} else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) {
|
||||
fileSystemViewPanel.reload(true)
|
||||
@@ -167,13 +191,15 @@ class FileSystemViewTable(
|
||||
// 如果不是新增行,并且光标不在第一列,那么不允许
|
||||
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false
|
||||
// 如果不是新增行,如果在一个文件上,那么不允许
|
||||
if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false
|
||||
if (!dropLocation.isInsertRow && model.getFileObject(dropLocation.row).isFile) return false
|
||||
// 如果不是新增行,在 .. 上面,不允许
|
||||
if (!dropLocation.isInsertRow && model.hasParent && dropLocation.row == 0) return false
|
||||
|
||||
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
|
||||
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
|
||||
return data is FileSystemTableRowTransferable && data.source != table
|
||||
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||
return !fileSystem.isLocal()
|
||||
return fileSystemProvider.getFileSystem() !is LocalFileSystem
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -184,32 +210,30 @@ class FileSystemViewTable(
|
||||
// 如果不是新增行,并且光标不在第一列,那么不允许
|
||||
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false
|
||||
// 如果不是新增行,如果在一个文件上,那么不允许
|
||||
if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false
|
||||
if (!dropLocation.isInsertRow && model.getFileObject(dropLocation.row).isFile) return false
|
||||
|
||||
var targetWorkdir: Path? = null
|
||||
var targetWorkdir: FileObject? = null
|
||||
|
||||
// 变更工作目录
|
||||
if (!dropLocation.isInsertRow) {
|
||||
targetWorkdir = model.getAttr(dropLocation.row).path
|
||||
targetWorkdir = model.getFileObject(dropLocation.row)
|
||||
}
|
||||
|
||||
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
|
||||
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
|
||||
if (data !is FileSystemTableRowTransferable) return false
|
||||
// 委托源表开始传输
|
||||
data.source.transfer(data.attrs.toTypedArray(), false, targetWorkdir)
|
||||
data.source.transfer(data.files, false, targetWorkdir)
|
||||
return true
|
||||
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||
val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
|
||||
if (files.isEmpty()) return false
|
||||
val paths = files.filterIsInstance<File>()
|
||||
.map { FileSystemViewTableModel.Attr(it.toPath()) }
|
||||
.toTypedArray()
|
||||
val paths = files.filterIsInstance<File>().map { VFS.getManager().resolveFile(it.toURI()) }
|
||||
if (paths.isEmpty()) return false
|
||||
val localTarget = sftpPanel.getLocalTarget()
|
||||
val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false
|
||||
// 委托最左侧的本地文件系统传输
|
||||
table.transfer(paths, true, targetWorkdir)
|
||||
table.transfer(paths, true, targetWorkdir, fileSystemViewPanel)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -220,9 +244,9 @@ class FileSystemViewTable(
|
||||
}
|
||||
|
||||
override fun createTransferable(c: JComponent?): Transferable? {
|
||||
val attrs = table.selectedRows.filter { it != 0 }.map { model.getAttr(it) }
|
||||
if (attrs.isEmpty()) return null
|
||||
return FileSystemTableRowTransferable(table, attrs)
|
||||
val files = table.selectedRows.filter { it != 0 }.map { model.getFileObject(it) }
|
||||
if (files.isEmpty()) return null
|
||||
return FileSystemTableRowTransferable(table, files)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +261,7 @@ class FileSystemViewTable(
|
||||
}
|
||||
|
||||
private fun navigate(row: Int, c: Char): Boolean {
|
||||
val name = model.getAttr(row).name
|
||||
val name = model.getFileObject(row).name.baseName
|
||||
if (name.startsWith(c, true)) {
|
||||
clearSelection()
|
||||
addRowSelectionInterval(row, row)
|
||||
@@ -249,19 +273,10 @@ class FileSystemViewTable(
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
override fun dispose() {
|
||||
if (isDisposed.compareAndSet(false, true)) {
|
||||
if (!fileSystem.isSFTP()) {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showContextMenu(rows: IntArray, e: MouseEvent) {
|
||||
val attrs = rows.map { model.getAttr(it) }.toTypedArray()
|
||||
val files = attrs.map { it.path }.toTypedArray()
|
||||
val files = rows.map { model.getFileObject(it) }
|
||||
val hasParent = rows.contains(0)
|
||||
val fileSystem = fileSystemProvider.getFileSystem()
|
||||
|
||||
val popupMenu = FlatPopupMenu()
|
||||
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
|
||||
@@ -273,13 +288,13 @@ class FileSystemViewTable(
|
||||
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
|
||||
// 编辑
|
||||
val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit"))
|
||||
edit.isEnabled = fileSystem.isSFTP() && attrs.all { it.isFile }
|
||||
edit.isEnabled = fileSystem is MySftpFileSystem && files.all { it.isFile }
|
||||
popupMenu.addSeparator()
|
||||
// 复制路径
|
||||
val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path"))
|
||||
|
||||
// 如果是本地,那么支持打开本地路径
|
||||
if (fileSystem.isLocal()) {
|
||||
if (fileSystem is LocalFileSystem) {
|
||||
popupMenu.add(
|
||||
I18n.getString(
|
||||
"termora.transport.table.contextmenu.open-in-folder",
|
||||
@@ -288,7 +303,7 @@ class FileSystemViewTable(
|
||||
else I18n.getString("termora.folder")
|
||||
)
|
||||
).addActionListener {
|
||||
Application.browseInFolder(files.last().toFile())
|
||||
Application.browseInFolder(File(files.last().absolutePathString()))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -301,18 +316,15 @@ class FileSystemViewTable(
|
||||
val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete"))
|
||||
// rm -rf
|
||||
val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction))
|
||||
|
||||
// 只有 SFTP 可以
|
||||
if (!fileSystem.isSFTP()) {
|
||||
rmrf.isVisible = false
|
||||
}
|
||||
rmrf.isVisible = fileSystem is MySftpFileSystem
|
||||
|
||||
// 修改权限
|
||||
val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
|
||||
permission.isEnabled = false
|
||||
|
||||
// 如果是本地系统文件,那么不允许修改权限,用户应该自己修改
|
||||
if (fileSystem.isSFTP() && rows.isNotEmpty()) {
|
||||
if (fileSystem is MySftpFileSystem && rows.isNotEmpty()) {
|
||||
permission.isEnabled = true
|
||||
}
|
||||
popupMenu.addSeparator()
|
||||
@@ -354,45 +366,20 @@ class FileSystemViewTable(
|
||||
})
|
||||
copyPath.addActionListener {
|
||||
val sb = StringBuilder()
|
||||
attrs.forEach { sb.append(it.path.absolutePathString()).appendLine() }
|
||||
files.forEach { sb.append(it.absolutePathString()).appendLine() }
|
||||
sb.deleteCharAt(sb.length - 1)
|
||||
toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null)
|
||||
}
|
||||
edit.addActionListener { if (files.isNotEmpty()) editFiles(files) }
|
||||
permission.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val last = attrs.last()
|
||||
val dialog = PosixFilePermissionDialog(
|
||||
SwingUtilities.getWindowAncestor(table),
|
||||
last.posixFilePermissions
|
||||
)
|
||||
val permissions = dialog.open() ?: return
|
||||
|
||||
if (fileSystemViewPanel.requestLoading()) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val c = runCatching { Files.setPosixFilePermissions(last.path, permissions) }.onFailure {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
ExceptionUtils.getMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// stop loading
|
||||
fileSystemViewPanel.stopLoading()
|
||||
|
||||
// reload
|
||||
if (c.isSuccess) {
|
||||
fileSystemViewPanel.reload(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
val last = files.last()
|
||||
if (last !is MySftpFileObject) return
|
||||
changePermission(last)
|
||||
}
|
||||
})
|
||||
refresh.addActionListener { fileSystemViewPanel.reload() }
|
||||
transfer.addActionListener { transfer(attrs) }
|
||||
transfer.addActionListener { transfer(files) }
|
||||
|
||||
if (rows.isEmpty() || hasParent) {
|
||||
transfer.isEnabled = false
|
||||
@@ -410,16 +397,90 @@ class FileSystemViewTable(
|
||||
popupMenu.show(table, e.x, e.y)
|
||||
}
|
||||
|
||||
private fun changePermission(file: MySftpFileObject) {
|
||||
|
||||
val dialog = PosixFilePermissionDialog(
|
||||
SwingUtilities.getWindowAncestor(table),
|
||||
model.getFilePermissions(file)
|
||||
)
|
||||
val permissions = dialog.open() ?: return
|
||||
val isIncludeSubdirectories = dialog.isIncludeSubdirectories()
|
||||
|
||||
if (fileSystemViewPanel.requestLoading()) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val c = runCatching {
|
||||
file.setPosixFilePermissions(permissions)
|
||||
if (isIncludeSubdirectories && file.isFolder) {
|
||||
file.refresh()
|
||||
VFSWalker.walk(file, object : FileVisitor<FileObject> {
|
||||
override fun preVisitDirectory(
|
||||
dir: FileObject,
|
||||
attrs: BasicFileAttributes
|
||||
): FileVisitResult {
|
||||
dir.refresh()
|
||||
if (dir is MySftpFileObject) {
|
||||
dir.setPosixFilePermissions(permissions)
|
||||
}
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
override fun visitFile(
|
||||
file: FileObject,
|
||||
attrs: BasicFileAttributes
|
||||
): FileVisitResult {
|
||||
if (file is MySftpFileObject) {
|
||||
file.setPosixFilePermissions(permissions)
|
||||
}
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
override fun visitFileFailed(
|
||||
file: FileObject,
|
||||
exc: IOException
|
||||
): FileVisitResult {
|
||||
return FileVisitResult.TERMINATE
|
||||
}
|
||||
|
||||
override fun postVisitDirectory(
|
||||
dir: FileObject,
|
||||
exc: IOException?
|
||||
): FileVisitResult {
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
ExceptionUtils.getMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// stop loading
|
||||
fileSystemViewPanel.stopLoading()
|
||||
|
||||
// reload
|
||||
if (c.isSuccess) {
|
||||
fileSystemViewPanel.reload(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renameSelection() {
|
||||
val index = selectedRow
|
||||
if (index < 0) return
|
||||
val attr = model.getAttr(index)
|
||||
val file = model.getFileObject(index)
|
||||
val text = OptionPane.showInputDialog(
|
||||
owner,
|
||||
value = attr.name,
|
||||
value = file.name.baseName,
|
||||
title = I18n.getString("termora.transport.table.contextmenu.rename")
|
||||
) ?: return
|
||||
if (text.isBlank() || text == attr.name) return
|
||||
if (text.isBlank() || text == file.name.baseName) return
|
||||
if (model.getPathNames().contains(text)) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
@@ -428,10 +489,11 @@ class FileSystemViewTable(
|
||||
)
|
||||
return
|
||||
}
|
||||
fileSystemViewPanel.renameTo(attr.path, attr.path.parent.resolve(text))
|
||||
|
||||
fileSystemViewPanel.renameTo(file, file.parent.resolveFile(text))
|
||||
}
|
||||
|
||||
private fun editFiles(files: Array<Path>) {
|
||||
private fun editFiles(files: List<FileObject>) {
|
||||
if (files.isEmpty()) return
|
||||
|
||||
if (SystemInfo.isLinux) {
|
||||
@@ -449,10 +511,11 @@ class FileSystemViewTable(
|
||||
|
||||
for (file in files) {
|
||||
val dir = Application.createSubTemporaryDir()
|
||||
val path = Paths.get(dir.absolutePathString(), file.name)
|
||||
val path = Paths.get(dir.absolutePathString(), file.name.baseName)
|
||||
val target = VFS.getManager().resolveFile("file://" + path.absolutePathString())
|
||||
|
||||
val newTransport = createTransport(file, false, 0L)
|
||||
.apply { target = path }
|
||||
.apply { this.target = target }
|
||||
|
||||
transportManager.addTransportListener(object : TransportListener {
|
||||
override fun onTransportChanged(transport: Transport) {
|
||||
@@ -461,7 +524,7 @@ class FileSystemViewTable(
|
||||
transportManager.removeTransportListener(this)
|
||||
if (transport.status != TransportStatus.Done) return
|
||||
// 监听文件变动
|
||||
listenFileChange(path, file)
|
||||
listenFileChange(target, file)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -470,21 +533,15 @@ class FileSystemViewTable(
|
||||
}
|
||||
}
|
||||
|
||||
private fun listenFileChange(localPath: Path, remotePath: Path) {
|
||||
private fun listenFileChange(localPath: FileObject, remotePath: FileObject) {
|
||||
try {
|
||||
val p = localPath.absolutePathString()
|
||||
if (sftp.editCommand.isNotBlank()) {
|
||||
ProcessBuilder(
|
||||
parseCommand(
|
||||
MessageFormat.format(
|
||||
sftp.editCommand,
|
||||
localPath.absolutePathString()
|
||||
)
|
||||
)
|
||||
).start()
|
||||
ProcessBuilder(parseCommand(MessageFormat.format(sftp.editCommand, p))).start()
|
||||
} else if (SystemInfo.isMacOS) {
|
||||
ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start()
|
||||
ProcessBuilder("open", "-a", "TextEdit", p).start()
|
||||
} else if (SystemInfo.isWindows) {
|
||||
ProcessBuilder("notepad", localPath.absolutePathString()).start()
|
||||
ProcessBuilder("notepad", p).start()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@@ -495,13 +552,17 @@ class FileSystemViewTable(
|
||||
return
|
||||
}
|
||||
|
||||
var lastModifiedTime = localPath.getLastModifiedTime().toMillis()
|
||||
var lastModifiedTime = localPath.content.lastModifiedTime
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
while (coroutineScope.isActive) {
|
||||
try {
|
||||
if (isDisposed.get() || !Files.exists(localPath)) break
|
||||
val nowModifiedTime = localPath.getLastModifiedTime().toMillis()
|
||||
|
||||
if (isDisposed.get()) break
|
||||
localPath.refresh()
|
||||
if (!localPath.exists()) break
|
||||
|
||||
val nowModifiedTime = localPath.content.lastModifiedTime
|
||||
if (nowModifiedTime != lastModifiedTime) {
|
||||
lastModifiedTime = nowModifiedTime
|
||||
if (log.isDebugEnabled) {
|
||||
@@ -556,23 +617,7 @@ class FileSystemViewTable(
|
||||
fileSystemViewPanel.newFolderOrFile(text, isFile)
|
||||
}
|
||||
|
||||
private fun transfer(
|
||||
attrs: Array<FileSystemViewTableModel.Attr>,
|
||||
fromLocalSystem: Boolean = false,
|
||||
targetWorkdir: Path? = null
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
doTransfer(attrs, fromLocalSystem, targetWorkdir)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deletePaths(paths: Array<Path>, rm: Boolean = false) {
|
||||
private fun deletePaths(paths: List<FileObject>, rm: Boolean = false) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"),
|
||||
@@ -586,10 +631,10 @@ class FileSystemViewTable(
|
||||
return
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
|
||||
runCatching {
|
||||
if (fileSystem.isSFTP()) {
|
||||
if (fileSystemProvider.getFileSystem() is MySftpFileSystem) {
|
||||
deleteSftpPaths(paths, rm)
|
||||
} else {
|
||||
deleteRecursively(paths)
|
||||
@@ -600,61 +645,215 @@ class FileSystemViewTable(
|
||||
}
|
||||
}
|
||||
|
||||
// 停止加载
|
||||
fileSystemViewPanel.stopLoading()
|
||||
|
||||
// 刷新
|
||||
fileSystemViewPanel.reload()
|
||||
withContext(Dispatchers.Swing) {
|
||||
// 停止加载
|
||||
fileSystemViewPanel.stopLoading()
|
||||
// 刷新
|
||||
fileSystemViewPanel.reload()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteSftpPaths(paths: Array<Path>, rm: Boolean = false) {
|
||||
val fs = this.fileSystem as SftpFileSystem
|
||||
private fun deleteSftpPaths(files: List<FileObject>, rm: Boolean = false) {
|
||||
if (rm) {
|
||||
for (path in paths) {
|
||||
fs.session.executeRemoteCommand(
|
||||
val session = (this.fileSystemProvider.getFileSystem() as MySftpFileSystem).getClientSession()
|
||||
for (path in files) {
|
||||
session.executeRemoteCommand(
|
||||
"rm -rf '${path.absolutePathString()}'",
|
||||
OutputStream.nullOutputStream(),
|
||||
Charsets.UTF_8
|
||||
)
|
||||
}
|
||||
} else {
|
||||
fs.client.use {
|
||||
for (path in paths) {
|
||||
deleteRecursivelySFTP(path as SftpPath, it)
|
||||
}
|
||||
}
|
||||
deleteRecursively(files)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteRecursively(paths: Array<Path>) {
|
||||
for (path in paths) {
|
||||
FileUtils.deleteQuietly(path.toFile())
|
||||
private fun deleteRecursively(files: List<FileObject>) {
|
||||
for (path in files) {
|
||||
path.deleteAll()
|
||||
path.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化删除效率,采用一个连接
|
||||
*/
|
||||
private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) {
|
||||
val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory()
|
||||
if (isDirectory) {
|
||||
for (e in sftpClient.readDir(path.toString())) {
|
||||
if (e.filename == ".." || e.filename == ".") {
|
||||
continue
|
||||
}
|
||||
if (e.attributes.isDirectory) {
|
||||
deleteRecursivelySFTP(path.resolve(e.filename), sftpClient)
|
||||
} else {
|
||||
sftpClient.remove(path.resolve(e.filename).toString())
|
||||
|
||||
private fun transfer(
|
||||
files: List<FileObject>,
|
||||
fromLocalSystem: Boolean = false,
|
||||
targetWorkdir: FileObject? = null,
|
||||
target: FileSystemViewPanel? = null,
|
||||
) {
|
||||
|
||||
assertEventDispatchThread()
|
||||
|
||||
val target = (target ?: sftpPanel.getTarget(table)) ?: return
|
||||
val table = target.getData(SFTPDataProviders.FileSystemViewTable) ?: return
|
||||
var isApplyAll = false
|
||||
var lastAction = Action.Overwrite
|
||||
|
||||
for (file in files) {
|
||||
if (!isApplyAll && (targetWorkdir == null || target.getWorkdir() == targetWorkdir)) {
|
||||
val targetAttr = 0.rangeUntil(table.model.rowCount).map { table.model.getFileObject(it) }
|
||||
.find { it.name.baseName == file.name.baseName }
|
||||
if (targetAttr != null) {
|
||||
val askTransfer = askTransfer(file, targetAttr)
|
||||
if (askTransfer.option != JOptionPane.YES_OPTION) {
|
||||
continue
|
||||
}
|
||||
if (askTransfer.action == Action.Skip) {
|
||||
if (askTransfer.applyAll) break
|
||||
continue
|
||||
} else {
|
||||
lastAction = askTransfer.action
|
||||
isApplyAll = askTransfer.applyAll
|
||||
}
|
||||
}
|
||||
}
|
||||
sftpClient.rmdir(path.toString())
|
||||
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
doTransfer(file, lastAction, fromLocalSystem, targetWorkdir, target)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private data class AskTransfer(
|
||||
val option: Int,
|
||||
val action: Action,
|
||||
val applyAll: Boolean
|
||||
) {
|
||||
enum class Action {
|
||||
Overwrite,
|
||||
Append,
|
||||
Skip
|
||||
}
|
||||
}
|
||||
|
||||
private fun askTransfer(
|
||||
sourceFile: FileObject,
|
||||
targetFile: FileObject
|
||||
): AskTransfer {
|
||||
val formMargin = "7dlu"
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, 2dlu, left:pref",
|
||||
"pref, 12dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val iconSize = 36
|
||||
|
||||
val targetIcon = if (SystemInfo.isWindows)
|
||||
model.getFileIcon(targetFile, iconSize, iconSize)
|
||||
else if (targetFile.isFolder) {
|
||||
FlatSVGIcon(Icons.folder.name, iconSize, iconSize)
|
||||
} else {
|
||||
sftpClient.remove(path.toString())
|
||||
FlatSVGIcon(Icons.file.name, iconSize, iconSize)
|
||||
}
|
||||
|
||||
val sourceIcon = if (SystemInfo.isWindows)
|
||||
model.getFileIcon(sourceFile, iconSize, iconSize)
|
||||
else if (sourceFile.isFolder) {
|
||||
FlatSVGIcon(Icons.folder.name, iconSize, iconSize)
|
||||
} else {
|
||||
FlatSVGIcon(Icons.file.name, iconSize, iconSize)
|
||||
}
|
||||
|
||||
|
||||
val sourceModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(sourceFile), "-")
|
||||
val targetModified = StringUtils.defaultIfBlank(model.getLastModifiedTime(targetFile), "-")
|
||||
|
||||
val actionsComBoBox = JComboBox<Action>()
|
||||
actionsComBoBox.addItem(Action.Overwrite)
|
||||
actionsComBoBox.addItem(Action.Append)
|
||||
actionsComBoBox.addItem(Action.Skip)
|
||||
actionsComBoBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
var text = value?.toString() ?: StringUtils.EMPTY
|
||||
if (value == Action.Overwrite) {
|
||||
text = I18n.getString("termora.transport.sftp.already-exists.overwrite")
|
||||
} else if (value == Action.Skip) {
|
||||
text = I18n.getString("termora.transport.sftp.already-exists.skip")
|
||||
} else if (value == Action.Append) {
|
||||
text = I18n.getString("termora.transport.sftp.already-exists.append")
|
||||
}
|
||||
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
|
||||
}
|
||||
}
|
||||
val applyAllCheckbox = JCheckBox(I18n.getString("termora.transport.sftp.already-exists.apply-all"))
|
||||
val box = Box.createHorizontalBox()
|
||||
box.add(actionsComBoBox)
|
||||
box.add(Box.createHorizontalStrut(8))
|
||||
box.add(applyAllCheckbox)
|
||||
box.add(Box.createHorizontalGlue())
|
||||
|
||||
val ttBox = Box.createVerticalBox()
|
||||
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message1")))
|
||||
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message2")))
|
||||
|
||||
val warningIcon = FlatSVGIcon(
|
||||
Icons.warningIntroduction.name,
|
||||
iconSize,
|
||||
iconSize
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val panel = FormBuilder.create().layout(layout)
|
||||
// tip
|
||||
.add(JLabel(warningIcon)).xy(1, rows)
|
||||
.add(ttBox).xyw(3, rows, 3).apply { rows += step }
|
||||
// name
|
||||
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.name")}:")).xy(1, rows)
|
||||
.add(sourceFile.name.baseName).xyw(3, rows, 3).apply { rows += step }
|
||||
// separator
|
||||
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
|
||||
// Destination
|
||||
.add("${I18n.getString("termora.transport.sftp.already-exists.destination")}:").xy(1, rows)
|
||||
.apply { rows += step }
|
||||
// Folder
|
||||
.add(JLabel(targetIcon)).xy(1, rows, "center, fill")
|
||||
.add(targetModified).xyw(3, rows, 3).apply { rows += step }
|
||||
// Source
|
||||
.add("${I18n.getString("termora.transport.sftp.already-exists.source")}:").xy(1, rows)
|
||||
.apply { rows += step }
|
||||
// Folder
|
||||
.add(JLabel(sourceIcon)).xy(1, rows, "center, fill")
|
||||
.add(sourceModified).xyw(3, rows, 3).apply { rows += step }
|
||||
// separator
|
||||
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
|
||||
// name
|
||||
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.actions")}:")).xy(1, rows)
|
||||
.add(box).xyw(3, rows, 3).apply { rows += step }
|
||||
.build()
|
||||
panel.putClientProperty("SKIP_requestFocusInWindow", true)
|
||||
|
||||
return AskTransfer(
|
||||
option = OptionPane.showConfirmDialog(
|
||||
owner, panel,
|
||||
messageType = JOptionPane.PLAIN_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION,
|
||||
title = sourceFile.name.baseName,
|
||||
initialValue = JOptionPane.YES_OPTION,
|
||||
) {
|
||||
it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height)
|
||||
it.setLocationRelativeTo(it.owner)
|
||||
},
|
||||
action = actionsComBoBox.selectedItem as Action,
|
||||
applyAll = applyAllCheckbox.isSelected
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -662,78 +861,82 @@ class FileSystemViewTable(
|
||||
* 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止
|
||||
*/
|
||||
private fun doTransfer(
|
||||
attrs: Array<FileSystemViewTableModel.Attr>,
|
||||
file: FileObject,
|
||||
action: Action,
|
||||
fromLocalSystem: Boolean,
|
||||
targetWorkdir: Path?
|
||||
targetWorkdir: FileObject?,
|
||||
target: FileSystemViewPanel? = null
|
||||
) {
|
||||
if (attrs.isEmpty()) return
|
||||
val sftpPanel = this.sftpPanel
|
||||
val target = sftpPanel.getTarget(table) ?: return
|
||||
var isTerminate = false
|
||||
val target = (target ?: sftpPanel.getTarget(table)) ?: return
|
||||
|
||||
/**
|
||||
* 定义一个添加器,它可以自动的判断导入/拖拽行为
|
||||
*/
|
||||
val adder = object {
|
||||
fun add(transport: Transport): Boolean {
|
||||
if (action == Action.Append) {
|
||||
transport.mode = StandardOpenOption.APPEND
|
||||
} else {
|
||||
transport.mode = StandardOpenOption.TRUNCATE_EXISTING
|
||||
}
|
||||
return addTransport(
|
||||
sftpPanel,
|
||||
if (fromLocalSystem) file.parent else null,
|
||||
target,
|
||||
targetWorkdir,
|
||||
transport
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (file.isFile) {
|
||||
adder.add(createTransport(file, false, 0).apply { scanned() })
|
||||
return
|
||||
}
|
||||
|
||||
val queue = ArrayDeque<Transport>()
|
||||
var isTerminate = false
|
||||
|
||||
for (attr in attrs) {
|
||||
|
||||
/**
|
||||
* 定义一个添加器,它可以自动的判断导入/拖拽行为
|
||||
*/
|
||||
val adder = object {
|
||||
fun add(transport: Transport): Boolean {
|
||||
return addTransport(
|
||||
sftpPanel,
|
||||
if (fromLocalSystem) attr.path.parent else null,
|
||||
target,
|
||||
targetWorkdir,
|
||||
transport
|
||||
)
|
||||
try {
|
||||
walk(file, object : FileVisitor<FileObject> {
|
||||
override fun preVisitDirectory(dir: FileObject, attrs: BasicFileAttributes): FileVisitResult {
|
||||
val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L)
|
||||
.apply { queue.addLast(this) }
|
||||
if (adder.add(transport)) return FileVisitResult.CONTINUE
|
||||
return FileVisitResult.TERMINATE.apply { isTerminate = true }
|
||||
}
|
||||
}
|
||||
|
||||
if (attr.isFile) {
|
||||
if (!adder.add(createTransport(attr.path, false, 0).apply { scanned() })) {
|
||||
isTerminate = true
|
||||
break
|
||||
override fun visitFile(file: FileObject, attrs: BasicFileAttributes): FileVisitResult {
|
||||
if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS
|
||||
val transport = createTransport(file, false, queue.last().id).apply { scanned() }
|
||||
if (adder.add(transport)) return FileVisitResult.CONTINUE
|
||||
return FileVisitResult.TERMINATE.apply { isTerminate = true }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
queue.clear()
|
||||
|
||||
try {
|
||||
walk(attr.path, object : FileVisitor<Path> {
|
||||
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
|
||||
val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L)
|
||||
.apply { queue.addLast(this) }
|
||||
if (adder.add(transport)) return FileVisitResult.CONTINUE
|
||||
return FileVisitResult.TERMINATE.apply { isTerminate = true }
|
||||
}
|
||||
|
||||
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
|
||||
if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS
|
||||
val transport = createTransport(file, false, queue.last().id).apply { scanned() }
|
||||
if (adder.add(transport)) return FileVisitResult.CONTINUE
|
||||
return FileVisitResult.TERMINATE.apply { isTerminate = true }
|
||||
}
|
||||
|
||||
override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult {
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
|
||||
// 标记为扫描完毕
|
||||
queue.removeLast().scanned()
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
override fun visitFileFailed(file: FileObject, exc: IOException): FileVisitResult {
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
isTerminate = true
|
||||
}
|
||||
|
||||
if (isTerminate) break
|
||||
override fun postVisitDirectory(dir: FileObject, exc: IOException?): FileVisitResult {
|
||||
// 标记为扫描完毕
|
||||
queue.removeLast().scanned()
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
SwingUtilities.invokeLater {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
message = ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
isTerminate = true
|
||||
}
|
||||
|
||||
if (isTerminate) {
|
||||
@@ -742,61 +945,32 @@ class FileSystemViewTable(
|
||||
}
|
||||
}
|
||||
|
||||
private fun walk(dir: Path, visitor: FileVisitor<Path>) {
|
||||
if (fileSystem is SftpFileSystem) {
|
||||
val attr = SftpPosixFileAttributes(dir, SftpClient.Attributes())
|
||||
fileSystem.client.use { walkSFTP(dir, attr, visitor, it) }
|
||||
} else {
|
||||
Files.walkFileTree(dir, setOf(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, visitor)
|
||||
}
|
||||
}
|
||||
|
||||
private fun walkSFTP(
|
||||
dir: Path,
|
||||
attr: SftpPosixFileAttributes,
|
||||
visitor: FileVisitor<Path>,
|
||||
client: SftpClient
|
||||
private fun walk(
|
||||
dir: FileObject,
|
||||
visitor: FileVisitor<FileObject>,
|
||||
): FileVisitResult {
|
||||
|
||||
if (visitor.preVisitDirectory(dir, attr) == FileVisitResult.TERMINATE) {
|
||||
return FileVisitResult.TERMINATE
|
||||
}
|
||||
|
||||
val paths = client.readDir(dir.absolutePathString())
|
||||
for (e in paths) {
|
||||
if (e.filename == ".." || e.filename == ".") continue
|
||||
if (e.attributes.isDirectory) {
|
||||
if (walkSFTP(dir.resolve(e.filename), attr, visitor, client) == FileVisitResult.TERMINATE) {
|
||||
return FileVisitResult.TERMINATE
|
||||
}
|
||||
} else {
|
||||
val result = visitor.visitFile(dir.resolve(e.filename), attr)
|
||||
if (result == FileVisitResult.TERMINATE) {
|
||||
return FileVisitResult.TERMINATE
|
||||
} else if (result == FileVisitResult.SKIP_SUBTREE) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visitor.postVisitDirectory(dir, null) == FileVisitResult.TERMINATE) {
|
||||
return FileVisitResult.TERMINATE
|
||||
}
|
||||
|
||||
return FileVisitResult.CONTINUE
|
||||
return VFSWalker.walk(dir, visitor)
|
||||
}
|
||||
|
||||
private fun addTransport(
|
||||
sftpPanel: SFTPPanel,
|
||||
sourceWorkdir: Path?,
|
||||
sourceWorkdir: FileObject?,
|
||||
target: FileSystemViewPanel,
|
||||
targetWorkdir: Path?,
|
||||
targetWorkdir: FileObject?,
|
||||
transport: Transport
|
||||
): Boolean {
|
||||
return sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport)
|
||||
return try {
|
||||
sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTransport(source: Path, isDirectory: Boolean, parentId: Long): Transport {
|
||||
private fun createTransport(source: FileObject, isDirectory: Boolean, parentId: Long): Transport {
|
||||
val transport = Transport(
|
||||
source = source,
|
||||
target = source,
|
||||
@@ -804,7 +978,7 @@ class FileSystemViewTable(
|
||||
isDirectory = isDirectory,
|
||||
)
|
||||
if (transport.isFile) {
|
||||
transport.filesize.addAndGet(source.fileSize())
|
||||
transport.filesize.addAndGet(source.content.size)
|
||||
}
|
||||
return transport
|
||||
}
|
||||
@@ -812,7 +986,7 @@ class FileSystemViewTable(
|
||||
|
||||
private class FileSystemTableRowTransferable(
|
||||
val source: FileSystemViewTable,
|
||||
val attrs: List<FileSystemViewTableModel.Attr>
|
||||
val files: List<FileObject>
|
||||
) : Transferable {
|
||||
companion object {
|
||||
val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable")
|
||||
@@ -835,4 +1009,5 @@ class FileSystemViewTable(
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -3,21 +3,24 @@ package app.termora.sftp
|
||||
import app.termora.I18n
|
||||
import app.termora.NativeStringComparator
|
||||
import app.termora.formatBytes
|
||||
import app.termora.vfs2.sftp.MySftpFileObject
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.apache.sshd.sftp.client.fs.SftpPath
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.FileType
|
||||
import org.apache.commons.vfs2.provider.local.LocalFileSystem
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.nio.file.attribute.PosixFilePermissions
|
||||
import java.util.*
|
||||
import javax.swing.Icon
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.table.DefaultTableModel
|
||||
import kotlin.io.path.*
|
||||
|
||||
class FileSystemViewTableModel : DefaultTableModel() {
|
||||
|
||||
@@ -29,9 +32,10 @@ class FileSystemViewTableModel : DefaultTableModel() {
|
||||
const val COLUMN_LAST_MODIFIED_TIME = 3
|
||||
const val COLUMN_ATTRS = 4
|
||||
const val COLUMN_OWNER = 5
|
||||
|
||||
private val log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java)
|
||||
|
||||
private fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
|
||||
fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
|
||||
val result = mutableSetOf<PosixFilePermission>()
|
||||
|
||||
// 将十进制权限转换为八进制字符串
|
||||
@@ -68,23 +72,69 @@ class FileSystemViewTableModel : DefaultTableModel() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun getValueAt(row: Int, column: Int): Any {
|
||||
val attr = getAttr(row)
|
||||
return when (column) {
|
||||
COLUMN_NAME -> attr.name
|
||||
COLUMN_FILE_SIZE -> if (attr.isDirectory) StringUtils.EMPTY else formatBytes(attr.size)
|
||||
COLUMN_TYPE -> attr.type
|
||||
COLUMN_LAST_MODIFIED_TIME -> if (attr.modified > 0) DateFormatUtils.format(
|
||||
Date(attr.modified),
|
||||
"yyyy/MM/dd HH:mm"
|
||||
) else StringUtils.EMPTY
|
||||
var hasParent: Boolean = false
|
||||
private set
|
||||
|
||||
COLUMN_ATTRS -> attr.permissions
|
||||
COLUMN_OWNER -> attr.owner
|
||||
else -> StringUtils.EMPTY
|
||||
override fun getValueAt(row: Int, column: Int): Any {
|
||||
val file = getFileObject(row)
|
||||
val isParentRow = hasParent && row == 0
|
||||
|
||||
try {
|
||||
if (file.type == FileType.IMAGINARY) return StringUtils.EMPTY
|
||||
return when (column) {
|
||||
COLUMN_NAME -> if (isParentRow) ".." else file.name.baseName
|
||||
COLUMN_FILE_SIZE -> if (isParentRow || file.isFolder) StringUtils.EMPTY else formatBytes(file.content.size)
|
||||
COLUMN_TYPE -> if (isParentRow) StringUtils.EMPTY else getFileType(file)
|
||||
COLUMN_LAST_MODIFIED_TIME -> if (isParentRow) StringUtils.EMPTY else getLastModifiedTime(file)
|
||||
COLUMN_ATTRS -> if (isParentRow) StringUtils.EMPTY else getAttrs(file)
|
||||
COLUMN_OWNER -> StringUtils.EMPTY
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (file.fileSystem is LocalFileSystem) {
|
||||
if (ExceptionUtils.getRootCause(e) is java.nio.file.NoSuchFileException) {
|
||||
SwingUtilities.invokeLater { removeRow(row) }
|
||||
return StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(e.message, e)
|
||||
}
|
||||
return StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileType(file: FileObject): String {
|
||||
return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile).second
|
||||
else if (file.isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
|
||||
else NativeFileIcons.getIcon(file.name.baseName, file.isFile).second
|
||||
}
|
||||
|
||||
fun getFileIcon(file: FileObject, width: Int = 16, height: Int = 16): Icon {
|
||||
return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile, width, height).first
|
||||
else NativeFileIcons.getIcon(file.name.baseName, file.isFile).first
|
||||
}
|
||||
|
||||
fun getFileIcon(row: Int): Icon {
|
||||
return getFileIcon(getFileObject(row))
|
||||
}
|
||||
|
||||
fun getLastModifiedTime(file: FileObject): String {
|
||||
if (file.content.lastModifiedTime < 1) return "-"
|
||||
return DateFormatUtils.format(Date(file.content.lastModifiedTime), "yyyy/MM/dd HH:mm")
|
||||
}
|
||||
|
||||
private fun getAttrs(file: FileObject): String {
|
||||
if (file.fileSystem is LocalFileSystem) return StringUtils.EMPTY
|
||||
return PosixFilePermissions.toString(getFilePermissions(file))
|
||||
}
|
||||
|
||||
fun getFilePermissions(file: FileObject): Set<PosixFilePermission> {
|
||||
val permissions = file.content.getAttribute(MySftpFileObject.POSIX_FILE_PERMISSIONS)
|
||||
as Int? ?: return emptySet()
|
||||
return fromSftpPermissions(permissions)
|
||||
}
|
||||
|
||||
override fun getDataVector(): Vector<Vector<Any>> {
|
||||
return super.getDataVector()
|
||||
}
|
||||
@@ -100,14 +150,18 @@ class FileSystemViewTableModel : DefaultTableModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun getAttr(row: Int): Attr {
|
||||
return super.getValueAt(row, 0) as Attr
|
||||
fun getFileObject(row: Int): FileObject {
|
||||
return super.getValueAt(row, 0) as FileObject
|
||||
}
|
||||
|
||||
fun getPathNames(): Set<String> {
|
||||
val names = linkedSetOf<String>()
|
||||
for (i in 0 until rowCount) {
|
||||
names.add(getAttr(i).name)
|
||||
if (hasParent && i == 0) {
|
||||
names.add("..")
|
||||
} else {
|
||||
names.add(getFileObject(i).name.baseName)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -129,144 +183,40 @@ class FileSystemViewTableModel : DefaultTableModel() {
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun reload(dir: Path, useFileHiding: Boolean) {
|
||||
suspend fun reload(dir: FileObject, useFileHiding: Boolean) {
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding)
|
||||
}
|
||||
|
||||
val attrs = mutableListOf<Attr>()
|
||||
if (dir.parent != null) {
|
||||
attrs.add(ParentAttr(dir.parent))
|
||||
}
|
||||
val files = mutableListOf<FileObject>()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
Files.list(dir).use { paths ->
|
||||
for (path in paths) {
|
||||
val attr = if (path is SftpPath) SftpAttr(path) else Attr(path)
|
||||
if (useFileHiding && attr.isHidden) continue
|
||||
attrs.add(attr)
|
||||
}
|
||||
dir.refresh()
|
||||
for (file in dir.children) {
|
||||
if (useFileHiding && file.isHidden) continue
|
||||
files.add(file)
|
||||
}
|
||||
}
|
||||
|
||||
attrs.sortWith(compareBy<Attr> { !it.isDirectory }.thenComparing { a, b ->
|
||||
files.sortWith(compareBy<FileObject> { !it.isFolder }.thenComparing { a, b ->
|
||||
NativeStringComparator.getInstance().compare(
|
||||
a.name,
|
||||
b.name
|
||||
a.name.baseName,
|
||||
b.name.baseName
|
||||
)
|
||||
})
|
||||
|
||||
hasParent = dir.parent != null
|
||||
if (hasParent) {
|
||||
files.addFirst(dir.parent)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
while (rowCount > 0) removeRow(0)
|
||||
attrs.forEach { addRow(arrayOf(it)) }
|
||||
files.forEach { addRow(arrayOf(it)) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
open class Attr(val path: Path) {
|
||||
|
||||
/**
|
||||
* 名称
|
||||
*/
|
||||
open val name by lazy { path.name }
|
||||
|
||||
/**
|
||||
* 文件类型
|
||||
*/
|
||||
open val type by lazy {
|
||||
if (path.fileSystem.isWindows()) NativeFileIcons.getIcon(name, isFile).second
|
||||
else if (isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
|
||||
else NativeFileIcons.getIcon(name, isFile).second
|
||||
}
|
||||
|
||||
/**
|
||||
* 大小
|
||||
*/
|
||||
open val size by lazy { path.fileSize() }
|
||||
|
||||
/**
|
||||
* 修改时间
|
||||
*/
|
||||
open val modified by lazy { path.getLastModifiedTime().toMillis() }
|
||||
|
||||
/**
|
||||
* 获取所有者
|
||||
*/
|
||||
open val owner by lazy { StringUtils.EMPTY }
|
||||
|
||||
/**
|
||||
* 获取操作系统图标
|
||||
*/
|
||||
open val icon by lazy { NativeFileIcons.getIcon(name, isFile).first }
|
||||
|
||||
/**
|
||||
* 是否是文件夹
|
||||
*/
|
||||
open val isDirectory by lazy { path.isDirectory() }
|
||||
|
||||
/**
|
||||
* 是否是文件
|
||||
*/
|
||||
open val isFile by lazy { !isDirectory }
|
||||
|
||||
/**
|
||||
* 是否是文件夹
|
||||
*/
|
||||
open val isHidden by lazy { path.isHidden() }
|
||||
|
||||
open val isSymbolicLink by lazy { path.isSymbolicLink() }
|
||||
|
||||
/**
|
||||
* 获取权限
|
||||
*/
|
||||
open val permissions: String by lazy {
|
||||
posixFilePermissions.let {
|
||||
if (it.isNotEmpty()) PosixFilePermissions.toString(
|
||||
it
|
||||
) else StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
open val posixFilePermissions by lazy { if (path.fileSystem.isUnix()) path.getPosixFilePermissions() else emptySet() }
|
||||
|
||||
open fun toFile(): File {
|
||||
if (path.fileSystem.isSFTP()) {
|
||||
return File(path.absolutePathString())
|
||||
}
|
||||
return path.toFile()
|
||||
}
|
||||
}
|
||||
|
||||
class ParentAttr(path: Path) : Attr(path) {
|
||||
override val name by lazy { ".." }
|
||||
override val isDirectory = true
|
||||
override val isFile = false
|
||||
override val isHidden = false
|
||||
override val permissions = StringUtils.EMPTY
|
||||
override val modified = 0L
|
||||
override val type = StringUtils.EMPTY
|
||||
override val icon by lazy { NativeFileIcons.getFolderIcon() }
|
||||
override val isSymbolicLink = false
|
||||
|
||||
}
|
||||
|
||||
|
||||
class SftpAttr(sftpPath: SftpPath) : Attr(sftpPath) {
|
||||
private val attributes = sftpPath.attributes
|
||||
|
||||
override val isSymbolicLink = attributes.isSymbolicLink
|
||||
override val isDirectory = if (isSymbolicLink) sftpPath.isDirectory() else attributes.isDirectory
|
||||
override val isHidden = name.startsWith(".")
|
||||
override val size = attributes.size
|
||||
override val owner: String = StringUtils.defaultString(attributes.owner)
|
||||
override val modified = attributes.modifyTime.toMillis()
|
||||
override val permissions: String = PosixFilePermissions.toString(fromSftpPermissions(attributes.permissions))
|
||||
override val posixFilePermissions = fromSftpPermissions(attributes.permissions)
|
||||
|
||||
override fun toFile(): File {
|
||||
return File(path.absolutePathString())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.formdev.flatlaf.icons.FlatTreeLeafIcon
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.eclipse.jgit.util.LRUMap
|
||||
import java.util.*
|
||||
@@ -24,7 +25,7 @@ object NativeFileIcons {
|
||||
|
||||
init {
|
||||
if (SystemUtils.IS_OS_UNIX) {
|
||||
cache[SystemUtils.USER_HOME] = Pair(FlatTreeClosedIcon(), I18n.getString("termora.folder"))
|
||||
cache[SystemUtils.USER_HOME] = Pair(folderIcon, I18n.getString("termora.folder"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,35 +37,30 @@ object NativeFileIcons {
|
||||
return getIcon(filename, true).first
|
||||
}
|
||||
|
||||
fun getIcon(filename: String, isFile: Boolean = true): Pair<Icon, String> {
|
||||
if (isFile) {
|
||||
val extension = FilenameUtils.getExtension(filename)
|
||||
if (cache.containsKey(extension)) {
|
||||
return cache.getValue(extension)
|
||||
}
|
||||
} else {
|
||||
if (cache.containsKey(SystemUtils.USER_HOME)) {
|
||||
return cache.getValue(SystemUtils.USER_HOME)
|
||||
}
|
||||
fun getIcon(filename: String, isFile: Boolean = true, width: Int = 16, height: Int = 16): Pair<Icon, String> {
|
||||
val key = if (isFile) FilenameUtils.getExtension(filename) + "." + width + "@" + height
|
||||
else SystemUtils.USER_HOME + "." + width + "@" + height
|
||||
|
||||
if (cache.containsKey(key)) {
|
||||
return cache.getValue(key)
|
||||
}
|
||||
|
||||
val isDirectory = !isFile
|
||||
|
||||
if (SystemInfo.isWindows) {
|
||||
|
||||
val file = if (isDirectory) FileUtils.getFile(SystemUtils.USER_HOME) else
|
||||
FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}.${filename}")
|
||||
if (isFile && !file.exists()) {
|
||||
file.createNewFile()
|
||||
}
|
||||
val icon = getFileSystemView().getSystemIcon(file, 16, 16)
|
||||
|
||||
val icon = getFileSystemView().getSystemIcon(file, width, height) ?: if (isFile) fileIcon else folderIcon
|
||||
val description = getFileSystemView().getSystemTypeDescription(file)
|
||||
?: StringUtils.defaultString(file.extension)
|
||||
val pair = icon to description
|
||||
|
||||
if (isDirectory) {
|
||||
cache[SystemUtils.USER_HOME] = pair
|
||||
} else {
|
||||
cache[FilenameUtils.getExtension(file.name)] = pair
|
||||
}
|
||||
cache[key] = pair
|
||||
|
||||
if (isFile) FileUtils.deleteQuietly(file)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class PosixFilePermissionDialog(
|
||||
private val otherRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
|
||||
private val otherWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
|
||||
private val otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
|
||||
private val includeSubFolder = JCheckBox(I18n.getString("termora.transport.permissions.include-subfolder"))
|
||||
|
||||
private var isCancelled = false
|
||||
|
||||
@@ -60,13 +61,14 @@ class PosixFilePermissionDialog(
|
||||
otherRead.isFocusable = false
|
||||
otherWrite.isFocusable = false
|
||||
otherExecute.isFocusable = false
|
||||
includeSubFolder.isFocusable = false
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val formMargin = "7dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, $formMargin, default:grow, $formMargin, default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref"
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin")
|
||||
@@ -95,6 +97,8 @@ class PosixFilePermissionDialog(
|
||||
otherBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.others"))
|
||||
builder.add(otherBox).xy(5, 3)
|
||||
|
||||
builder.add(includeSubFolder).xyw(1, 5, 5)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@@ -103,6 +107,10 @@ class PosixFilePermissionDialog(
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
fun isIncludeSubdirectories(): Boolean {
|
||||
return includeSubFolder.isSelected
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 返回空表示取消了
|
||||
*/
|
||||
|
||||
@@ -56,6 +56,16 @@ class SFTPAction : AnAction("SFTP", Icons.folder) {
|
||||
}
|
||||
|
||||
val host = hostManager.getHost(hostId) ?: return
|
||||
for (i in 0 until tabbed.tabCount) {
|
||||
val c = tabbed.getComponentAt(i)
|
||||
if (c is SFTPFileSystemViewPanel) {
|
||||
if (c.state == SFTPFileSystemViewPanel.State.Initialized) {
|
||||
c.selectHost(host)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tabbed.addSFTPFileSystemViewPanelTab(host)
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package app.termora.sftp
|
||||
import app.termora.*
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder
|
||||
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
@@ -11,10 +12,11 @@ import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.vfs2.FileSystem
|
||||
import org.apache.commons.vfs2.FileSystemOptions
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.sftp.client.SftpClientFactory
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.jdesktop.swingx.JXBusyLabel
|
||||
import org.jdesktop.swingx.JXHyperlink
|
||||
import org.slf4j.LoggerFactory
|
||||
@@ -25,6 +27,8 @@ import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.*
|
||||
import javax.swing.event.TreeExpansionEvent
|
||||
import javax.swing.event.TreeExpansionListener
|
||||
|
||||
class SFTPFileSystemViewPanel(
|
||||
var host: Host? = null,
|
||||
@@ -33,31 +37,30 @@ class SFTPFileSystemViewPanel(
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java)
|
||||
}
|
||||
|
||||
private enum class State {
|
||||
Initialized,
|
||||
Connecting,
|
||||
Connected,
|
||||
ConnectFailed,
|
||||
}
|
||||
enum class State {
|
||||
Initialized,
|
||||
Connecting,
|
||||
Connected,
|
||||
ConnectFailed,
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var state = State.Initialized
|
||||
var state = State.Initialized
|
||||
private set
|
||||
private val cardLayout = CardLayout()
|
||||
private val cardPanel = JPanel(cardLayout)
|
||||
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val connectingPanel = ConnectingPanel()
|
||||
private val selectHostPanel = SelectHostPanel()
|
||||
private val connectFailedPanel = ConnectFailedPanel()
|
||||
private val isDisposed = AtomicBoolean(false)
|
||||
private val that = this
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
|
||||
private var client: SshClient? = null
|
||||
private var session: ClientSession? = null
|
||||
private var fileSystem: SftpFileSystem? = null
|
||||
private var fileSystemPanel: FileSystemViewPanel? = null
|
||||
|
||||
|
||||
@@ -111,11 +114,17 @@ class SFTPFileSystemViewPanel(
|
||||
|
||||
closeIO()
|
||||
|
||||
val mySftpFileSystem: FileSystem
|
||||
|
||||
try {
|
||||
val (client, host) = SshClients.openClient(thisHost, SwingUtilities.getWindowAncestor(that))
|
||||
this.client = client
|
||||
val session = SshClients.openSession(host, client).apply { session = this }
|
||||
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
|
||||
val owner = SwingUtilities.getWindowAncestor(that)
|
||||
val client = SshClients.openClient(thisHost, owner).apply { client = this }
|
||||
val session = SshClients.openSession(thisHost, client).apply { session = this }
|
||||
|
||||
val options = FileSystemOptions()
|
||||
MySftpFileSystemConfigBuilder.getInstance()
|
||||
.setClientSession(options, session)
|
||||
mySftpFileSystem = VFS.getManager().resolveFile("sftp:///", options).fileSystem
|
||||
session.addCloseFutureListener { onClose() }
|
||||
} catch (e: Exception) {
|
||||
closeIO()
|
||||
@@ -126,11 +135,10 @@ class SFTPFileSystemViewPanel(
|
||||
throw IllegalStateException("Closed")
|
||||
}
|
||||
|
||||
val fileSystem = this.fileSystem ?: return
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
state = State.Connected
|
||||
val fileSystemPanel = FileSystemViewPanel(thisHost, fileSystem, transportManager, coroutineScope)
|
||||
val fileSystemPanel = FileSystemViewPanel(thisHost, mySftpFileSystem, transportManager, coroutineScope)
|
||||
cardPanel.add(fileSystemPanel, State.Connected.name)
|
||||
cardLayout.show(cardPanel, State.Connected.name)
|
||||
that.fileSystemPanel = fileSystemPanel
|
||||
@@ -157,7 +165,6 @@ class SFTPFileSystemViewPanel(
|
||||
fileSystemPanel?.let { Disposer.dispose(it) }
|
||||
fileSystemPanel = null
|
||||
|
||||
runCatching { IOUtils.closeQuietly(fileSystem) }
|
||||
runCatching { IOUtils.closeQuietly(session) }
|
||||
runCatching { IOUtils.closeQuietly(client) }
|
||||
|
||||
@@ -279,12 +286,20 @@ class SFTPFileSystemViewPanel(
|
||||
val node = tree.getLastSelectedPathNode() ?: return
|
||||
if (node.isFolder) return
|
||||
val host = node.data as Host
|
||||
that.setTabTitle(host.name)
|
||||
that.host = host
|
||||
that.connect()
|
||||
selectHost(host)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tree.addTreeExpansionListener(object : TreeExpansionListener {
|
||||
override fun treeExpanded(event: TreeExpansionEvent) {
|
||||
properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree))
|
||||
}
|
||||
|
||||
override fun treeCollapsed(event: TreeExpansionEvent) {
|
||||
properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
@@ -301,6 +316,12 @@ class SFTPFileSystemViewPanel(
|
||||
}
|
||||
}
|
||||
|
||||
fun selectHost(host: Host) {
|
||||
that.setTabTitle(host.name)
|
||||
that.host = host
|
||||
that.connect()
|
||||
}
|
||||
|
||||
private fun setTabTitle(title: String) {
|
||||
val tabbed = SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, that)
|
||||
if (tabbed is JTabbedPane) {
|
||||
|
||||
18
src/main/kotlin/app/termora/sftp/SFTPKit.kt
Normal file
18
src/main/kotlin/app/termora/sftp/SFTPKit.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.provider.local.LocalFile
|
||||
import java.io.File
|
||||
|
||||
|
||||
fun FileObject.absolutePathString(): String {
|
||||
var text = name.path
|
||||
if (this is LocalFile && SystemUtils.IS_OS_WINDOWS) {
|
||||
text = this.name.toString()
|
||||
text = StringUtils.removeStart(text, "file:///")
|
||||
text = StringUtils.replace(text, "/", File.separator)
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -5,31 +5,34 @@ import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.findeverywhere.FindEverywhereProvider
|
||||
import app.termora.terminal.DataKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import okio.withLock
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import javax.swing.*
|
||||
import kotlin.io.path.absolutePathString
|
||||
|
||||
fun FileSystem.isSFTP() = this is SftpFileSystem
|
||||
fun FileSystem.isUnix() = isLocal() && SystemUtils.IS_OS_UNIX
|
||||
fun FileSystem.isWindows() = isLocal() && SystemUtils.IS_OS_WINDOWS
|
||||
fun FileSystem.isLocal() = StringUtils.startsWithAny(javaClass.name, "java", "sun")
|
||||
|
||||
class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val transportTable = TransportTable()
|
||||
private val transportManager get() = transportTable.model
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val leftComponent = SFTPTabbed(transportManager)
|
||||
private val rightComponent = SFTPTabbed(transportManager)
|
||||
private val localHost = Host(
|
||||
id = "local",
|
||||
name = I18n.getString("termora.transport.local"),
|
||||
protocol = Protocol.Local,
|
||||
)
|
||||
|
||||
init {
|
||||
initViews()
|
||||
@@ -87,11 +90,10 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
leftComponent.addTab(
|
||||
I18n.getString("termora.transport.local"),
|
||||
FileSystemViewPanel(
|
||||
Host(
|
||||
id = "local",
|
||||
name = I18n.getString("termora.transport.local"),
|
||||
protocol = Protocol.Local,
|
||||
), FileSystems.getDefault(), transportManager
|
||||
localHost,
|
||||
VFS.getManager().resolveFile("file:///${SystemUtils.USER_HOME}").fileSystem,
|
||||
transportManager,
|
||||
coroutineScope
|
||||
)
|
||||
)
|
||||
leftComponent.setTabClosable(0, false)
|
||||
@@ -125,7 +127,7 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
return
|
||||
}
|
||||
|
||||
val fs = c.fileSystem
|
||||
val fs = c.getFileSystem()
|
||||
val root = transportManager.root
|
||||
|
||||
transportManager.lock.withLock {
|
||||
@@ -165,9 +167,9 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
*/
|
||||
fun addTransport(
|
||||
source: JComponent,
|
||||
sourceWorkdir: Path?,
|
||||
sourceWorkdir: FileObject?,
|
||||
target: FileSystemViewPanel,
|
||||
targetWorkdir: Path?,
|
||||
targetWorkdir: FileObject?,
|
||||
transport: Transport
|
||||
): Boolean {
|
||||
|
||||
@@ -175,15 +177,12 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
as? FileSystemViewPanel ?: return false
|
||||
val targetPanel = target as? FileSystemViewPanel ?: return false
|
||||
if (sourcePanel.isDisposed || targetPanel.isDisposed) return false
|
||||
val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir()).absolutePathString()
|
||||
val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir()).absolutePathString()
|
||||
val targetFileSystem = targetPanel.fileSystem
|
||||
val sourcePath = transport.source.absolutePathString()
|
||||
val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir())
|
||||
val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir())
|
||||
val sourcePath = transport.source
|
||||
|
||||
transport.target = targetFileSystem.getPath(
|
||||
myTargetWorkdir,
|
||||
StringUtils.removeStart(sourcePath, mySourceWorkdir)
|
||||
)
|
||||
val relativeName = mySourceWorkdir.name.getRelativeName(sourcePath.name)
|
||||
transport.target = myTargetWorkdir.resolveFile(relativeName)
|
||||
|
||||
return transportManager.addTransport(transport)
|
||||
|
||||
@@ -212,4 +211,8 @@ class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
return dataProviderSupport.getData(dataKey)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import java.awt.Point
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -13,8 +12,6 @@ import javax.swing.JButton
|
||||
import javax.swing.JToolBar
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.UIManager
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.math.max
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPane(), Disposable {
|
||||
@@ -44,23 +41,20 @@ class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPan
|
||||
private fun initEvents() {
|
||||
addBtn.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(tabbed))
|
||||
dialog.location = Point(
|
||||
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("SFTPTabbed.Tree")
|
||||
dialog.allowMulti = true
|
||||
dialog.isVisible = true
|
||||
|
||||
val hosts = dialog.hosts
|
||||
if (hosts.isEmpty()) return
|
||||
|
||||
for (host in hosts) {
|
||||
addSFTPFileSystemViewPanelTab(host)
|
||||
for (i in 0 until tabCount) {
|
||||
val c = getComponentAt(i)
|
||||
if (c !is SFTPFileSystemViewPanel) continue
|
||||
if (c.state != SFTPFileSystemViewPanel.State.Initialized) continue
|
||||
selectedIndex = i
|
||||
return
|
||||
}
|
||||
|
||||
// 添加一个新的
|
||||
addTab(
|
||||
I18n.getString("termora.transport.sftp.select-host"),
|
||||
SFTPFileSystemViewPanel(transportManager = transportManager)
|
||||
)
|
||||
selectedIndex = tabCount - 1
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,18 +6,13 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.net.io.Util
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.BasicFileAttributeView
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.getLastModifiedTime
|
||||
import kotlin.io.path.name
|
||||
|
||||
enum class TransportStatus {
|
||||
Ready,
|
||||
@@ -48,12 +43,19 @@ class Transport(
|
||||
/**
|
||||
* 源
|
||||
*/
|
||||
val source: Path,
|
||||
val source: FileObject,
|
||||
|
||||
/**
|
||||
* 目标
|
||||
*/
|
||||
var target: Path,
|
||||
var target: FileObject,
|
||||
/**
|
||||
* 仅对文件生效,切只有两个选项
|
||||
*
|
||||
* 1. [StandardOpenOption.APPEND]
|
||||
* 2. [StandardOpenOption.TRUNCATE_EXISTING]
|
||||
*/
|
||||
var mode: StandardOpenOption = StandardOpenOption.TRUNCATE_EXISTING
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -154,7 +156,7 @@ class Transport(
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!target.exists()) {
|
||||
target.createDirectories()
|
||||
target.createFolder()
|
||||
}
|
||||
} catch (e: FileAlreadyExistsException) {
|
||||
if (log.isWarnEnabled) {
|
||||
@@ -169,8 +171,8 @@ class Transport(
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val input = Files.newInputStream(source)
|
||||
val output = Files.newOutputStream(target)
|
||||
val input = source.content.inputStream
|
||||
val output = target.content.getOutputStream(mode == StandardOpenOption.APPEND)
|
||||
|
||||
try {
|
||||
|
||||
@@ -209,8 +211,7 @@ class Transport(
|
||||
private fun preserveModificationTime() {
|
||||
// 设置修改时间
|
||||
if (isPreserveModificationTime) {
|
||||
Files.getFileAttributeView(target, BasicFileAttributeView::class.java)
|
||||
.setTimes(source.getLastModifiedTime(), source.getLastModifiedTime(), null)
|
||||
target.content.lastModifiedTime = source.content.lastModifiedTime
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||
import org.jdesktop.swingx.treetable.DefaultTreeTableModel
|
||||
import org.jdesktop.swingx.treetable.MutableTreeTableNode
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.collections.ArrayDeque
|
||||
import kotlin.io.path.name
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
@@ -27,7 +29,7 @@ class TransportTableModel(private val coroutineScope: CoroutineScope) :
|
||||
|
||||
val lock = ReentrantLock()
|
||||
|
||||
private val transports = linkedMapOf<Long, TransportTreeTableNode>()
|
||||
private val transports = Collections.synchronizedMap(linkedMapOf<Long, TransportTreeTableNode>())
|
||||
private val reporter = SpeedReporter(coroutineScope)
|
||||
private var listeners = emptyArray<TransportListener>()
|
||||
private val activeTransports = linkedMapOf<Long, Job>()
|
||||
@@ -104,8 +106,15 @@ class TransportTableModel(private val coroutineScope: CoroutineScope) :
|
||||
transports[transport.id] = newNode
|
||||
|
||||
if ((transports.containsKey(parentId) || p == root) && transports.containsKey(transport.id)) {
|
||||
// 同步加入节点
|
||||
SwingUtilities.invokeLater { insertNodeInto(newNode, p, p.childCount) }
|
||||
// 主线程加入节点
|
||||
SwingUtilities.invokeLater {
|
||||
// 因为是异步的,父节点此时可能已经被移除了
|
||||
if (p == root || transports.containsKey(parentId)) {
|
||||
insertNodeInto(newNode, p, p.childCount)
|
||||
} else {
|
||||
removeTransport(transport.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return@withLock true
|
||||
|
||||
@@ -3,12 +3,11 @@ package app.termora.sftp
|
||||
import app.termora.I18n
|
||||
import app.termora.formatBytes
|
||||
import app.termora.formatSeconds
|
||||
import org.apache.commons.io.file.PathUtils
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import app.termora.vfs2.sftp.MySftpFileSystem
|
||||
import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
|
||||
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.absolutePathString
|
||||
|
||||
class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) {
|
||||
val transport get() = userObject as Transport
|
||||
@@ -20,7 +19,7 @@ class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode
|
||||
(transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0
|
||||
|
||||
return when (column) {
|
||||
TransportTableModel.COLUMN_NAME -> PathUtils.getFileNameString(transport.source)
|
||||
TransportTableModel.COLUMN_NAME -> transport.source.name.baseName
|
||||
TransportTableModel.COLUMN_STATUS -> formatStatus(transport)
|
||||
TransportTableModel.COLUMN_SIZE -> size()
|
||||
TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-"
|
||||
@@ -31,12 +30,14 @@ class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPath(path: Path): String {
|
||||
if (path.fileSystem.isSFTP()) {
|
||||
val hostname = ((path.fileSystem as SftpFileSystem).session as JGitClientSession).hostConfigEntry.hostName
|
||||
return hostname + ":" + path.absolutePathString()
|
||||
private fun formatPath(file: FileObject): String {
|
||||
if (file.fileSystem is MySftpFileSystem) {
|
||||
val session = MySftpFileSystemConfigBuilder.getInstance()
|
||||
.getClientSession(file.fileSystem.fileSystemOptions) as JGitClientSession
|
||||
val hostname = session.hostConfigEntry.hostName
|
||||
return hostname + ":" + file.name.path
|
||||
}
|
||||
return path.toUri().scheme + ":" + path.absolutePathString()
|
||||
return file.name.toString()
|
||||
}
|
||||
|
||||
private fun formatStatus(transport: Transport): String {
|
||||
|
||||
@@ -6,7 +6,10 @@ import app.termora.Icons
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.terminal.ControlCharacters
|
||||
import app.termora.terminal.Null
|
||||
import app.termora.terminal.panel.TerminalWriter
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.text.StringEscapeUtils
|
||||
|
||||
class SnippetAction private constructor() : AnAction(I18n.getString("termora.snippet.title"), Icons.codeSpan) {
|
||||
companion object {
|
||||
@@ -25,18 +28,30 @@ class SnippetAction private constructor() : AnAction(I18n.getString("termora.sni
|
||||
fun runSnippet(snippet: Snippet, writer: TerminalWriter) {
|
||||
if (snippet.type != SnippetType.Snippet) return
|
||||
val map = mapOf(
|
||||
"\\r" to ControlCharacters.CR,
|
||||
"\\n" to ControlCharacters.LF,
|
||||
"\\t" to ControlCharacters.TAB,
|
||||
"\n" to ControlCharacters.LF,
|
||||
"\r" to ControlCharacters.CR,
|
||||
"\t" to ControlCharacters.TAB,
|
||||
"\b" to ControlCharacters.BS,
|
||||
"\\a" to ControlCharacters.BEL,
|
||||
"\\e" to ControlCharacters.ESC,
|
||||
"\\b" to ControlCharacters.BS,
|
||||
)
|
||||
val chars = snippet.snippet.toCharArray()
|
||||
for (i in chars.indices) {
|
||||
val c = chars[i]
|
||||
if (i == 0) continue
|
||||
if (c != '\n') continue
|
||||
if (chars[i - 1] != '\\') continue
|
||||
// 每一行的最后一个 \ 比较特殊,先转成 null 然后再去 unescapeJava
|
||||
chars[i - 1] = Char.Null
|
||||
}
|
||||
|
||||
var text = snippet.snippet
|
||||
var text = chars.joinToString(StringUtils.EMPTY)
|
||||
text = StringEscapeUtils.unescapeJava(text)
|
||||
for (e in map.entries) {
|
||||
text = text.replace(e.key, e.value.toString())
|
||||
}
|
||||
text = text.replace(Char.Null, '\\')
|
||||
|
||||
writer.write(TerminalWriter.WriteRequest.fromBytes(text.toByteArray(writer.getCharset())))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.termora.snippet
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.Database
|
||||
import app.termora.DeleteDataManager
|
||||
import app.termora.assertEventDispatchThread
|
||||
|
||||
|
||||
@@ -20,14 +21,20 @@ class SnippetManager private constructor() {
|
||||
*/
|
||||
fun addSnippet(snippet: Snippet) {
|
||||
assertEventDispatchThread()
|
||||
database.addSnippet(snippet)
|
||||
if (snippet.deleted) {
|
||||
snippets.entries.removeIf { it.value.id == snippet.id || it.value.parentId == snippet.id }
|
||||
removeSnippet(snippet.id)
|
||||
} else {
|
||||
database.addSnippet(snippet)
|
||||
snippets[snippet.id] = snippet
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSnippet(id: String) {
|
||||
snippets.entries.removeIf { it.value.id == id || it.value.parentId == id }
|
||||
database.removeSnippet(id)
|
||||
DeleteDataManager.getInstance().removeSnippet(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 第一次调用从数据库中获取,后续从缓存中获取
|
||||
*/
|
||||
|
||||
@@ -41,8 +41,10 @@ class SnippetPanel : JPanel(BorderLayout()), Disposable {
|
||||
private fun initViews() {
|
||||
val splitPane = JSplitPane()
|
||||
splitPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
||||
val scrollPane = JScrollPane(snippetTree)
|
||||
scrollPane.border = BorderFactory.createEmptyBorder()
|
||||
|
||||
leftPanel.add(snippetTree, BorderLayout.CENTER)
|
||||
leftPanel.add(scrollPane, BorderLayout.CENTER)
|
||||
leftPanel.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor),
|
||||
BorderFactory.createEmptyBorder(4, 4, 4, 4)
|
||||
@@ -51,6 +53,7 @@ class SnippetPanel : JPanel(BorderLayout()), Disposable {
|
||||
properties.getString("SnippetPanel.LeftPanel.width", "180").toIntOrNull() ?: 180,
|
||||
-1
|
||||
)
|
||||
leftPanel.minimumSize = Dimension(leftPanel.preferredSize.width, leftPanel.preferredSize.height)
|
||||
|
||||
rightPanel.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora.snippet
|
||||
|
||||
import app.termora.I18n
|
||||
import app.termora.SimpleTreeModel
|
||||
import javax.swing.tree.MutableTreeNode
|
||||
import javax.swing.tree.TreeNode
|
||||
@@ -8,7 +9,7 @@ class SnippetTreeModel : SimpleTreeModel<Snippet>(
|
||||
SnippetTreeNode(
|
||||
Snippet(
|
||||
id = "0",
|
||||
name = "全部片段",
|
||||
name = I18n.getString("termora.snippet.title"),
|
||||
type = SnippetType.Folder
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.termora.sync
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.DeletedData
|
||||
import app.termora.ResponseException
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@@ -26,46 +27,51 @@ abstract class GitSyncer : SafetySyncer() {
|
||||
}
|
||||
|
||||
val gistResponse = parsePullResponse(response, config)
|
||||
val deletedData = mutableListOf<DeletedData>()
|
||||
|
||||
// DeletedData
|
||||
gistResponse.gists.findLast { it.filename == "DeletedData" }
|
||||
?.let { deletedData.addAll(decodeDeletedData(it.content, config)) }
|
||||
|
||||
// decode hosts
|
||||
if (config.ranges.contains(SyncRange.Hosts)) {
|
||||
gistResponse.gists.findLast { it.filename == "Hosts" }?.let {
|
||||
decodeHosts(it.content, config)
|
||||
decodeHosts(it.content, deletedData.filter { e -> e.type == "Host" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
// decode keys
|
||||
if (config.ranges.contains(SyncRange.KeyPairs)) {
|
||||
gistResponse.gists.findLast { it.filename == "KeyPairs" }?.let {
|
||||
decodeKeys(it.content, config)
|
||||
decodeKeys(it.content, deletedData.filter { e -> e.type == "KeyPair" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
// decode keyword highlights
|
||||
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
|
||||
gistResponse.gists.findLast { it.filename == "KeywordHighlights" }?.let {
|
||||
decodeKeywordHighlights(it.content, config)
|
||||
decodeKeywordHighlights(it.content, deletedData.filter { e -> e.type == "KeywordHighlight" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
// decode macros
|
||||
if (config.ranges.contains(SyncRange.Macros)) {
|
||||
gistResponse.gists.findLast { it.filename == "Macros" }?.let {
|
||||
decodeMacros(it.content, config)
|
||||
decodeMacros(it.content, deletedData.filter { e -> e.type == "Macro" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
// decode keymaps
|
||||
if (config.ranges.contains(SyncRange.Macros)) {
|
||||
gistResponse.gists.findLast { it.filename == "Keymaps" }?.let {
|
||||
decodeKeymaps(it.content, config)
|
||||
decodeKeymaps(it.content, deletedData.filter { e -> e.type == "Keymap" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
// decode Snippets
|
||||
if (config.ranges.contains(SyncRange.Snippets)) {
|
||||
gistResponse.gists.findLast { it.filename == "Snippets" }?.let {
|
||||
decodeSnippets(it.content, config)
|
||||
decodeSnippets(it.content, deletedData.filter { e -> e.type == "Snippet" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +85,11 @@ abstract class GitSyncer : SafetySyncer() {
|
||||
|
||||
|
||||
override fun push(config: SyncConfig): GistResponse {
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Type: ${config.type} , Gist: ${config.gistId} Push...")
|
||||
}
|
||||
|
||||
val gistFiles = mutableListOf<GistFile>()
|
||||
// aes key
|
||||
val key = ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
|
||||
@@ -142,9 +153,21 @@ abstract class GitSyncer : SafetySyncer() {
|
||||
throw IllegalArgumentException("No gist files found")
|
||||
}
|
||||
|
||||
val deletedData = encodeDeletedData(config)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push DeletedData: {}", deletedData)
|
||||
}
|
||||
gistFiles.add(GistFile("DeletedData", deletedData))
|
||||
|
||||
val request = newPushRequestBuilder(gistFiles, config).build()
|
||||
|
||||
return parsePushResponse(httpClient.newCall(request).execute(), config)
|
||||
try {
|
||||
return parsePushResponse(httpClient.newCall(request).execute(), config)
|
||||
} finally {
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Type: ${config.type} , Gist: ${config.gistId} Pushed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun parsePullResponse(response: Response, config: SyncConfig): GistResponse {
|
||||
|
||||
@@ -34,8 +34,9 @@ abstract class SafetySyncer : Syncer {
|
||||
protected val macroManager get() = MacroManager.getInstance()
|
||||
protected val keymapManager get() = KeymapManager.getInstance()
|
||||
protected val snippetManager get() = SnippetManager.getInstance()
|
||||
protected val deleteDataManager get() = DeleteDataManager.getInstance()
|
||||
|
||||
protected fun decodeHosts(text: String, config: SyncConfig) {
|
||||
protected fun decodeHosts(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
|
||||
@@ -44,9 +45,9 @@ abstract class SafetySyncer : Syncer {
|
||||
for (encryptedHost in encryptedHosts) {
|
||||
val oldHost = hosts[encryptedHost.id]
|
||||
|
||||
// 如果一样,则无需配置
|
||||
// 如果本地的修改时间大于云端时间,那么跳过
|
||||
if (oldHost != null) {
|
||||
if (oldHost.updateDate == encryptedHost.updateDate) {
|
||||
if (oldHost.updateDate >= encryptedHost.updateDate) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -83,7 +84,6 @@ abstract class SafetySyncer : Syncer {
|
||||
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
|
||||
createDate = encryptedHost.createDate,
|
||||
updateDate = encryptedHost.updateDate,
|
||||
deleted = encryptedHost.deleted
|
||||
)
|
||||
SwingUtilities.invokeLater { hostManager.addHost(host) }
|
||||
} catch (e: Exception) {
|
||||
@@ -93,6 +93,12 @@ abstract class SafetySyncer : Syncer {
|
||||
}
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
deletedData.forEach {
|
||||
hostManager.removeHost(it.id)
|
||||
deleteDataManager.removeHost(it.id, it.deleteDate)
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode hosts: {}", text)
|
||||
@@ -120,7 +126,6 @@ abstract class SafetySyncer : Syncer {
|
||||
encryptedHost.tunnelings =
|
||||
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.sort = host.sort
|
||||
encryptedHost.deleted = host.deleted
|
||||
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
|
||||
@@ -133,7 +138,18 @@ abstract class SafetySyncer : Syncer {
|
||||
|
||||
}
|
||||
|
||||
protected fun decodeSnippets(text: String, config: SyncConfig) {
|
||||
protected fun encodeDeletedData(config: SyncConfig): String {
|
||||
return ohMyJson.encodeToString(deleteDataManager.getDeletedData())
|
||||
}
|
||||
|
||||
protected fun decodeDeletedData(text: String, config: SyncConfig): List<DeletedData> {
|
||||
val deletedData = ohMyJson.decodeFromString<List<DeletedData>>(text).toMutableList()
|
||||
// 和本地融合
|
||||
deletedData.addAll(deleteDataManager.getDeletedData())
|
||||
return deletedData
|
||||
}
|
||||
|
||||
protected fun decodeSnippets(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedSnippets = ohMyJson.decodeFromString<List<Snippet>>(text)
|
||||
@@ -144,7 +160,7 @@ abstract class SafetySyncer : Syncer {
|
||||
|
||||
// 如果一样,则无需配置
|
||||
if (oldHost != null) {
|
||||
if (oldHost.updateDate == encryptedSnippet.updateDate) {
|
||||
if (oldHost.updateDate >= encryptedSnippet.updateDate) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -165,9 +181,15 @@ abstract class SafetySyncer : Syncer {
|
||||
}
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
deletedData.forEach {
|
||||
snippetManager.removeSnippet(it.id)
|
||||
deleteDataManager.removeSnippet(it.id, it.deleteDate)
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode hosts: {}", text)
|
||||
log.debug("Decode Snippets: {}", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,12 +210,20 @@ abstract class SafetySyncer : Syncer {
|
||||
|
||||
}
|
||||
|
||||
protected fun decodeKeys(text: String, config: SyncConfig) {
|
||||
protected fun decodeKeys(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
|
||||
val keys = keyManager.getOhKeyPairs().associateBy { it.id }
|
||||
|
||||
for (encryptedKey in encryptedKeys) {
|
||||
val k = keys[encryptedKey.id]
|
||||
if (k != null) {
|
||||
if (k.updateDate > encryptedKey.updateDate) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(encryptedKey.id)
|
||||
@@ -215,6 +245,13 @@ abstract class SafetySyncer : Syncer {
|
||||
}
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
deletedData.forEach {
|
||||
keyManager.removeOhKeyPair(it.id)
|
||||
deleteDataManager.removeKeyPair(it.id, it.deleteDate)
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode keys: {}", text)
|
||||
}
|
||||
@@ -240,12 +277,20 @@ abstract class SafetySyncer : Syncer {
|
||||
return ohMyJson.encodeToString(encryptedKeys)
|
||||
}
|
||||
|
||||
protected fun decodeKeywordHighlights(text: String, config: SyncConfig) {
|
||||
protected fun decodeKeywordHighlights(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
|
||||
val keywordHighlights = keywordHighlightManager.getKeywordHighlights().associateBy { it.id }
|
||||
|
||||
for (e in encryptedKeywordHighlights) {
|
||||
val keywordHighlight = keywordHighlights[e.id]
|
||||
if (keywordHighlight != null) {
|
||||
if (keywordHighlight.updateDate >= e.updateDate) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(e.id)
|
||||
@@ -262,6 +307,13 @@ abstract class SafetySyncer : Syncer {
|
||||
}
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
deletedData.forEach {
|
||||
keywordHighlightManager.removeKeywordHighlight(it.id)
|
||||
deleteDataManager.removeKeywordHighlight(it.id, it.deleteDate)
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode KeywordHighlight: {}", text)
|
||||
}
|
||||
@@ -281,12 +333,19 @@ abstract class SafetySyncer : Syncer {
|
||||
return ohMyJson.encodeToString(keywordHighlights)
|
||||
}
|
||||
|
||||
protected fun decodeMacros(text: String, config: SyncConfig) {
|
||||
protected fun decodeMacros(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
|
||||
// aes key
|
||||
val key = getKey(config)
|
||||
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
|
||||
|
||||
val macros = macroManager.getMacros().associateBy { it.id }
|
||||
for (e in encryptedMacros) {
|
||||
val macro = macros[e.id]
|
||||
if (macro != null) {
|
||||
if (macro.updateDate >= e.updateDate) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// aes iv
|
||||
val iv = getIv(e.id)
|
||||
@@ -303,6 +362,13 @@ abstract class SafetySyncer : Syncer {
|
||||
}
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
deletedData.forEach {
|
||||
macroManager.removeMacro(it.id)
|
||||
deleteDataManager.removeMacro(it.id, it.deleteDate)
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode Macros: {}", text)
|
||||
}
|
||||
@@ -322,12 +388,27 @@ abstract class SafetySyncer : Syncer {
|
||||
return ohMyJson.encodeToString(macros)
|
||||
}
|
||||
|
||||
protected fun decodeKeymaps(text: String, config: SyncConfig) {
|
||||
protected fun decodeKeymaps(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
|
||||
|
||||
for (keymap in ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }) {
|
||||
val localKeymaps = keymapManager.getKeymaps().associateBy { it.name }
|
||||
val remoteKeymaps = ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }
|
||||
for (keymap in remoteKeymaps) {
|
||||
val localKeymap = localKeymaps[keymap.name]
|
||||
if (localKeymap != null) {
|
||||
if (localKeymap.updateDate > keymap.updateDate) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
keymapManager.addKeymap(keymap)
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
deletedData.forEach {
|
||||
keymapManager.removeKeymap(it.id)
|
||||
deleteDataManager.removeKeymap(it.id, it.deleteDate)
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Decode Keymaps: {}", text)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ enum class SyncType {
|
||||
WebDAV,
|
||||
}
|
||||
|
||||
enum class SyncPolicy {
|
||||
Manual,
|
||||
OnChange,
|
||||
}
|
||||
|
||||
enum class SyncRange {
|
||||
Hosts,
|
||||
KeyPairs,
|
||||
|
||||
175
src/main/kotlin/app/termora/sync/SyncManager.kt
Normal file
175
src/main/kotlin/app/termora/sync/SyncManager.kt
Normal file
@@ -0,0 +1,175 @@
|
||||
package app.termora.sync
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.Database
|
||||
import app.termora.Disposable
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
class SyncManager private constructor() : Disposable {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SyncManager::class.java)
|
||||
|
||||
fun getInstance(): SyncManager {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(SyncManager::class) { SyncManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val sync get() = Database.getDatabase().sync
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var job: Job? = null
|
||||
private var disableTrigger = false
|
||||
|
||||
|
||||
private fun trigger() {
|
||||
trigger(getSyncConfig())
|
||||
}
|
||||
|
||||
fun triggerOnChanged() {
|
||||
if (sync.policy == SyncPolicy.OnChange.name) {
|
||||
trigger()
|
||||
}
|
||||
}
|
||||
|
||||
private fun trigger(config: SyncConfig) {
|
||||
if (disableTrigger) return
|
||||
|
||||
job?.cancel()
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Automatic synchronisation is interrupted")
|
||||
}
|
||||
|
||||
job = coroutineScope.launch {
|
||||
|
||||
// 因为会频繁调用,等待 10 - 30 秒
|
||||
val seconds = Random.nextInt(10, 30)
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Trigger synchronisation, which will take place after {} seconds", seconds)
|
||||
}
|
||||
|
||||
delay(seconds.seconds)
|
||||
|
||||
|
||||
if (!disableTrigger) {
|
||||
try {
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Automatic synchronisation begin")
|
||||
}
|
||||
|
||||
// 如果已经开始,设置为 null
|
||||
// 因为同步的时候会修改数据,避免被中断
|
||||
job = null
|
||||
|
||||
sync(config)
|
||||
|
||||
sync.lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Automatic synchronisation end")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(config: SyncConfig): SyncResponse {
|
||||
return syncImmediately(config)
|
||||
}
|
||||
|
||||
|
||||
private fun getSyncConfig(): SyncConfig {
|
||||
val range = mutableSetOf<SyncRange>()
|
||||
if (sync.rangeHosts) {
|
||||
range.add(SyncRange.Hosts)
|
||||
}
|
||||
if (sync.rangeKeyPairs) {
|
||||
range.add(SyncRange.KeyPairs)
|
||||
}
|
||||
if (sync.rangeKeywordHighlights) {
|
||||
range.add(SyncRange.KeywordHighlights)
|
||||
}
|
||||
if (sync.rangeMacros) {
|
||||
range.add(SyncRange.Macros)
|
||||
}
|
||||
if (sync.rangeKeymap) {
|
||||
range.add(SyncRange.Keymap)
|
||||
}
|
||||
if (sync.rangeSnippets) {
|
||||
range.add(SyncRange.Snippets)
|
||||
}
|
||||
return SyncConfig(
|
||||
type = sync.type,
|
||||
token = sync.token,
|
||||
gistId = sync.gist,
|
||||
options = mapOf("domain" to sync.domain),
|
||||
ranges = range
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun syncImmediately(config: SyncConfig): SyncResponse {
|
||||
synchronized(this) {
|
||||
return SyncResponse(pull(config), push(config))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun pull(config: SyncConfig): GistResponse {
|
||||
synchronized(this) {
|
||||
disableTrigger = true
|
||||
try {
|
||||
return SyncerProvider.getInstance().getSyncer(config.type).pull(config)
|
||||
} finally {
|
||||
disableTrigger = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun push(config: SyncConfig): GistResponse {
|
||||
synchronized(this) {
|
||||
try {
|
||||
disableTrigger = true
|
||||
return SyncerProvider.getInstance().getSyncer(config.type).push(config)
|
||||
} finally {
|
||||
disableTrigger = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
|
||||
private class SyncerProvider private constructor() {
|
||||
companion object {
|
||||
fun getInstance(): SyncerProvider {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(SyncerProvider::class) { SyncerProvider() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getSyncer(type: SyncType): Syncer {
|
||||
return when (type) {
|
||||
SyncType.GitHub -> GitHubSyncer.getInstance()
|
||||
SyncType.Gitee -> GiteeSyncer.getInstance()
|
||||
SyncType.GitLab -> GitLabSyncer.getInstance()
|
||||
SyncType.WebDAV -> WebDAVSyncer.getInstance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SyncResponse(val pull: GistResponse, val push: GistResponse)
|
||||
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package app.termora.sync
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
|
||||
class SyncerProvider private constructor() {
|
||||
companion object {
|
||||
fun getInstance(): SyncerProvider {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(SyncerProvider::class) { SyncerProvider() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getSyncer(type: SyncType): Syncer {
|
||||
return when (type) {
|
||||
SyncType.GitHub -> GitHubSyncer.getInstance()
|
||||
SyncType.Gitee -> GiteeSyncer.getInstance()
|
||||
SyncType.GitLab -> GitLabSyncer.getInstance()
|
||||
SyncType.WebDAV -> WebDAVSyncer.getInstance()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.termora.sync
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.DeletedData
|
||||
import app.termora.PBKDF2
|
||||
import app.termora.ResponseException
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -27,6 +28,9 @@ class WebDAVSyncer private constructor() : SafetySyncer() {
|
||||
override fun pull(config: SyncConfig): GistResponse {
|
||||
val response = httpClient.newCall(newRequestBuilder(config).get().build()).execute()
|
||||
if (!response.isSuccessful) {
|
||||
if (response.code == 404) {
|
||||
return GistResponse(config, emptyList())
|
||||
}
|
||||
throw ResponseException(response.code, response)
|
||||
}
|
||||
|
||||
@@ -34,46 +38,48 @@ class WebDAVSyncer private constructor() : SafetySyncer() {
|
||||
?: throw ResponseException(response.code, response)
|
||||
|
||||
val json = ohMyJson.decodeFromString<JsonObject>(text)
|
||||
val deletedData = mutableListOf<DeletedData>()
|
||||
json["DeletedData"]?.jsonPrimitive?.content?.let { deletedData.addAll(decodeDeletedData(it, config)) }
|
||||
|
||||
// decode hosts
|
||||
if (config.ranges.contains(SyncRange.Hosts)) {
|
||||
json["Hosts"]?.jsonPrimitive?.content?.let {
|
||||
decodeHosts(it, config)
|
||||
decodeHosts(it, deletedData.filter { e -> e.type == "Host" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
// decode KeyPairs
|
||||
if (config.ranges.contains(SyncRange.KeyPairs)) {
|
||||
json["KeyPairs"]?.jsonPrimitive?.content?.let {
|
||||
decodeKeys(it, config)
|
||||
decodeKeys(it, deletedData.filter { e -> e.type == "KeyPair" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
// decode Highlights
|
||||
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
|
||||
json["KeywordHighlights"]?.jsonPrimitive?.content?.let {
|
||||
decodeKeywordHighlights(it, config)
|
||||
decodeKeywordHighlights(it, deletedData.filter { e -> e.type == "KeywordHighlight" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
// decode Macros
|
||||
if (config.ranges.contains(SyncRange.Macros)) {
|
||||
json["Macros"]?.jsonPrimitive?.content?.let {
|
||||
decodeMacros(it, config)
|
||||
decodeMacros(it, deletedData.filter { e -> e.type == "Macro" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
// decode Keymaps
|
||||
if (config.ranges.contains(SyncRange.Keymap)) {
|
||||
json["Keymaps"]?.jsonPrimitive?.content?.let {
|
||||
decodeKeymaps(it, config)
|
||||
decodeKeymaps(it, deletedData.filter { e -> e.type == "Keymap" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
// decode Snippets
|
||||
if (config.ranges.contains(SyncRange.Snippets)) {
|
||||
json["Snippets"]?.jsonPrimitive?.content?.let {
|
||||
decodeSnippets(it, config)
|
||||
decodeSnippets(it, deletedData.filter { e -> e.type == "Snippet" }, config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +143,13 @@ class WebDAVSyncer private constructor() : SafetySyncer() {
|
||||
}
|
||||
put("Keymaps", keymapsContent)
|
||||
}
|
||||
|
||||
// deletedData
|
||||
val deletedData = encodeDeletedData(config)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Push DeletedData: {}", deletedData)
|
||||
}
|
||||
put("DeletedData", deletedData)
|
||||
}
|
||||
|
||||
val response = httpClient.newCall(
|
||||
|
||||
@@ -399,6 +399,16 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
|
||||
}
|
||||
}
|
||||
|
||||
// CSI Ps Z Cursor Backward Tabulation Ps tab stops (default = 1) (CBT).
|
||||
'Z' -> {
|
||||
val count = args.toInt(1)
|
||||
val cursorModel = terminal.getCursorModel()
|
||||
for (i in 0 until count) {
|
||||
val x = terminal.getTabulator().previousTab(cursorModel.getPosition().x - 1) + 1
|
||||
terminal.getCursorModel().move(cursorModel.getPosition().y, x)
|
||||
}
|
||||
}
|
||||
|
||||
// split
|
||||
';' -> {
|
||||
args.append(ch)
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.io.InputStreamReader
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class PtyProcessConnector(private val process: PtyProcess, private val charset: Charset = StandardCharsets.UTF_8) :
|
||||
class PtyProcessConnector(val process: PtyProcess, private val charset: Charset = StandardCharsets.UTF_8) :
|
||||
StreamPtyConnector(process.inputStream, process.outputStream) {
|
||||
|
||||
private val reader = InputStreamReader(input)
|
||||
|
||||
@@ -14,11 +14,16 @@ 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 java.beans.PropertyChangeEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.util.*
|
||||
import javax.swing.JButton
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class FloatingToolbarPanel : FlatToolBar(), Disposable {
|
||||
private val floatingToolbarEnable get() = Database.getDatabase().terminal.floatingToolbar
|
||||
private var closed = false
|
||||
private val anEvent get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -72,6 +77,7 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
|
||||
}
|
||||
|
||||
initActions()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
@@ -123,12 +129,38 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
|
||||
add(initCloseActionButton())
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
// 被添加到组件后
|
||||
addPropertyChangeListener("ancestor", object : PropertyChangeListener {
|
||||
override fun propertyChange(evt: PropertyChangeEvent) {
|
||||
removePropertyChangeListener("ancestor", this)
|
||||
SwingUtilities.invokeLater { resumeVisualWindows() }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun resumeVisualWindows() {
|
||||
val tab = anEvent.getData(DataProviders.TerminalTab) ?: return
|
||||
if (tab !is SSHTerminalTab) return
|
||||
val terminalPanel = tab.getData(DataProviders.TerminalPanel) ?: return
|
||||
terminalPanel.resumeVisualWindows(tab.host.id, object : DataProvider {
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
if (dataKey == DataProviders.TerminalTab) {
|
||||
return tab as T
|
||||
}
|
||||
return super.getData(dataKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
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 tab = anEvent.getData(DataProviders.TerminalTab) ?: return
|
||||
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
|
||||
|
||||
if (tab !is SSHTerminalTab) {
|
||||
@@ -156,7 +188,7 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
|
||||
btn.toolTipText = I18n.getString("termora.snippet.title")
|
||||
btn.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val tab = evt.getData(DataProviders.TerminalTab) ?: return
|
||||
val tab = anEvent.getData(DataProviders.TerminalTab) ?: return
|
||||
val writer = tab.getData(DataProviders.TerminalWriter) ?: return
|
||||
val dialog = SnippetTreeDialog(evt.window)
|
||||
dialog.setLocationRelativeTo(btn)
|
||||
@@ -174,7 +206,7 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
|
||||
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 tab = anEvent.getData(DataProviders.TerminalTab) ?: return
|
||||
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
|
||||
|
||||
if (tab !is SSHTerminalTab) {
|
||||
@@ -233,7 +265,7 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
|
||||
|
||||
btn.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val tab = evt.getData(DataProviders.TerminalTab) ?: return
|
||||
val tab = anEvent.getData(DataProviders.TerminalTab) ?: return
|
||||
if (tab.canReconnect()) {
|
||||
tab.reconnect()
|
||||
}
|
||||
@@ -242,8 +274,4 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
|
||||
return btn
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -93,7 +93,7 @@ class TerminalBlink(terminal: Terminal) : Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }
|
||||
val coroutineScope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
|
||||
/**
|
||||
* 返回 true 表示可以显示某些内容 [TextStyle.blink]
|
||||
|
||||
@@ -3,8 +3,11 @@ package app.termora.terminal.panel
|
||||
import app.termora.Database
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.assertEventDispatchThread
|
||||
import app.termora.swingCoroutineScope
|
||||
import app.termora.terminal.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import java.awt.*
|
||||
import javax.swing.JComponent
|
||||
@@ -484,14 +487,13 @@ class TerminalDisplay(
|
||||
g.font = font
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun toast(text: String, duration: Duration) {
|
||||
if (!terminalPanel.showToast) {
|
||||
return
|
||||
}
|
||||
|
||||
val toast = Toast(text)
|
||||
GlobalScope.launch(Dispatchers.Swing) {
|
||||
swingCoroutineScope.launch(Dispatchers.Swing) {
|
||||
delay(duration)
|
||||
toasts.remove(toast)
|
||||
terminalPanel.repaintImmediate()
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.termora.terminal.panel
|
||||
|
||||
import app.termora.Application
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.Database
|
||||
import app.termora.terminal.*
|
||||
import java.awt.Graphics
|
||||
import java.net.URI
|
||||
@@ -16,6 +17,7 @@ class TerminalHyperlinkPaintListener private constructor() : TerminalPaintListen
|
||||
}
|
||||
|
||||
private val regex = Regex("https?://\\S*[^.\\s'\",()<>\\[\\]]")
|
||||
private val isEnableHyperlink get() = Database.getDatabase().terminal.hyperlink
|
||||
|
||||
override fun before(
|
||||
offset: Int,
|
||||
@@ -25,6 +27,9 @@ class TerminalHyperlinkPaintListener private constructor() : TerminalPaintListen
|
||||
terminalDisplay: TerminalDisplay,
|
||||
terminal: Terminal
|
||||
) {
|
||||
|
||||
if (isEnableHyperlink.not()) return
|
||||
|
||||
val document = terminal.getDocument()
|
||||
var startOffset = offset
|
||||
var endOffset = startOffset + count
|
||||
@@ -91,4 +96,18 @@ class TerminalHyperlinkPaintListener private constructor() : TerminalPaintListen
|
||||
}
|
||||
}
|
||||
|
||||
override fun after(
|
||||
offset: Int,
|
||||
count: Int,
|
||||
g: Graphics,
|
||||
terminalPanel: TerminalPanel,
|
||||
terminalDisplay: TerminalDisplay,
|
||||
terminal: Terminal
|
||||
) {
|
||||
if (isEnableHyperlink.not()) {
|
||||
// 删除之前的
|
||||
terminal.getMarkupModel().removeAllHighlighters(Highlighter.HYPERLINK)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
package app.termora.terminal.panel
|
||||
|
||||
import app.termora.Database
|
||||
import app.termora.Disposable
|
||||
import app.termora.Disposer
|
||||
import app.termora.SSHTerminalTab
|
||||
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 app.termora.terminal.panel.vw.*
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
@@ -44,15 +45,15 @@ class TerminalPanel(val terminal: Terminal, private val writer: TerminalWriter)
|
||||
val SelectCopy = DataKey(Boolean::class)
|
||||
}
|
||||
|
||||
private val properties get() = Database.getDatabase().properties
|
||||
private val terminalBlink = TerminalBlink(terminal)
|
||||
private val terminalFindPanel = TerminalFindPanel(this, 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)
|
||||
val scrollBar = TerminalScrollBar(this, terminalFindPanel, terminal)
|
||||
var enableFloatingToolbar = true
|
||||
set(value) {
|
||||
field = value
|
||||
@@ -63,6 +64,8 @@ class TerminalPanel(val terminal: Terminal, private val writer: TerminalWriter)
|
||||
}
|
||||
}
|
||||
|
||||
val dataProviderSupport = DataProviderSupport()
|
||||
|
||||
|
||||
/**
|
||||
* 键盘事件
|
||||
@@ -585,6 +588,37 @@ class TerminalPanel(val terminal: Terminal, private val writer: TerminalWriter)
|
||||
requestFocusInWindow()
|
||||
}
|
||||
|
||||
override fun resumeVisualWindows(id: String, dataProvider: DataProvider) {
|
||||
val windows = properties.getString("VisualWindow.${id}.store") ?: return
|
||||
for (name in windows.split(",")) {
|
||||
if (name == "NVIDIA-SMI") {
|
||||
addVisualWindow(
|
||||
NvidiaSMIVisualWindow(
|
||||
dataProvider.getData(DataProviders.TerminalTab) as SSHTerminalTab,
|
||||
this
|
||||
)
|
||||
)
|
||||
} else if (name == "SystemInformation") {
|
||||
addVisualWindow(
|
||||
SystemInformationVisualWindow(
|
||||
dataProvider.getData(DataProviders.TerminalTab) as SSHTerminalTab,
|
||||
this
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun storeVisualWindows(id: String) {
|
||||
val windows = mutableListOf<String>()
|
||||
for (window in getVisualWindows()) {
|
||||
if (window is Resumeable) {
|
||||
windows.add(window.getWindowName())
|
||||
}
|
||||
}
|
||||
properties.putString("VisualWindow.${id}.store", windows.joinToString(","))
|
||||
}
|
||||
|
||||
override fun getDimension(): Dimension {
|
||||
return Dimension(
|
||||
terminalDisplay.size.width + padding.left + padding.right,
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.termora.terminal.panel
|
||||
|
||||
import app.termora.keymap.KeyShortcut
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.terminal.ControlCharacters
|
||||
import app.termora.terminal.Terminal
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.slf4j.LoggerFactory
|
||||
@@ -78,6 +79,8 @@ class TerminalPanelKeyAdapter(
|
||||
val encode = terminal.getKeyEncoder().encode(AWTTerminalKeyEvent(e))
|
||||
if (encode.isNotEmpty()) {
|
||||
writer.write(TerminalWriter.WriteRequest.fromBytes(encode.toByteArray(writer.getCharset())))
|
||||
// scroll to bottom
|
||||
terminal.getScrollingModel().scrollTo(Int.MAX_VALUE)
|
||||
e.consume()
|
||||
}
|
||||
|
||||
@@ -90,6 +93,8 @@ class TerminalPanelKeyAdapter(
|
||||
if (isAltPressedOnly(e) && Character.isDefined(e.keyChar)) {
|
||||
val c = String(charArrayOf(ASCII_ESC, simpleMapKeyCodeToChar(e)))
|
||||
writer.write(TerminalWriter.WriteRequest.fromBytes(c.toByteArray(writer.getCharset())))
|
||||
// scroll to bottom
|
||||
terminal.getScrollingModel().scrollTo(Int.MAX_VALUE)
|
||||
e.consume()
|
||||
return
|
||||
}
|
||||
@@ -99,11 +104,12 @@ class TerminalPanelKeyAdapter(
|
||||
return
|
||||
}
|
||||
|
||||
if (Character.isISOControl(e.keyChar)) {
|
||||
val keyChar = mapKeyChar(e)
|
||||
if (Character.isISOControl(keyChar)) {
|
||||
terminal.getSelectionModel().clearSelection()
|
||||
// 如果不为空表示已经发送过了,所以这里为空的时候再发送
|
||||
if (encode.isEmpty()) {
|
||||
writer.write(TerminalWriter.WriteRequest.fromBytes("${e.keyChar}".toByteArray(writer.getCharset())))
|
||||
writer.write(TerminalWriter.WriteRequest.fromBytes("$keyChar".toByteArray(writer.getCharset())))
|
||||
e.consume()
|
||||
}
|
||||
terminal.getScrollingModel().scrollTo(Int.MAX_VALUE)
|
||||
@@ -111,6 +117,21 @@ class TerminalPanelKeyAdapter(
|
||||
|
||||
}
|
||||
|
||||
private fun mapKeyChar(e: KeyEvent): Char {
|
||||
if (Character.isISOControl(e.keyChar)) {
|
||||
return e.keyChar
|
||||
}
|
||||
|
||||
val isCtrlPressedOnly = isCtrlPressedOnly(e)
|
||||
|
||||
// https://github.com/TermoraDev/termora/issues/478
|
||||
if (isCtrlPressedOnly && e.keyCode == KeyEvent.VK_OPEN_BRACKET) {
|
||||
return ControlCharacters.ESC
|
||||
}
|
||||
|
||||
return e.keyChar
|
||||
}
|
||||
|
||||
private fun isCtrlPressedOnly(e: KeyEvent): Boolean {
|
||||
val modifiersEx = e.modifiersEx
|
||||
return (modifiersEx and InputEvent.ALT_DOWN_MASK) == 0
|
||||
|
||||
@@ -11,7 +11,7 @@ abstract class AutoRefreshPanel : JPanel(), Disposable {
|
||||
private val log = LoggerFactory.getLogger(AutoRefreshPanel::class.java)
|
||||
}
|
||||
|
||||
protected val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
protected val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
protected abstract suspend fun refresh(isFirst: Boolean)
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWind
|
||||
private val percentageBtn by lazy { JButton(if (isPercentage) Icons.text else Icons.percentage) }
|
||||
|
||||
init {
|
||||
Disposer.register(tab, this)
|
||||
initViews()
|
||||
initEvents()
|
||||
initVisualWindowPanel()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package app.termora.terminal.panel.vw
|
||||
|
||||
interface Resumeable
|
||||
@@ -1,6 +1,5 @@
|
||||
package app.termora.terminal.panel.vw
|
||||
|
||||
import app.termora.Disposer
|
||||
import app.termora.SSHTerminalTab
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
@@ -11,11 +10,7 @@ abstract class SSHVisualWindow(
|
||||
protected val tab: SSHTerminalTab,
|
||||
id: String,
|
||||
visualWindowManager: VisualWindowManager
|
||||
) : VisualWindowPanel(id, visualWindowManager) {
|
||||
|
||||
init {
|
||||
Disposer.register(tab, this)
|
||||
}
|
||||
) : VisualWindowPanel(id, visualWindowManager), Resumeable {
|
||||
|
||||
override fun toggleWindow() {
|
||||
val evt = AnActionEvent(tab.getJComponent(), StringUtils.EMPTY, EventObject(this))
|
||||
|
||||
@@ -25,6 +25,7 @@ class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: Vi
|
||||
private val systemInformationPanel by lazy { SystemInformationPanel() }
|
||||
|
||||
init {
|
||||
Disposer.register(tab, this)
|
||||
initViews()
|
||||
initEvents()
|
||||
initVisualWindowPanel()
|
||||
@@ -137,7 +138,7 @@ class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: Vi
|
||||
private suspend fun refreshCPUAndMem(session: ClientSession) {
|
||||
|
||||
// top
|
||||
var pair = SshClients.execChannel(session, "top -bn1")
|
||||
val pair = SshClients.execChannel(session, "top -bn1")
|
||||
if (pair.first != 0) {
|
||||
return
|
||||
}
|
||||
@@ -236,7 +237,7 @@ class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: Vi
|
||||
private suspend fun refreshDisk(session: ClientSession) {
|
||||
|
||||
// df -h
|
||||
var pair = SshClients.execChannel(session, "df -B1")
|
||||
val pair = SshClients.execChannel(session, "df -B1")
|
||||
if (pair.first != 0) {
|
||||
return
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user