Compare commits

..

44 Commits
1.0.7 ... 1.0.8

Author SHA1 Message Date
hstyi
b332bada95 release: 1.0.8 2025-02-18 11:42:19 +08:00
hstyi
63a12c2ec8 docs: README 2025-02-18 11:42:01 +08:00
hstyi
743f242805 feat: support system fonts (#260) 2025-02-18 08:31:33 +08:00
hstyi
5bead0b27d fix: high CPU 2025-02-17 09:41:35 +08:00
hstyi
73e3c7016b feat: SFTP command icon 2025-02-17 08:24:59 +08:00
hstyi
3829dcd0f9 feat: sftp HostKeyAlgorithms (#255) 2025-02-16 19:18:00 +08:00
hstyi
b2047044fe chore: apple.awt.application.name 2025-02-16 11:22:30 +08:00
hstyi
47d1a13189 chore: improve contextmenu (#251) 2025-02-16 11:14:37 +08:00
hstyi
309909cbd7 fix: key shortcut also triggers when Popup is available (#250) 2025-02-15 19:52:57 +08:00
hstyi
b5cebb4cea chore: remove double Shift key shortcut (#249) 2025-02-15 19:27:44 +08:00
hstyi
b6dd2693cd fix: hostConfigEntry NPE 2025-02-15 19:22:20 +08:00
hstyi
5fdfe98f26 feat: OSC 1337 (#244) 2025-02-15 17:38:06 +08:00
hstyi
0c768aa1ca chore: osx github actions 2025-02-15 16:20:22 +08:00
hstyi
d493e6dc9e chore: description 2025-02-15 15:09:47 +08:00
hstyi
7e0c7d8891 fix: sftp1 to sftp 2025-02-15 14:43:46 +08:00
hstyi
3510c6600d feat: detecting SFTP program (#241) 2025-02-15 14:38:51 +08:00
hstyi
32d91150bd fix: dialog edge detection (#240) 2025-02-15 14:15:17 +08:00
hstyi
bbf2d50e3f feat: clear terminal screen shortcut (#239) 2025-02-15 14:14:49 +08:00
hstyi
39725f9828 chore: linux-aarch64.yml 2025-02-15 13:39:23 +08:00
hstyi
1e8c617a85 feat: SFTP command support for Jump Hosts and Proxy (#236) 2025-02-15 13:15:02 +08:00
hstyi
7f8573ec4c fix: frequent fingerprint saving on the jump server 2025-02-15 12:42:18 +08:00
hstyi
d8e629917e feat: SFTP command (#234) 2025-02-15 11:23:06 +08:00
hstyi
bdc0a15439 fix: HostDialog title 2025-02-14 20:54:51 +08:00
hstyi
a25b97614f feat: Floating Toolbar (#231) 2025-02-14 20:38:46 +08:00
hstyi
4e12c32566 chore: stop listening if the file does not exist (#230) 2025-02-14 15:36:26 +08:00
hstyi
ea9c0f1225 chore: optimising SFTP for Linux edit (#229) 2025-02-14 15:00:19 +08:00
hstyi
ff865f13a2 fix: AppImage not working 2025-02-14 14:41:52 +08:00
hstyi
9875200912 chore: toolbar strut (#227) 2025-02-14 13:58:11 +08:00
hstyi
9f218d004e fix: tab drag (#226) 2025-02-14 13:54:39 +08:00
hstyi
ab727f66f4 fix: windows action cache 2025-02-14 12:46:37 +08:00
hstyi
efbc0302e4 chore: wget quiet 2025-02-14 12:34:27 +08:00
hstyi
ab2367d670 chore: linux AppImage and actions/cache (#222) 2025-02-14 12:27:14 +08:00
hstyi
045e4f81d6 feat: export configuration file support encryption (#221) 2025-02-14 12:18:37 +08:00
hstyi
160cfee947 chore: linux logo 2025-02-13 20:00:44 +08:00
hstyi
0e40b5ecce feat: open with SFTP (#217) 2025-02-13 17:04:14 +08:00
hstyi
fcaddcee80 feat: open SFTP directly to the current SSH server (#216) 2025-02-13 16:46:52 +08:00
hstyi
8d6295fd3b fix: auto wrap mode (#215) 2025-02-13 15:50:50 +08:00
hstyi
d0d51b3e6f fix: authentication dialog 2025-02-12 17:32:31 +08:00
hstyi
b8d612f1d5 feat: supports one-time authorised connection (#211) 2025-02-12 17:13:30 +08:00
hstyi
f7c49cde0c feat: supports custom editing of SFTP command (#210) 2025-02-12 16:33:37 +08:00
hstyi
189f8fb3ba feat: SFTP file editing support (#209) 2025-02-12 15:55:51 +08:00
hstyi
2a64bd28a8 chore: HostTree.showMoreInfo 2025-02-12 14:30:01 +08:00
hstyi
8a733379a3 feat: known_hosts (#206) 2025-02-12 11:45:55 +08:00
hstyi
e5f854dfcd feat: HostTree shows more information (#203) 2025-02-12 11:45:39 +08:00
61 changed files with 1916 additions and 309 deletions

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

@@ -0,0 +1,47 @@
name: Linux aarch64
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# download jdk
- run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-aarch64-b825.69.tar.gz
# appimagetool
- run: sudo apt install libfuse2
# install jdk
- name: Installing Java
uses: actions/setup-java@v4
with:
distribution: 'jdkfile'
jdkFile: ${{ runner.temp }}/java_package.tar.gz
java-version: '21.0.6'
architecture: aarch64
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# dist
- run: |
./gradlew dist --no-daemon
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: termora-linux-aarch64
path: |
build/distributions/*.tar.gz
build/distributions/*.AppImage

View File

@@ -4,14 +4,17 @@ on: [ push, pull_request ]
jobs: jobs:
build: build:
runs-on: ubuntu-20.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# download jdk # download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b825.69.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-linux-x64-b825.69.tar.gz
# appimagetool
- run: sudo apt install libfuse2
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -22,6 +25,15 @@ jobs:
java-version: '21.0.6' java-version: '21.0.6'
architecture: x64 architecture: x64
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# dist # dist
- run: | - run: |
./gradlew dist --no-daemon ./gradlew dist --no-daemon
@@ -30,4 +42,6 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: termora-linux-x86-64 name: termora-linux-x86-64
path: build/distributions/*.tar.gz path: |
build/distributions/*.tar.gz
build/distributions/*.AppImage

View File

@@ -11,7 +11,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Install the Apple certificate - name: Install the Apple certificate
if: github.event_name == 'push' if: github.event_name == 'push' && github.repository == 'TermoraDev/termora'
env: env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }} P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
@@ -34,7 +34,7 @@ jobs:
security list-keychain -d user -s $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH
# download jdk # download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-aarch64-b825.69.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -45,6 +45,15 @@ jobs:
java-version: '21.0.6' java-version: '21.0.6'
architecture: aarch64 architecture: aarch64
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# dist # dist
- name: Dist - name: Dist
env: env:

View File

@@ -11,7 +11,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Install the Apple certificate - name: Install the Apple certificate
if: github.event_name == 'push' if: github.event_name == 'push' && github.repository == 'TermoraDev/termora'
env: env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }} P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
@@ -34,7 +34,7 @@ jobs:
security list-keychain -d user -s $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH
# download jdk # download jdk
- run: wget -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz - run: wget -q -O $RUNNER_TEMP/java_package.tar.gz https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-osx-x64-b825.69.tar.gz
# install jdk # install jdk
- name: Installing Java - name: Installing Java
@@ -46,6 +46,16 @@ jobs:
architecture: x64 architecture: x64
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# dist # dist
- name: Dist - name: Dist
env: env:

View File

@@ -16,9 +16,19 @@ jobs:
distribution: 'jetbrains' distribution: 'jetbrains'
java-version: '21' java-version: '21'
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
# dist # dist
- run: | - run: |
.\gradlew.bat dist --no-daemon .\gradlew.bat dist --no-daemon
.\gradlew.bat --stop
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -16,12 +16,12 @@
- SSH and local terminal support - SSH and local terminal support
- Serial port protocol support - Serial port protocol support
- [SFTP](./docs/sftp.png?raw=1) file transfer support - [SFTP](./docs/sftp.png?raw=1) & [Command](./docs/sftp-command.png?raw=1) file transfer support
- Compatible with Windows, macOS, and Linux - Compatible with Windows, macOS, and Linux
- Zmodem protocol support - Zmodem protocol support
- SSH port forwarding & Jump hosts - SSH port forwarding & Jump hosts
- Terminal log - Terminal log
- Configuration synchronization via [Gist](https://gist.github.com) - Configuration synchronization via [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- Macro support (record and replay scripts) - Macro support (record and replay scripts)
- Keyword highlighting - Keyword highlighting
- Key management - Key management

View File

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

View File

@@ -8,6 +8,7 @@ import java.nio.file.Files
plugins { plugins {
java java
idea
application application
alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.kotlinx.serialization)
@@ -15,7 +16,7 @@ plugins {
group = "app.termora" group = "app.termora"
version = "1.0.7" version = "1.0.8"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture() val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
@@ -116,7 +117,6 @@ application {
"-XX:+ZUncommit", "-XX:+ZUncommit",
"-XX:+ZGenerational", "-XX:+ZGenerational",
"-XX:ZUncommitDelay=60", "-XX:ZUncommitDelay=60",
"-XX:SoftMaxHeapSize=64m"
) )
if (os.isMacOsX) { if (os.isMacOsX) {
@@ -244,7 +244,6 @@ tasks.register<Exec>("jpackage") {
"-XX:+ZUncommit", "-XX:+ZUncommit",
"-XX:+ZGenerational", "-XX:+ZGenerational",
"-XX:ZUncommitDelay=60", "-XX:ZUncommitDelay=60",
"-XX:SoftMaxHeapSize=64m",
"-XX:+HeapDumpOnOutOfMemoryError", "-XX:+HeapDumpOnOutOfMemoryError",
"-Dlogger.console.level=off", "-Dlogger.console.level=off",
"-Dkotlinx.coroutines.debug=off", "-Dkotlinx.coroutines.debug=off",
@@ -274,7 +273,17 @@ tasks.register<Exec>("jpackage") {
arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE))) arguments.addAll(listOf("--java-options", options.joinToString(StringUtils.SPACE)))
arguments.addAll(listOf("--vendor", "TermoraDev")) arguments.addAll(listOf("--vendor", "TermoraDev"))
arguments.addAll(listOf("--copyright", "TermoraDev")) arguments.addAll(listOf("--copyright", "TermoraDev"))
if (os.isWindows) {
arguments.addAll(
listOf(
"--description",
"${project.name.uppercaseFirstChar()}: A terminal emulator and SSH client"
)
)
} else {
arguments.addAll(listOf("--description", "A terminal emulator and SSH client.")) arguments.addAll(listOf("--description", "A terminal emulator and SSH client."))
}
if (os.isMacOsX) { if (os.isMacOsX) {
@@ -292,6 +301,10 @@ tasks.register<Exec>("jpackage") {
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.ico")) arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.ico"))
} }
if (os.isLinux) {
arguments.addAll(listOf("--icon", "${projectDir.absolutePath}/src/main/resources/icons/termora.png"))
}
arguments.add("--type") arguments.add("--type")
if (os.isMacOsX) { if (os.isMacOsX) {
@@ -388,6 +401,56 @@ tasks.register("dist") {
throw GradleException("${os.name} is not supported") throw GradleException("${os.name} is not supported")
} }
// AppImage
if (os.isLinux) {
// Download AppImageKit
val appimagetool = FileUtils.getFile(projectDir, ".gradle", "appimagetool")
if (!appimagetool.exists()) {
exec {
commandLine(
"wget",
"-O", appimagetool.absolutePath,
"https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-${if (arch.isArm) "aarch64" else "x86_64"}.AppImage"
)
workingDir = distributionDir.asFile
}
// AppImageKit chmod
exec { commandLine("chmod", "+x", appimagetool.absolutePath) }
}
// Desktop file
val termoraName = project.name.uppercaseFirstChar()
val desktopFile = distributionDir.file(termoraName + File.separator + termoraName + ".desktop").asFile
desktopFile.writeText(
"""[Desktop Entry]
Type=Application
Name=${termoraName}
Comment=Terminal emulator and SSH client
Icon=/lib/${termoraName}
Categories=Development;
Terminal=false
""".trimIndent()
)
// AppRun file
val appRun = File(desktopFile.parentFile, "AppRun")
val sb = StringBuilder()
sb.append("#!/bin/sh").appendLine()
sb.append("SELF=$(readlink -f \"$0\")").appendLine()
sb.append("HERE=\${SELF%/*}").appendLine()
sb.append("exec \"\${HERE}/bin/${termoraName}\" \"$@\"")
appRun.writeText(sb.toString())
appRun.setExecutable(true)
exec {
commandLine(appimagetool.absolutePath, termoraName, "${finalFilenameWithoutExtension}.AppImage")
workingDir = distributionDir.asFile
}
}
// sign dmg // sign dmg
if (os.isMacOsX && macOSSign) { if (os.isMacOsX && macOSSign) {
@@ -479,3 +542,10 @@ kotlin {
vendor = JvmVendorSpec.JETBRAINS vendor = JvmVendorSpec.JETBRAINS
} }
} }
idea {
module {
isDownloadJavadoc = true
isDownloadSources = true
}
}

BIN
docs/sftp-command.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -15,6 +15,8 @@ import org.slf4j.LoggerFactory
import java.awt.Desktop import java.awt.Desktop
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration import java.time.Duration
import kotlin.math.ln import kotlin.math.ln
import kotlin.math.pow import kotlin.math.pow
@@ -60,6 +62,16 @@ object Application {
return "/bin/bash" return "/bin/bash"
} }
fun getTemporaryDir(): File {
val temporaryDir = File(getBaseDataDir(), "temporary")
FileUtils.forceMkdir(temporaryDir)
return temporaryDir
}
fun createSubTemporaryDir(prefix: String = getName()): Path {
return Files.createTempDirectory(getTemporaryDir().toPath(), prefix)
}
fun getBaseDataDir(): File { fun getBaseDataDir(): File {
if (::baseDataDir.isInitialized) { if (::baseDataDir.isInitialized) {
return baseDataDir return baseDataDir

View File

@@ -73,6 +73,9 @@ class ApplicationRunner {
// 解密数据 // 解密数据
val openDoor = measureTimeMillis { openDoor() } val openDoor = measureTimeMillis { openDoor() }
// clear temporary
clearTemporary()
// 启动主窗口 // 启动主窗口
val startMainFrame = measureTimeMillis { startMainFrame() } val startMainFrame = measureTimeMillis { startMainFrame() }
@@ -94,6 +97,22 @@ class ApplicationRunner {
} }
} }
@Suppress("OPT_IN_USAGE")
private fun clearTemporary() {
GlobalScope.launch(Dispatchers.IO) {
// 启动时清除
FileUtils.cleanDirectory(Application.getTemporaryDir())
// 关闭时清除
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
override fun dispose() {
FileUtils.cleanDirectory(Application.getTemporaryDir())
}
})
}
}
private fun openDoor() { private fun openDoor() {
if (Doorman.getInstance().isWorking()) { if (Doorman.getInstance().isWorking()) {

View File

@@ -14,12 +14,10 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.File import java.io.File
import java.util.* import java.util.*
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@@ -55,6 +53,7 @@ class Database private constructor(private val env: Environment) : Disposable {
val safetyProperties by lazy { SafetyProperties("Setting.SafetyProperties") } val safetyProperties by lazy { SafetyProperties("Setting.SafetyProperties") }
val terminal by lazy { Terminal() } val terminal by lazy { Terminal() }
val appearance by lazy { Appearance() } val appearance by lazy { Appearance() }
val sftp by lazy { SFTP() }
val sync by lazy { Sync() } val sync by lazy { Sync() }
private val doorman get() = Doorman.getInstance() private val doorman get() = Doorman.getInstance()
@@ -473,6 +472,11 @@ class Database private constructor(private val env: Environment) : Disposable {
* 终端断开连接时自动关闭Tab * 终端断开连接时自动关闭Tab
*/ */
var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false) var autoCloseTabWhenDisconnected by BooleanPropertyDelegate(false)
/**
* 是否显示悬浮工具栏
*/
var floatingToolbar by BooleanPropertyDelegate(true)
} }
/** /**
@@ -573,6 +577,19 @@ class Database private constructor(private val env: Environment) : Disposable {
} }
/**
* SFTP
*/
inner class SFTP : Property("Setting.SFTP") {
/**
* 编辑命令
*/
var editCommand by StringPropertyDelegate(StringUtils.EMPTY)
}
/** /**
* 同步配置 * 同步配置
*/ */

View File

@@ -5,6 +5,17 @@ import org.apache.commons.lang3.StringUtils
import java.util.* import java.util.*
fun Map<*, *>.toPropertiesString(): String {
val env = StringBuilder()
for ((i, e) in entries.withIndex()) {
env.append(e.key).append('=').append(e.value)
if (i != size - 1) {
env.appendLine()
}
}
return env.toString()
}
fun UUID.toSimpleString(): String { fun UUID.toSimpleString(): String {
return toString().replace("-", StringUtils.EMPTY) return toString().replace("-", StringUtils.EMPTY)
} }
@@ -13,7 +24,13 @@ enum class Protocol {
Folder, Folder,
SSH, SSH,
Local, Local,
Serial Serial,
/**
* 交互式的 SFTP此协议只在系统内部交互不应该暴露给用户也不应该持久化
*/
@Transient
SFTPPty
} }

View File

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

View File

@@ -1,11 +1,14 @@
package app.termora package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.NewHostAction import app.termora.actions.NewHostAction
import app.termora.actions.OpenHostAction import app.termora.actions.OpenHostAction
import app.termora.transport.SFTPAction
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.icons.FlatTreeClosedIcon import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeOpenIcon import com.formdev.flatlaf.icons.FlatTreeOpenIcon
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
import java.awt.Component import java.awt.Component
@@ -54,6 +57,7 @@ class HostTree : JTree(), Disposable {
editor.preferredSize = Dimension(220, 0) editor.preferredSize = Dimension(220, 0)
setCellRenderer(object : DefaultXTreeCellRenderer() { setCellRenderer(object : DefaultXTreeCellRenderer() {
private val properties get() = Database.getDatabase().properties
override fun getTreeCellRendererComponent( override fun getTreeCellRendererComponent(
tree: JTree, tree: JTree,
value: Any, value: Any,
@@ -64,13 +68,41 @@ class HostTree : JTree(), Disposable {
hasFocus: Boolean hasFocus: Boolean
): Component { ): Component {
val host = value as Host val host = value as Host
val c = super.getTreeCellRendererComponent(tree, host, sel, expanded, leaf, row, hasFocus) var text = host.name
if (host.protocol == Protocol.Folder) {
icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() // 是否显示更多信息
} else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) { if (properties.getString("HostTree.showMoreInfo", "false").toBoolean()) {
icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal val color = if (sel) {
if (this@HostTree.hasFocus()) {
UIManager.getColor("textHighlightText")
} else {
this.foreground
}
} else {
UIManager.getColor("textInactiveText")
}
if (host.protocol == Protocol.SSH) {
text = """
<html>${host.name}
&nbsp;&nbsp;
<font color=rgb(${color.red},${color.green},${color.blue})>${host.username}@${host.host}</font></html>
""".trimIndent()
} else if (host.protocol == Protocol.Serial) { } else if (host.protocol == Protocol.Serial) {
icon = if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin text = """
<html>${host.name}
&nbsp;&nbsp;
<font color=rgb(${color.red},${color.green},${color.blue})>${host.options.serialComm.port}</font></html>
""".trimIndent()
}
}
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
icon = when (host.protocol) {
Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
Protocol.Serial -> if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin
else -> if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal
} }
return c return c
} }
@@ -314,12 +346,16 @@ class HostTree : JTree(), Disposable {
return return
} }
val properties = Database.getDatabase().properties
val popupMenu = FlatPopupMenu() val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new")) val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new"))
val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder")) val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder"))
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host")) val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host"))
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open")) val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect"))
val openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu
val openWithSFTP = openWith.add("SFTP")
val openWithSFTPCommand = openWith.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
val openInNewWindow = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open-in-new-window")) val openInNewWindow = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open-in-new-window"))
popupMenu.addSeparator() popupMenu.addSeparator()
val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy")) val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy"))
@@ -331,11 +367,29 @@ class HostTree : JTree(), Disposable {
popupMenu.addSeparator() popupMenu.addSeparator()
popupMenu.add(newMenu) popupMenu.add(newMenu)
popupMenu.addSeparator() popupMenu.addSeparator()
val showMoreInfo = JCheckBoxMenuItem(I18n.getString("termora.welcome.contextmenu.show-more-info"))
showMoreInfo.isSelected = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
showMoreInfo.addActionListener {
properties.putString(
"HostTree.showMoreInfo",
showMoreInfo.isSelected.toString()
)
SwingUtilities.updateComponentTreeUI(this)
}
popupMenu.add(showMoreInfo)
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property")) val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
open.addActionListener { openHosts(it, false) } open.addActionListener { openHosts(it, false) }
openWithSFTP.addActionListener { openWithSFTP(it) }
openWithSFTPCommand.addActionListener { openWithSFTPCommand(it) }
openInNewWindow.addActionListener { openHosts(it, true) } openInNewWindow.addActionListener { openHosts(it, true) }
// 如果选中了 SSH 服务器,那么才启用
openWithSFTP.isEnabled = getSelectionNodes().any { it.protocol == Protocol.SSH }
openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled
openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled }
rename.addActionListener { rename.addActionListener {
startEditingAtPath(TreePath(model.getPathToRoot(lastHost))) startEditingAtPath(TreePath(model.getPathToRoot(lastHost)))
} }
@@ -418,6 +472,7 @@ class HostTree : JTree(), Disposable {
property.addActionListener(object : AbstractAction() { property.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) {
val dialog = HostDialog(SwingUtilities.getWindowAncestor(this@HostTree), lastHost) val dialog = HostDialog(SwingUtilities.getWindowAncestor(this@HostTree), lastHost)
dialog.title = lastHost.name
dialog.isVisible = true dialog.isVisible = true
val host = dialog.host ?: return val host = dialog.host ?: return
runCatchingHost(host) runCatchingHost(host)
@@ -462,6 +517,26 @@ class HostTree : JTree(), Disposable {
nodes.forEach { openHostAction.actionPerformed(OpenHostActionEvent(source, it, evt)) } nodes.forEach { openHostAction.actionPerformed(OpenHostActionEvent(source, it, evt)) }
} }
private fun openWithSFTP(evt: EventObject) {
val nodes = getSelectionNodes().filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return
val sftpAction = ActionManager.getInstance().getAction(Actions.SFTP) as SFTPAction? ?: return
val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return
for (node in nodes) {
sftpAction.connectHost(node, tab)
}
}
private fun openWithSFTPCommand(evt: EventObject) {
val nodes = getSelectionNodes().filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return
val action = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return
for (host in nodes) {
action.actionPerformed(OpenHostActionEvent(this, host.copy(protocol = Protocol.SFTPPty), evt))
}
}
fun expandNode(node: Host, including: Boolean = false) { fun expandNode(node: Host, including: Boolean = false) {
expandPath(TreePath(model.getPathToRoot(node))) expandPath(TreePath(model.getPathToRoot(node)))
if (including) { if (including) {

View File

@@ -3,6 +3,8 @@ package app.termora
object Icons { object Icons {
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") } val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") } val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
val closeSmall by lazy { DynamicIcon("icons/closeSmall.svg", "icons/closeSmall_dark.svg") }
val closeSmallHovered by lazy { DynamicIcon("icons/closeSmallHovered.svg", "icons/closeSmallHovered_dark.svg") }
val plugin by lazy { DynamicIcon("icons/plugin.svg", "icons/plugin_dark.svg") } val plugin by lazy { DynamicIcon("icons/plugin.svg", "icons/plugin_dark.svg") }
val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") } val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") }
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") } val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
@@ -48,6 +50,7 @@ object Icons {
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") } val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") } val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") }
val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_dark.svg") } val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_dark.svg") }
val fileFormat by lazy { DynamicIcon("icons/fileFormat.svg", "icons/fileFormat_dark.svg") }
val azure by lazy { DynamicIcon("icons/azure.svg", "icons/azure_dark.svg") } val azure by lazy { DynamicIcon("icons/azure.svg", "icons/azure_dark.svg") }
val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") } val revert by lazy { DynamicIcon("icons/revert.svg", "icons/revert_dark.svg") }
val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") } val edit by lazy { DynamicIcon("icons/edit.svg", "icons/edit_dark.svg") }

View File

@@ -12,6 +12,10 @@ fun main() {
setupNativeLibraries() setupNativeLibraries()
} }
if (SystemUtils.IS_OS_MAC_OSX) {
System.setProperty("apple.awt.application.name", Application.getName())
}
ApplicationRunner().run() ApplicationRunner().run()
} }

View File

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

View File

@@ -80,6 +80,8 @@ class MyTabbedPane : FlatTabbedPane() {
override fun mousePressed(e: MouseEvent) { override fun mousePressed(e: MouseEvent) {
val index = indexAtLocation(e.x, e.y) val index = indexAtLocation(e.x, e.y)
if (index < 0 || !isTabClosable(index)) { if (index < 0 || !isTabClosable(index)) {
tabIndex = -1
mousePressedPoint = Point()
return return
} }
tabIndex = index tabIndex = index

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package app.termora package app.termora
import app.termora.actions.DataProviders
import app.termora.terminal.* import app.termora.terminal.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
@@ -50,7 +51,7 @@ abstract class PtyHostTerminalTab(
startPtyConnectorReader() startPtyConnectorReader()
// 启动命令 // 启动命令
if (host.options.startupCommand.isNotBlank()) { if (host.options.startupCommand.isNotBlank() && host.protocol != Protocol.SFTPPty) {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
delay(250.milliseconds) delay(250.milliseconds)
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
@@ -135,4 +136,12 @@ abstract class PtyHostTerminalTab(
} }
abstract suspend fun openPtyConnector(): PtyConnector abstract suspend fun openPtyConnector(): PtyConnector
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == DataProviders.TerminalPanel) {
return terminalPanel as T?
}
return super.getData(dataKey)
}
} }

View File

@@ -0,0 +1,146 @@
package app.termora
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.Window
import java.awt.event.ItemEvent
import javax.swing.*
import kotlin.math.max
class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
private val rememberCheckBox = JCheckBox("Remember")
private val passwordPanel = JPanel(BorderLayout())
private val passwordPasswordField = OutlinePasswordField()
private val publicKeyComboBox = OutlineComboBox<OhKeyPair>()
private val keyManager get() = KeyManager.getInstance()
private var authentication = Authentication.No
init {
isModal = true
title = "SSH User Authentication"
controlsVisible = false
init()
pack()
size = Dimension(max(380, size.width), size.height)
setLocationRelativeTo(null)
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
return super.getListCellRendererComponent(
list,
if (value is OhKeyPair) value.name else value,
index,
isSelected,
cellHasFocus
)
}
}
for (keyPair in keyManager.getOhKeyPairs()) {
publicKeyComboBox.addItem(keyPair)
}
authenticationTypeComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
switchPasswordComponent()
}
}
}
override fun createCenterPanel(): JComponent {
authenticationTypeComboBox.addItem(AuthenticationType.Password)
authenticationTypeComboBox.addItem(AuthenticationType.PublicKey)
val formMargin = "7dlu"
val layout = FormLayout(
"left:pref, $formMargin, default:grow",
"pref, $formMargin, pref"
)
switchPasswordComponent()
return FormBuilder.create().padding("$formMargin, $formMargin, $formMargin, $formMargin")
.layout(layout)
.add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, 1)
.add(authenticationTypeComboBox).xy(3, 1)
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 3)
.add(passwordPanel).xy(3, 3)
.build()
}
private fun switchPasswordComponent() {
passwordPanel.removeAll()
if (authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
passwordPanel.add(passwordPasswordField, BorderLayout.CENTER)
} else if (authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
passwordPanel.add(publicKeyComboBox, BorderLayout.CENTER)
}
passwordPanel.revalidate()
passwordPanel.repaint()
}
override fun createSouthPanel(): JComponent? {
val box = super.createSouthPanel() ?: return null
rememberCheckBox.isFocusable = false
box.add(rememberCheckBox, 0)
return box
}
override fun doCancelAction() {
authentication = Authentication.No
super.doCancelAction()
}
override fun doOKAction() {
val type = authenticationTypeComboBox.selectedItem as AuthenticationType
if (type == AuthenticationType.Password) {
if (passwordPasswordField.password.isEmpty()) {
passwordPasswordField.requestFocusInWindow()
return
}
} else if (type == AuthenticationType.PublicKey) {
if (publicKeyComboBox.selectedItem == null) {
publicKeyComboBox.requestFocusInWindow()
return
}
}
authentication = authentication.copy(
type = type,
password = if (type == AuthenticationType.Password) String(passwordPasswordField.password)
else (publicKeyComboBox.selectedItem as OhKeyPair).id
)
super.doOKAction()
}
fun getAuthentication(): Authentication {
isModal = true
SwingUtilities.invokeLater { passwordPasswordField.requestFocusInWindow() }
isVisible = true
return authentication
}
fun isRemembered(): Boolean {
return rememberCheckBox.isSelected
}
}

View File

@@ -0,0 +1,196 @@
package app.termora
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.*
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.Charsets
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter
import org.apache.sshd.common.util.net.SshdSocketAddress
import java.awt.event.KeyEvent
import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import javax.swing.Icon
import javax.swing.SwingUtilities
class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
private val keyManager by lazy { KeyManager.getInstance() }
private val tempFiles = mutableListOf<Path>()
private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
companion object {
val canSupports by lazy {
val process = if (SystemInfo.isWindows) {
ProcessBuilder("cmd.exe", "/c", "where", "sftp").start()
} else {
ProcessBuilder("which", "sftp").start()
}
process.waitFor()
return@lazy process.exitValue() == 0
}
}
override suspend fun openPtyConnector(): PtyConnector {
val useJumpHosts = host.options.jumpHosts.isNotEmpty() || host.proxy.type != ProxyType.No
val commands = mutableListOf("sftp")
var host = this.host
// 如果配置了跳板机或者代理,那么通过 SSH 的端口转发到本地
if (useJumpHosts) {
host = host.copy(
tunnelings = listOf(
Tunneling(
type = TunnelingType.Local,
sourceHost = SshdSocketAddress.LOCALHOST_NAME,
destinationHost = SshdSocketAddress.LOCALHOST_NAME,
destinationPort = host.port,
)
)
)
val sshClient = SshClients.openClient(host).apply { sshClient = this }
val sshSession = SshClients.openSession(host, sshClient).apply { sshSession = this }
// 打开通道
for (tunneling in host.tunnelings) {
val address = SshClients.openTunneling(sshSession, host, tunneling)
host = host.copy(host = address.hostName, port = address.port)
}
}
if (useJumpHosts) {
// 打开通道后忽略 key 检查
commands.add("-o")
commands.add("StrictHostKeyChecking=no")
// 不保存 known_hosts
commands.add("-o")
commands.add("UserKnownHostsFile=${if (SystemInfo.isWindows) "NUL" else "/dev/null"}")
} else {
// known_hosts
commands.add("-o")
commands.add("UserKnownHostsFile=${File(Application.getBaseDataDir(), "known_hosts").absolutePath}")
}
// Compression
commands.add("-o")
commands.add("Compression=yes")
// HostKeyAlgorithms 让 SFTP 命令的顺序和 sshd 的一致 这样可以避免 known_hosts 文件不一致问题
val hostKeyAlgorithms = ClientBuilder.setUpDefaultSignatureFactories(true).joinToString(",") { it.name }
commands.add("-o")
commands.add("HostKeyAlgorithms=${hostKeyAlgorithms}")
// 不使用配置文件
commands.add("-F")
commands.add("/dev/null")
// port
commands.add("-P")
commands.add(host.port.toString())
// 设置认证信息
setAuthentication(commands, host)
val envs = host.options.envs()
if (envs.containsKey("CurrentDir")) {
val currentDir = envs.getValue("CurrentDir")
commands.add("${host.username}@${host.host}:${currentDir}")
} else {
commands.add("${host.username}@${host.host}")
}
val winSize = terminalPanel.winSize()
val ptyConnector = ptyConnectorFactory.createPtyConnector(
commands.toTypedArray(),
winSize.rows, winSize.cols,
host.options.envs(),
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
)
return ptyConnector
}
private fun setAuthentication(commands: MutableList<String>, host: Host) {
// 如果通过公钥连接
if (host.authentication.type == AuthenticationType.PublicKey) {
val keyPair = keyManager.getOhKeyPair(host.authentication.password)
if (keyPair != null) {
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(keyPair)
val privateKeyPath = Application.createSubTemporaryDir()
val privateKeyFile = Files.createTempFile(privateKeyPath, Application.getName(), StringUtils.EMPTY)
Files.newOutputStream(privateKeyFile)
.use { OpenSSHKeyPairResourceWriter.INSTANCE.writePrivateKey(keyPair, null, null, it) }
commands.add("-i")
commands.add(privateKeyFile.toFile().absolutePath)
tempFiles.add(privateKeyPath)
}
} else if (host.authentication.type == AuthenticationType.Password) {
terminal.getTerminalModel().addDataListener(PasswordReporterDataListener(host).apply {
lastPasswordReporterDataListener = this
})
}
}
override fun stop() {
// 删除密码监听
lastPasswordReporterDataListener?.let { listener ->
SwingUtilities.invokeLater { terminal.getTerminalModel().removeDataListener(listener) }
}
IOUtils.closeQuietly(sshSession)
IOUtils.closeQuietly(sshClient)
tempFiles.removeIf {
FileUtils.deleteQuietly(it.toFile())
true
}
super.stop()
}
override fun getIcon(): Icon {
return Icons.fileFormat
}
private inner class PasswordReporterDataListener(private val host: Host) : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
if (key == VisualTerminal.Written && data is String) {
// 要求输入密码
val line = terminal.getDocument().getScreenLine(terminal.getCursorModel().getPosition().y)
if (line.getText().trim().trimIndent().startsWith("${host.username}@${host.host}'s password:")) {
// 删除密码监听
terminal.getTerminalModel().removeDataListener(this)
val ptyConnector = getPtyConnector()
// password
ptyConnector.write(host.authentication.password.toByteArray(ptyConnector.getCharset()))
// enter
ptyConnector.write(
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
.toByteArray(ptyConnector.getCharset())
)
}
}
}
}
}

View File

@@ -1,5 +1,7 @@
package app.termora package app.termora
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import app.termora.transport.TransportDataProviders import app.termora.transport.TransportDataProviders
import app.termora.transport.TransportPanel import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener import java.beans.PropertyChangeListener
@@ -8,7 +10,7 @@ import javax.swing.JComponent
import javax.swing.JOptionPane import javax.swing.JOptionPane
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
class SFTPTerminalTab : Disposable, TerminalTab { class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
private val transportPanel by lazy { private val transportPanel by lazy {
TransportPanel().apply { TransportPanel().apply {
@@ -54,4 +56,12 @@ class SFTPTerminalTab : Disposable, TerminalTab {
) == JOptionPane.OK_OPTION ) == JOptionPane.OK_OPTION
} }
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == TransportDataProviders.TransportPanel) {
return transportPanel as T
}
return null
}
} }

View File

@@ -26,10 +26,9 @@ import org.apache.sshd.common.channel.ChannelListener
import org.apache.sshd.common.session.Session import org.apache.sshd.common.session.Session
import org.apache.sshd.common.session.SessionListener import org.apache.sshd.common.session.SessionListener
import org.apache.sshd.common.session.SessionListener.Event import org.apache.sshd.common.session.SessionListener.Event
import org.apache.sshd.common.util.net.SshdSocketAddress
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.EventObject import java.util.*
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
@@ -87,9 +86,24 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
terminal.write("SSH client is opening...\r\n") terminal.write("SSH client is opening...\r\n")
} }
var host = this.host.copy(authentication = this.host.authentication.copy())
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
val client = SshClients.openClient(host).also { sshClient = it } val client = SshClients.openClient(host).also { sshClient = it }
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// keyboard interactive // keyboard interactive
client.userInteraction = TerminalUserInteraction(SwingUtilities.getWindowAncestor(terminalPanel)) client.userInteraction = TerminalUserInteraction(owner)
if (host.authentication.type == AuthenticationType.No) {
withContext(Dispatchers.Swing) {
val dialog = RequestAuthenticationDialog(owner)
val authentication = dialog.getAuthentication()
host = host.copy(authentication = authentication)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(this@SSHTerminalTab.host.copy(authentication = authentication))
}
}
}
val sessionListener = MySessionListener() val sessionListener = MySessionListener()
val channelListener = MyChannelListener() val channelListener = MyChannelListener()
@@ -178,28 +192,8 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
} }
for (tunneling in host.tunnelings) { for (tunneling in host.tunnelings) {
if (tunneling.type == TunnelingType.Local) {
session.startLocalPortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
)
} else if (tunneling.type == TunnelingType.Remote) {
session.startRemotePortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
)
} else if (tunneling.type == TunnelingType.Dynamic) {
session.startDynamicPortForwarding(
SshdSocketAddress(
tunneling.sourceHost,
tunneling.sourcePort
)
)
}
if (log.isInfoEnabled) { SshClients.openTunneling(session, host, tunneling)
log.info("SSH [{}] started {} port forwarding.", host.name, tunneling.name)
}
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n") terminal.write("Start [${tunneling.name}] port forwarding successfully.\r\n")

View File

@@ -20,6 +20,7 @@ import app.termora.sync.SyncType
import app.termora.sync.SyncerProvider import app.termora.sync.SyncerProvider
import app.termora.terminal.CursorStyle import app.termora.terminal.CursorStyle
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalPanel import app.termora.terminal.panel.TerminalPanel
import cash.z.ecc.android.bip39.Mnemonics import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
@@ -35,6 +36,7 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import org.apache.commons.codec.binary.Base64
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
@@ -44,6 +46,7 @@ import org.jdesktop.swingx.JXEditorPane
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
import java.awt.Dimension
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import java.awt.event.ItemEvent import java.awt.event.ItemEvent
@@ -109,6 +112,7 @@ class SettingsOptionsPane : OptionsPane() {
addOption(AppearanceOption()) addOption(AppearanceOption())
addOption(TerminalOption()) addOption(TerminalOption())
addOption(KeyShortcutsOption()) addOption(KeyShortcutsOption())
addOption(SFTPOption())
addOption(CloudSyncOption()) addOption(CloudSyncOption())
addOption(DoormanOption()) addOption(DoormanOption())
addOption(AboutOption()) addOption(AboutOption())
@@ -308,6 +312,7 @@ class SettingsOptionsPane : OptionsPane() {
private val terminalSetting get() = Database.getDatabase().terminal private val terminalSetting get() = Database.getDatabase().terminal
private val selectCopyComboBox = YesOrNoComboBox() private val selectCopyComboBox = YesOrNoComboBox()
private val autoCloseTabComboBox = YesOrNoComboBox() private val autoCloseTabComboBox = YesOrNoComboBox()
private val floatingToolbarComboBox = YesOrNoComboBox()
init { init {
initView() initView()
@@ -330,6 +335,19 @@ class SettingsOptionsPane : OptionsPane() {
} }
autoCloseTabComboBox.toolTipText = I18n.getString("termora.settings.terminal.auto-close-tab-description") autoCloseTabComboBox.toolTipText = I18n.getString("termora.settings.terminal.auto-close-tab-description")
floatingToolbarComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.floatingToolbar = floatingToolbarComboBox.selectedItem as Boolean
TerminalPanelFactory.getAllTerminalPanel().forEach { tp ->
if (terminalSetting.floatingToolbar && FloatingToolbarPanel.isPined) {
tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerShow()
} else {
tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerHide()
}
}
}
}
selectCopyComboBox.addItemListener { e -> selectCopyComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) { if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean terminalSetting.selectCopy = selectCopyComboBox.selectedItem as Boolean
@@ -408,6 +426,11 @@ class SettingsOptionsPane : OptionsPane() {
} }
fontComboBox.renderer = object : DefaultListCellRenderer() { fontComboBox.renderer = object : DefaultListCellRenderer() {
init {
preferredSize = Dimension(preferredSize.width, fontComboBox.preferredSize.height - 2)
maximumSize = Dimension(preferredSize.width, preferredSize.height)
}
override fun getListCellRendererComponent( override fun getListCellRendererComponent(
list: JList<*>?, list: JList<*>?,
value: Any?, value: Any?,
@@ -441,28 +464,11 @@ class SettingsOptionsPane : OptionsPane() {
shellComboBox.selectedItem = terminalSetting.localShell shellComboBox.selectedItem = terminalSetting.localShell
val fonts = linkedSetOf( val fonts = linkedSetOf<String>("JetBrains Mono", "Source Code Pro", "Monospaced")
"JetBrains Mono", FontUtils.getAllFonts().forEach {
"Source Code Pro", if (!fonts.contains(it.family)) {
"Monospaced", fonts.addLast(it.family)
"Andale Mono",
"Ayuthaya",
"Courier New",
"Droid Sans Mono",
"Fira Code",
"PCMyungjo",
"Menlo",
"Monaco",
"Osaka",
"PT Mono",
"SimSong",
)
for (font in FontUtils.getAllFonts()) {
if (fonts.contains(font.family)) {
continue
} }
fonts.remove(font.family)
} }
for (font in fonts) { for (font in fonts) {
@@ -475,6 +481,7 @@ class SettingsOptionsPane : OptionsPane() {
cursorStyleComboBox.selectedItem = terminalSetting.cursor cursorStyleComboBox.selectedItem = terminalSetting.cursor
selectCopyComboBox.selectedItem = terminalSetting.selectCopy selectCopyComboBox.selectedItem = terminalSetting.selectCopy
autoCloseTabComboBox.selectedItem = terminalSetting.autoCloseTabWhenDisconnected autoCloseTabComboBox.selectedItem = terminalSetting.autoCloseTabWhenDisconnected
floatingToolbarComboBox.selectedItem = terminalSetting.floatingToolbar
} }
override fun getIcon(isSelected: Boolean): Icon { override fun getIcon(isSelected: Boolean): Icon {
@@ -492,7 +499,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent { private fun getCenterComponent(): JComponent {
val layout = FormLayout( val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow", "left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
) )
val beepBtn = JButton(Icons.run) val beepBtn = JButton(Icons.run)
@@ -519,6 +526,8 @@ class SettingsOptionsPane : OptionsPane() {
.add(selectCopyComboBox).xy(3, rows).apply { rows += step } .add(selectCopyComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows)
.add(cursorStyleComboBox).xy(3, rows).apply { rows += step } .add(cursorStyleComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.floating-toolbar")}:").xy(1, rows)
.add(floatingToolbarComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.auto-close-tab")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.auto-close-tab")}:").xy(1, rows)
.add(autoCloseTabComboBox).xy(3, rows).apply { rows += step } .add(autoCloseTabComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.local-shell")}:").xy(1, rows) .add("${I18n.getString("termora.settings.terminal.local-shell")}:").xy(1, rows)
@@ -666,13 +675,40 @@ class SettingsOptionsPane : OptionsPane() {
private fun export() { private fun export() {
assertEventDispatchThread()
val passwordField = OutlinePasswordField()
val panel = object : JPanel(BorderLayout()) {
override fun requestFocusInWindow(): Boolean {
return passwordField.requestFocusInWindow()
}
}
val label = JLabel(I18n.getString("termora.settings.sync.export-encrypt") + StringUtils.SPACE.repeat(25))
label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
panel.add(label, BorderLayout.NORTH)
panel.add(passwordField, BorderLayout.CENTER)
var password = StringUtils.EMPTY
if (OptionPane.showConfirmDialog(
owner,
panel,
optionType = JOptionPane.YES_NO_OPTION,
initialValue = passwordField
) == JOptionPane.YES_OPTION
) {
password = String(passwordField.password).trim()
}
val fileChooser = FileChooser() val fileChooser = FileChooser()
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
fileChooser.win32Filters.add(Pair("All Files", listOf("*"))) fileChooser.win32Filters.add(Pair("All Files", listOf("*")))
fileChooser.win32Filters.add(Pair("JSON files", listOf("json"))) fileChooser.win32Filters.add(Pair("JSON files", listOf("json")))
fileChooser.showSaveDialog(owner, "${Application.getName()}.json").thenAccept { file -> fileChooser.showSaveDialog(owner, "${Application.getName()}.json").thenAccept { file ->
if (file != null) { if (file != null) {
SwingUtilities.invokeLater { exportText(file) } SwingUtilities.invokeLater { exportText(file, password) }
} }
} }
} }
@@ -689,6 +725,7 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
@Suppress("DuplicatedCode")
private fun importFromFile(file: File) { private fun importFromFile(file: File) {
if (!file.exists()) { if (!file.exists()) {
return return
@@ -719,7 +756,79 @@ class SettingsOptionsPane : OptionsPane() {
return return
} }
val json = jsonResult.getOrNull() ?: return var json = jsonResult.getOrNull() ?: return
// 如果加密了 则解密数据
if (json["encryption"]?.jsonPrimitive?.booleanOrNull == true) {
val data = json["data"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
if (data.isBlank()) {
OptionPane.showMessageDialog(
owner, "Data file corruption",
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
while (true) {
val passwordField = OutlinePasswordField()
val panel = object : JPanel(BorderLayout()) {
override fun requestFocusInWindow(): Boolean {
return passwordField.requestFocusInWindow()
}
}
val label = JLabel("Please enter the password" + StringUtils.SPACE.repeat(25))
label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
panel.add(label, BorderLayout.NORTH)
panel.add(passwordField, BorderLayout.CENTER)
if (OptionPane.showConfirmDialog(
owner,
panel,
optionType = JOptionPane.YES_NO_OPTION,
initialValue = passwordField
) != JOptionPane.YES_OPTION
) {
return
}
if (passwordField.password.isEmpty()) {
OptionPane.showMessageDialog(
owner, I18n.getString("termora.doorman.unlock-data"),
messageType = JOptionPane.ERROR_MESSAGE
)
continue
}
val password = String(passwordField.password)
val key = PBKDF2.generateSecret(
password.toCharArray(),
password.toByteArray(), keyLength = 128
)
try {
val dataText = AES.ECB.decrypt(key, Base64.decodeBase64(data)).toString(Charsets.UTF_8)
val dataJsonResult = ohMyJson.runCatching { decodeFromString<JsonObject>(dataText) }
if (dataJsonResult.isFailure) {
val e = dataJsonResult.exceptionOrNull() ?: return
OptionPane.showMessageDialog(
owner, ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
json = dataJsonResult.getOrNull() ?: return
break
} catch (_: Exception) {
OptionPane.showMessageDialog(
owner, I18n.getString("termora.doorman.password-wrong"),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
}
if (ranges.contains(SyncRange.Hosts)) { if (ranges.contains(SyncRange.Hosts)) {
val hosts = json["hosts"] val hosts = json["hosts"]
if (hosts is JsonArray) { if (hosts is JsonArray) {
@@ -780,9 +889,9 @@ class SettingsOptionsPane : OptionsPane() {
) )
} }
private fun exportText(file: File) { private fun exportText(file: File, password: String) {
val syncConfig = getSyncConfig() val syncConfig = getSyncConfig()
val text = ohMyJson.encodeToString(buildJsonObject { var text = ohMyJson.encodeToString(buildJsonObject {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
put("exporter", SystemUtils.USER_NAME) put("exporter", SystemUtils.USER_NAME)
put("version", Application.getVersion()) put("version", Application.getVersion())
@@ -821,6 +930,19 @@ class SettingsOptionsPane : OptionsPane() {
put("terminal", ohMyJson.encodeToJsonElement(database.terminal.getProperties())) put("terminal", ohMyJson.encodeToJsonElement(database.terminal.getProperties()))
}) })
}) })
if (password.isNotBlank()) {
val key = PBKDF2.generateSecret(
password.toCharArray(),
password.toByteArray(), keyLength = 128
)
text = ohMyJson.encodeToString(buildJsonObject {
put("encryption", true)
put("data", AES.ECB.encrypt(key, text.toByteArray(Charsets.UTF_8)).encodeBase64String())
})
}
file.outputStream().use { file.outputStream().use {
IOUtils.write(text, it, StandardCharsets.UTF_8) IOUtils.write(text, it, StandardCharsets.UTF_8)
OptionPane.openFileInFolder( OptionPane.openFileInFolder(
@@ -1172,6 +1294,63 @@ class SettingsOptionsPane : OptionsPane() {
} }
} }
private inner class SFTPOption : JPanel(BorderLayout()), Option {
val editCommandField = OutlineTextField(255)
private val sftp get() = database.sftp
init {
initView()
initEvents()
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
editCommandField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
sftp.editCommand = editCommandField.text
}
})
}
private fun initView() {
if (SystemInfo.isWindows || SystemInfo.isLinux) {
editCommandField.placeholderText = "notepad {0}"
} else if (SystemInfo.isMacOS) {
editCommandField.placeholderText = "open -a TextEdit {0}"
}
editCommandField.text = sftp.editCommand
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.folder
}
override fun getTitle(): String {
return "SFTP"
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, 30dlu",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, 1)
builder.add(editCommandField).xy(3, 1)
return builder.build()
}
}
private inner class AboutOption : JPanel(BorderLayout()), Option { private inner class AboutOption : JPanel(BorderLayout()), Option {
init { init {

View File

@@ -2,14 +2,21 @@ package app.termora
import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize import app.termora.terminal.TerminalSize
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.ClientBuilder import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ChannelShell import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.config.hosts.HostConfigEntry
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
import org.apache.sshd.client.config.hosts.KnownHostEntry
import org.apache.sshd.client.kex.DHGClient import org.apache.sshd.client.kex.DHGClient
import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier
import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
import org.apache.sshd.client.keyverifier.ServerKeyVerifier
import org.apache.sshd.client.session.ClientSession import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.SshException import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.PtyChannelConfiguration import org.apache.sshd.common.channel.PtyChannelConfiguration
import org.apache.sshd.common.config.keys.KeyUtils
import org.apache.sshd.common.global.KeepAliveHandler import org.apache.sshd.common.global.KeepAliveHandler
import org.apache.sshd.common.kex.BuiltinDHFactories import org.apache.sshd.common.kex.BuiltinDHFactories
import org.apache.sshd.common.keyprovider.KeyIdentityProvider import org.apache.sshd.common.keyprovider.KeyIdentityProvider
@@ -17,14 +24,23 @@ import org.apache.sshd.common.util.net.SshdSocketAddress
import org.apache.sshd.core.CoreModuleProperties import org.apache.sshd.core.CoreModuleProperties
import org.apache.sshd.server.forward.AcceptAllForwardingFilter import org.apache.sshd.server.forward.AcceptAllForwardingFilter
import org.apache.sshd.server.forward.RejectAllForwardingFilter import org.apache.sshd.server.forward.RejectAllForwardingFilter
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.eclipse.jgit.internal.transport.sshd.JGitSshClient import org.eclipse.jgit.internal.transport.sshd.JGitSshClient
import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
import org.eclipse.jgit.transport.sshd.ProxyData import org.eclipse.jgit.transport.sshd.ProxyData
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.Window
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy import java.net.Proxy
import java.net.SocketAddress
import java.nio.file.Path
import java.nio.file.Paths
import java.security.PublicKey
import java.time.Duration import java.time.Duration
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import kotlin.math.max import kotlin.math.max
object SshClients { object SshClients {
@@ -89,7 +105,7 @@ object SshClients {
val sessions = mutableListOf<ClientSession>() val sessions = mutableListOf<ClientSession>()
for (i in 0 until jumpHosts.size) { for (i in 0 until jumpHosts.size) {
val currentHost = jumpHosts[i] val currentHost = jumpHosts[i]
sessions.add(doOpenSession(currentHost, client)) sessions.add(doOpenSession(currentHost, client, i != 0))
// 如果有下一跳 // 如果有下一跳
if (i < jumpHosts.size - 1) { if (i < jumpHosts.size - 1) {
@@ -110,8 +126,27 @@ object SshClients {
return sessions.last() return sessions.last()
} }
private fun doOpenSession(host: Host, client: SshClient): ClientSession { fun isMiddleware(session: ClientSession): Boolean {
val session = client.connect(host.username, host.host, host.port) if (session is JGitClientSession) {
if (session.hostConfigEntry.properties["Middleware"]?.toBoolean() == true) {
return true
}
}
return false
}
/**
* @param middleware 如果为 true 表示是跳板
*/
private fun doOpenSession(host: Host, client: SshClient, middleware: Boolean = false): ClientSession {
val entry = HostConfigEntry()
entry.port = host.port
entry.username = host.username
entry.hostName = host.host
entry.setProperty("Middleware", middleware.toString())
val session = client.connect(entry)
.verify(timeout).session .verify(timeout).session
if (host.authentication.type == AuthenticationType.Password) { if (host.authentication.type == AuthenticationType.Password) {
session.addPasswordIdentity(host.authentication.password) session.addPasswordIdentity(host.authentication.password)
@@ -127,6 +162,41 @@ object SshClients {
return session return session
} }
fun openTunneling(session: ClientSession, host: Host, tunneling: Tunneling): SshdSocketAddress {
val sshdSocketAddress = if (tunneling.type == TunnelingType.Local) {
session.startLocalPortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort)
)
} else if (tunneling.type == TunnelingType.Remote) {
session.startRemotePortForwarding(
SshdSocketAddress(tunneling.sourceHost, tunneling.sourcePort),
SshdSocketAddress(tunneling.destinationHost, tunneling.destinationPort),
)
} else if (tunneling.type == TunnelingType.Dynamic) {
session.startDynamicPortForwarding(
SshdSocketAddress(
tunneling.sourceHost,
tunneling.sourcePort
)
)
} else {
SshdSocketAddress.LOCALHOST_ADDRESS
}
if (log.isInfoEnabled) {
log.info(
"SSH [{}] started {} port forwarding. host: {} , port: {}",
host.name,
tunneling.name,
sshdSocketAddress.hostName,
sshdSocketAddress.port
)
}
return sshdSocketAddress
}
/** /**
* 打开一个客户端 * 打开一个客户端
@@ -192,3 +262,93 @@ object SshClients {
return sshClient return sshClient
} }
} }
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
override fun verifyServerKey(
clientSession: ClientSession,
remoteAddress: SocketAddress,
serverKey: PublicKey
): Boolean {
if (SshClients.isMiddleware(clientSession)) {
return true
}
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait {
result.set(
OptionPane.showConfirmDialog(
parentComponent = owner,
message = I18n.getString(
"termora.host.verify-server-key",
remoteAddress.toString().replace("/", StringUtils.EMPTY),
KeyUtils.getKeyType(serverKey),
KeyUtils.getFingerPrint(serverKey)
),
optionType = JOptionPane.OK_CANCEL_OPTION,
messageType = JOptionPane.WARNING_MESSAGE,
) == JOptionPane.OK_OPTION
)
}
return result.get()
}
override fun acceptModifiedServerKey(
clientSession: ClientSession?,
remoteAddress: SocketAddress?,
entry: KnownHostEntry?,
expected: PublicKey?,
actual: PublicKey?
): Boolean {
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait {
result.set(
OptionPane.showConfirmDialog(
parentComponent = owner,
message = I18n.getString(
"termora.host.modified-server-key",
remoteAddress.toString().replace("/", StringUtils.EMPTY),
KeyUtils.getKeyType(expected),
KeyUtils.getFingerPrint(expected),
KeyUtils.getKeyType(actual),
KeyUtils.getFingerPrint(actual),
),
optionType = JOptionPane.OK_CANCEL_OPTION,
messageType = JOptionPane.WARNING_MESSAGE,
) == JOptionPane.OK_OPTION
)
}
return result.get()
}
}
class DialogServerKeyVerifier(
owner: Window,
) : KnownHostsServerKeyVerifier(
MyDialogServerKeyVerifier(owner),
Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts")
) {
init {
modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor
}
override fun updateKnownHostsFile(
clientSession: ClientSession?,
remoteAddress: SocketAddress?,
serverKey: PublicKey?,
file: Path?,
knownHosts: Collection<HostEntryPair?>?
): KnownHostEntry? {
if (clientSession is JGitClientSession) {
if (SshClients.isMiddleware(clientSession)) {
return null
}
}
return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts)
}
}

View File

@@ -16,6 +16,12 @@ class TerminalPanelFactory {
fun getInstance(scope: Scope): TerminalPanelFactory { fun getInstance(scope: Scope): TerminalPanelFactory {
return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() } return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
} }
fun getAllTerminalPanel(): List<TerminalPanel> {
return ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }
.flatMap { it.getTerminalPanels() }
}
} }
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel { fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {

View File

@@ -18,11 +18,8 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.beans.PropertyChangeListener import java.beans.PropertyChangeListener
import java.util.* import java.util.*
import javax.swing.Icon import javax.swing.*
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
import javax.swing.SwingUtilities
import kotlin.math.min import kotlin.math.min
class TerminalTabbed( class TerminalTabbed(
@@ -238,6 +235,17 @@ class TerminalTabbed(
} }
}) })
if (tab is HostTerminalTab) {
val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST)
if (openHostAction != null) {
if (tab.host.protocol == Protocol.SSH || tab.host.protocol == Protocol.SFTPPty) {
popupMenu.addSeparator()
val sftpCommand = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
sftpCommand.addActionListener { openSFTPPtyTab(tab, openHostAction, it) }
}
}
}
popupMenu.addSeparator() popupMenu.addSeparator()
// 关闭 // 关闭
@@ -311,6 +319,36 @@ class TerminalTabbed(
Disposer.register(this, tab) Disposer.register(this, tab)
} }
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
if (!SFTPPtyTerminalTab.canSupports) {
OptionPane.showMessageDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.tabbed.contextmenu.sftp-not-install"),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
var host = tab.host
if (host.protocol == Protocol.SSH) {
val envs = tab.host.options.envs().toMutableMap()
val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel()
?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY
if (currentDir.isNotBlank()) {
envs["CurrentDir"] = currentDir
}
host = host.copy(
protocol = Protocol.SFTPPty,
options = host.options.copy(env = envs.toPropertiesString())
)
}
openHostAction.actionPerformed(OpenHostActionEvent(this, host, evt))
}
/** /**
* 对着 ToolBar 右键 * 对着 ToolBar 右键
*/ */

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ class KeymapTableModel : DefaultTableModel() {
TerminalZoomOutAction.ZOOM_OUT, TerminalZoomOutAction.ZOOM_OUT,
TerminalZoomResetAction.ZOOM_RESET, TerminalZoomResetAction.ZOOM_RESET,
OpenLocalTerminalAction.LOCAL_TERMINAL, OpenLocalTerminalAction.LOCAL_TERMINAL,
TerminalClearScreenAction.CLEAR_SCREEN,
FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction.FIND_EVERYWHERE,
NewWindowAction.NEW_WINDOW, NewWindowAction.NEW_WINDOW,
TabReconnectAction.RECONNECT_TAB, TabReconnectAction.RECONNECT_TAB,

View File

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

View File

@@ -22,7 +22,9 @@ object CursorStoreStores {
terminalModel.setData(DataKey.OriginMode, cursorStore.originMode) terminalModel.setData(DataKey.OriginMode, cursorStore.originMode)
terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle) terminalModel.setData(DataKey.TextStyle, cursorStore.textStyle)
if (cursorStore.autoWarpMode != null) {
terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode) terminalModel.setData(DataKey.AutoWrapMode, cursorStore.autoWarpMode)
}
terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet) terminalModel.setData(DataKey.GraphicCharacterSet, cursorStore.graphicCharacterSet)
val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion() val region = if (terminalModel.isOriginMode()) terminalModel.getScrollingRegion()
@@ -52,7 +54,7 @@ object CursorStoreStores {
val cursorStore = CursorStore( val cursorStore = CursorStore(
position = terminal.getCursorModel().getPosition(), position = terminal.getCursorModel().getPosition(),
textStyle = terminalModel.getData(DataKey.TextStyle), textStyle = terminalModel.getData(DataKey.TextStyle),
autoWarpMode = terminalModel.getData(DataKey.AutoWrapMode, false), autoWarpMode = if (terminalModel.hasData(DataKey.AutoWrapMode)) terminalModel.getData(DataKey.AutoWrapMode) else null,
originMode = terminalModel.isOriginMode(), originMode = terminalModel.isOriginMode(),
graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets), graphicCharacterSet = graphicCharacterSet.copy(characterSets = characterSets),
) )

View File

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

View File

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

View File

@@ -0,0 +1,156 @@
package app.termora.terminal.panel
import app.termora.*
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.terminal.DataKey
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.ui.FlatRoundBorder
import org.apache.commons.lang3.StringUtils
import java.awt.event.ActionListener
import javax.swing.JButton
class FloatingToolbarPanel : FlatToolBar(), Disposable {
private val floatingToolbarEnable get() = Database.getDatabase().terminal.floatingToolbar
private var closed = false
companion object {
val FloatingToolbar = DataKey(FloatingToolbarPanel::class)
val isPined get() = pinAction.isSelected
private val pinAction by lazy {
object : AnAction() {
private val properties get() = Database.getDatabase().properties
private val key = "FloatingToolbar.pined"
init {
setStateAction()
isSelected = properties.getString(key, StringUtils.EMPTY).toBoolean()
}
override fun actionPerformed(evt: AnActionEvent) {
isSelected = !isSelected
properties.putString(key, isSelected.toString())
actionListeners.forEach { it.actionPerformed(evt) }
if (isSelected) {
TerminalPanelFactory.getAllTerminalPanel().forEach {
it.getData(FloatingToolbar)?.triggerShow()
}
} else {
// 触发者的不隐藏
val c = evt.getData(FloatingToolbar)
TerminalPanelFactory.getAllTerminalPanel().forEach {
val e = it.getData(FloatingToolbar)
if (c != e) {
e?.triggerHide()
}
}
}
}
}
}
}
init {
border = FlatRoundBorder()
isOpaque = false
isFocusable = false
isFloatable = false
isVisible = false
if (floatingToolbarEnable) {
if (pinAction.isSelected) {
isVisible = true
}
}
initActions()
}
fun triggerShow() {
if (!floatingToolbarEnable || closed) {
return
}
if (isVisible == false) {
isVisible = true
firePropertyChange("visible", false, true)
}
}
fun triggerHide() {
if (floatingToolbarEnable && !closed) {
if (pinAction.isSelected) {
return
}
}
if (isVisible == true) {
isVisible = false
firePropertyChange("visible", true, false)
}
}
private fun initActions() {
// Pin
add(initPinActionButton())
// 重连
add(initReconnectActionButton())
// 关闭
add(initCloseActionButton())
}
private fun initPinActionButton(): JButton {
val btn = JButton(Icons.pin)
btn.isSelected = pinAction.isSelected
val actionListener = ActionListener { btn.isSelected = pinAction.isSelected }
pinAction.addActionListener(actionListener)
btn.addActionListener(pinAction)
Disposer.register(this, object : Disposable {
override fun dispose() {
btn.removeActionListener(pinAction)
pinAction.removeActionListener(actionListener)
}
})
return btn
}
private fun initCloseActionButton(): JButton {
val btn = JButton(Icons.closeSmall)
btn.pressedIcon = Icons.closeSmallHovered
btn.rolloverIcon = Icons.closeSmallHovered
btn.addActionListener {
closed = true
triggerHide()
}
return btn
}
private fun initReconnectActionButton(): JButton {
val btn = JButton(Icons.refresh)
btn.toolTipText = I18n.getString("termora.tabbed.contextmenu.reconnect")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = evt.getData(DataProviders.TerminalTab) ?: return
if (tab.canReconnect()) {
tab.reconnect()
}
}
})
return btn
}
override fun dispose() {
}
}

View File

@@ -1,6 +1,7 @@
package app.termora.terminal.panel package app.termora.terminal.panel
import app.termora.Disposable import app.termora.Disposable
import app.termora.Disposer
import app.termora.actions.DataProvider import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
@@ -40,10 +41,12 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
} }
private val terminalFindPanel = TerminalFindPanel(this, terminal) private val terminalFindPanel = TerminalFindPanel(this, terminal)
private val floatingToolbar = FloatingToolbarPanel()
private val terminalDisplay = TerminalDisplay(this, terminal) private val terminalDisplay = TerminalDisplay(this, terminal)
val scrollBar = TerminalScrollBar(this@TerminalPanel, terminalFindPanel, terminal)
private val dataProviderSupport = DataProviderSupport() private val dataProviderSupport = DataProviderSupport()
val scrollBar = TerminalScrollBar(this@TerminalPanel, terminalFindPanel, terminal)
/** /**
* 键盘事件 * 键盘事件
@@ -117,6 +120,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
val layeredPane = TerminalLayeredPane() val layeredPane = TerminalLayeredPane()
layeredPane.add(terminalDisplay, JLayeredPane.DEFAULT_LAYER as Any) layeredPane.add(terminalDisplay, JLayeredPane.DEFAULT_LAYER as Any)
layeredPane.add(terminalFindPanel, JLayeredPane.POPUP_LAYER as Any) layeredPane.add(terminalFindPanel, JLayeredPane.POPUP_LAYER as Any)
layeredPane.add(floatingToolbar, JLayeredPane.POPUP_LAYER as Any)
add(layeredPane, BorderLayout.CENTER) add(layeredPane, BorderLayout.CENTER)
add(scrollBar, BorderLayout.EAST) add(scrollBar, BorderLayout.EAST)
@@ -127,6 +131,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
dataProviderSupport.addData(DataProviders.TerminalPanel, this) dataProviderSupport.addData(DataProviders.TerminalPanel, this)
dataProviderSupport.addData(DataProviders.Terminal, terminal) dataProviderSupport.addData(DataProviders.Terminal, terminal)
dataProviderSupport.addData(DataProviders.PtyConnector, ptyConnector) dataProviderSupport.addData(DataProviders.PtyConnector, ptyConnector)
dataProviderSupport.addData(FloatingToolbarPanel.FloatingToolbar, floatingToolbar)
} }
private fun initEvents() { private fun initEvents() {
@@ -158,6 +163,11 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
this.addMouseListener(trackingAdapter) this.addMouseListener(trackingAdapter)
this.addMouseWheelListener(trackingAdapter) this.addMouseWheelListener(trackingAdapter)
// 悬浮工具栏
val floatingToolBarAdapter = TerminalPanelMouseFloatingToolBarAdapter(this, terminalDisplay)
this.addMouseMotionListener(floatingToolBarAdapter)
this.addMouseListener(floatingToolBarAdapter)
// 滚动相关 // 滚动相关
this.addMouseWheelListener(object : MouseWheelListener { this.addMouseWheelListener(object : MouseWheelListener {
override fun mouseWheelMoved(e: MouseWheelEvent) { override fun mouseWheelMoved(e: MouseWheelEvent) {
@@ -197,6 +207,8 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
// 开启拖拽 // 开启拖拽
enableDropTarget() enableDropTarget()
// 监听悬浮工具栏变化,然后重新渲染
floatingToolbar.addPropertyChangeListener { repaintImmediate() }
} }
@@ -373,6 +385,9 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
} }
override fun dispose() {
Disposer.dispose(floatingToolbar)
}
fun getAverageCharWidth(): Int { fun getAverageCharWidth(): Int {
return terminalDisplay.getAverageCharWidth() return terminalDisplay.getAverageCharWidth()
@@ -450,6 +465,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
synchronized(treeLock) { synchronized(treeLock) {
val w = width val w = width
val h = height val h = height
val findPanelHeight = max(terminalFindPanel.preferredSize.height, terminalFindPanel.height)
for (c in components) { for (c in components) {
when (c) { when (c) {
terminalDisplay -> { terminalDisplay -> {
@@ -467,7 +483,19 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
w - width, w - width,
0, 0,
width, width,
max(terminalFindPanel.preferredSize.height, terminalFindPanel.height) findPanelHeight
)
}
floatingToolbar -> {
val width = floatingToolbar.preferredSize.width
val height = floatingToolbar.preferredSize.height
val y = 4
c.setBounds(
w - width,
if (terminalFindPanel.isVisible) findPanelHeight + y else y,
width,
height
) )
} }
} }

View File

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

View File

@@ -2,6 +2,7 @@ package app.termora.transport
import app.termora.* import app.termora.*
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatToolBar import com.formdev.flatlaf.extras.components.FlatToolBar
@@ -12,6 +13,7 @@ import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.io.file.PathUtils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils import org.apache.commons.lang3.exception.ExceptionUtils
@@ -19,6 +21,7 @@ import org.apache.sshd.sftp.client.SftpClient
import org.apache.sshd.sftp.client.fs.SftpFileSystem import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath import org.apache.sshd.sftp.client.fs.SftpPath
import org.jdesktop.swingx.JXBusyLabel import org.jdesktop.swingx.JXBusyLabel
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
@@ -32,11 +35,16 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.io.File import java.io.File
import java.nio.file.* import java.nio.file.*
import java.text.MessageFormat
import java.util.* import java.util.*
import java.util.regex.Pattern
import javax.swing.* import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableCellRenderer
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists import kotlin.io.path.exists
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.isDirectory import kotlin.io.path.isDirectory
import kotlin.time.Duration.Companion.milliseconds
/** /**
@@ -44,9 +52,8 @@ import kotlin.io.path.isDirectory
*/ */
class FileSystemPanel( class FileSystemPanel(
private val fileSystem: FileSystem, private val fileSystem: FileSystem,
private val transportManager: TransportManager,
private val host: Host private val host: Host
) : JPanel(BorderLayout()), Disposable, FileSystemTransportListener.Provider { ) : JPanel(BorderLayout()), Disposable {
companion object { companion object {
private val log = LoggerFactory.getLogger(FileSystemPanel::class.java) private val log = LoggerFactory.getLogger(FileSystemPanel::class.java)
@@ -64,6 +71,14 @@ class FileSystemPanel(
private val showHiddenFilesBtn = JButton(Icons.eyeClose) private val showHiddenFilesBtn = JButton(Icons.eyeClose)
private val properties get() = Database.getDatabase().properties private val properties get() = Database.getDatabase().properties
private val showHiddenFilesKey by lazy { "termora.transport.host.${host.id}.show-hidden-files" } private val showHiddenFilesKey by lazy { "termora.transport.host.${host.id}.show-hidden-files" }
private val evt by lazy { AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) }
private val sftp get() = Database.getDatabase().sftp
private val actionManager get() = ActionManager.getInstance()
/**
* Edit
*/
private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
val workdir get() = tableModel.workdir val workdir get() = tableModel.workdir
@@ -342,6 +357,9 @@ class FileSystemPanel(
} }
override fun dispose() {
coroutineScope.cancel()
}
private fun copyLocalFileToFileSystem(files: List<File>) { private fun copyLocalFileToFileSystem(files: List<File>) {
val event = AnActionEvent(this, StringUtils.EMPTY, EventObject(this)) val event = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
@@ -425,14 +443,6 @@ class FileSystemPanel(
} }
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listenerList.add(FileSystemTransportListener::class.java, listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listenerList.remove(FileSystemTransportListener::class.java, listener)
}
private fun openFolder() { private fun openFolder() {
val row = table.selectedRow val row = table.selectedRow
if (row < 0) return if (row < 0) return
@@ -460,6 +470,7 @@ class FileSystemPanel(
private fun showContextMenu(rows: IntArray, event: MouseEvent) { private fun showContextMenu(rows: IntArray, event: MouseEvent) {
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
val popupMenu = FlatPopupMenu() val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new")) val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
@@ -477,11 +488,22 @@ class FileSystemPanel(
// 传输 // 传输
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer")) val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
transfer.addActionListener { transfer.addActionListener {
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
if (paths.isNotEmpty()) { if (paths.isNotEmpty()) {
transport(paths) transport(paths)
} }
} }
// 编辑
val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit"))
// 不是本地文件系统 & 包含文件
edit.isEnabled = !tableModel.isLocalFileSystem && paths.any { !it.isDirectory }
edit.addActionListener {
val files = paths.filter { !it.isDirectory }
if (files.isNotEmpty()) {
editFiles(files)
}
}
popupMenu.addSeparator() popupMenu.addSeparator()
// 复制路径 // 复制路径
@@ -574,6 +596,127 @@ class FileSystemPanel(
popupMenu.show(table, event.x, event.y) popupMenu.show(table, event.x, event.y)
} }
private fun editFiles(files: List<FileSystemTableModel.CacheablePath>) {
if (files.isEmpty()) return
val transportManager = evt.getData(TransportDataProviders.TransportManager) ?: return
if (SystemInfo.isLinux) {
if (sftp.editCommand.isBlank()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.transport.table.contextmenu.edit-command"),
messageType = JOptionPane.INFORMATION_MESSAGE
)
actionManager.getAction(SettingsAction.SETTING)
?.actionPerformed(AnActionEvent(this, StringUtils.EMPTY, EventObject(this)))
return
}
}
val temporary = Application.getTemporaryDir().toPath()
for (file in files) {
val dir = Files.createTempDirectory(temporary, "termora-")
val path = Paths.get(dir.absolutePathString(), file.fileName)
transportManager.addTransport(
transport = FileTransport(
name = file.fileName,
source = file.path,
target = path,
sourceHolder = this,
targetHolder = this,
listener = editFileTransportListener(file.path, path)
)
)
}
}
private fun editFileTransportListener(source: Path, localPath: Path): TransportListener {
return object : TransportListener {
override fun onTransportChanged(transport: Transport) {
// 传输成功
if (transport.state == TransportState.Done) {
val transportManager = evt.getData(TransportDataProviders.TransportManager) ?: return
var lastModifiedTime = localPath.getLastModifiedTime().toMillis()
try {
if (sftp.editCommand.isNotBlank()) {
ProcessBuilder(
parseCommand(
MessageFormat.format(
sftp.editCommand,
localPath.absolutePathString()
)
)
).start()
} else if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start()
} else if (SystemInfo.isWindows) {
ProcessBuilder("notepad", localPath.absolutePathString()).start()
} else {
return
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
return
}
coroutineScope.launch(Dispatchers.IO) {
while (coroutineScope.isActive) {
try {
if (!Files.exists(localPath)) {
break
}
val nowModifiedTime = localPath.getLastModifiedTime().toMillis()
if (nowModifiedTime != lastModifiedTime) {
lastModifiedTime = nowModifiedTime
withContext(Dispatchers.Swing) {
// upload
transportManager.addTransport(
transport = FileTransport(
name = PathUtils.getFileNameString(localPath.fileName),
source = localPath,
target = source,
sourceHolder = this@FileSystemPanel,
targetHolder = this@FileSystemPanel,
)
)
}
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
break
}
delay(250.milliseconds)
}
}
}
}
fun parseCommand(command: String): List<String> {
val result = mutableListOf<String>()
val matcher = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(command)
while (matcher.find()) {
if (matcher.group(1) != null) {
result.add(matcher.group(1)) // 处理双引号部分
} else {
result.add(matcher.group(2).replace("\\\\ ", " "))
}
}
return result
}
}
}
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
private fun renamePath(path: Path) { private fun renamePath(path: Path) {
@@ -789,17 +932,31 @@ class FileSystemPanel(
private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) { private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) {
if (paths.isEmpty()) return if (paths.isEmpty()) return
val transportPanel = evt.getData(TransportDataProviders.TransportPanel) ?: return
val listeners = listenerList.getListeners(FileSystemTransportListener::class.java) val leftFileSystemPanel = evt.getData(TransportDataProviders.LeftFileSystemPanel) ?: return
if (listeners.isEmpty()) return val rightFileSystemPanel = evt.getData(TransportDataProviders.RightFileSystemPanel) ?: return
val sourceFileSystemPanel = this
val targetFileSystemPanel = if (this == leftFileSystemPanel) rightFileSystemPanel else leftFileSystemPanel
// 收集数据 // 收集数据
for (e in paths) { for (e in paths) {
if (!e.isDirectory) { if (!e.isDirectory) {
val job = TransportJob(
fileSystemPanel = this,
workdir = workdir,
isDirectory = false,
path = e.path,
)
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, false, e.path) } transportPanel.transport(
sourceWorkdir = workdir,
targetWorkdir = targetFileSystemPanel.workdir,
isSourceDirectory = false,
sourcePath = e.path,
sourceHolder = sourceFileSystemPanel,
targetHolder = targetFileSystemPanel
)
} }
continue continue
} }
@@ -811,12 +968,26 @@ class FileSystemPanel(
val isDirectory = if (path.attributes != null) val isDirectory = if (path.attributes != null)
path.attributes.isDirectory else path.isDirectory() path.attributes.isDirectory else path.isDirectory()
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) } transportPanel.transport(
sourceWorkdir = workdir,
targetWorkdir = targetFileSystemPanel.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = sourceFileSystemPanel,
targetHolder = targetFileSystemPanel
)
} }
} else { } else {
val isDirectory = path.isDirectory() val isDirectory = path.isDirectory()
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) } transportPanel.transport(
sourceWorkdir = workdir,
targetWorkdir = targetFileSystemPanel.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = sourceFileSystemPanel,
targetHolder = targetFileSystemPanel
)
} }
} }
} }

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
package app.termora.transport package app.termora.transport
import app.termora.Icons import app.termora.*
import app.termora.SFTPTerminalTab
import app.termora.actions.AnAction import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders import app.termora.actions.DataProviders
@@ -9,15 +8,71 @@ import app.termora.actions.DataProviders
class SFTPAction : AnAction("SFTP", Icons.folder) { class SFTPAction : AnAction("SFTP", Icons.folder) {
override fun actionPerformed(evt: AnActionEvent) { override fun actionPerformed(evt: AnActionEvent) {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val selectedTerminalTab = terminalTabbedManager.getSelectedTerminalTab()
val host = if (selectedTerminalTab is SSHTerminalTab || selectedTerminalTab is SFTPPtyTerminalTab)
selectedTerminalTab.host else null
val tab = openOrCreateSFTPTerminalTab(evt) ?: return
if (host != null) {
connectHost(host.copy(protocol = Protocol.SSH), tab)
}
}
/**
* 打开一个已经存在或者创建一个 SFTP Tab
*
* @return null 表示当前条件下无法创建
*/
fun openOrCreateSFTPTerminalTab(evt: AnActionEvent): SFTPTerminalTab? {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return null
val tabs = terminalTabbedManager.getTerminalTabs() val tabs = terminalTabbedManager.getTerminalTabs()
for (tab in tabs) { for (tab in tabs) {
if (tab is SFTPTerminalTab) { if (tab is SFTPTerminalTab) {
terminalTabbedManager.setSelectedTerminalTab(tab) terminalTabbedManager.setSelectedTerminalTab(tab)
return return tab
} }
} }
// 创建一个新的 // 创建一个新的
terminalTabbedManager.addTerminalTab(SFTPTerminalTab()) val tab = SFTPTerminalTab()
terminalTabbedManager.addTerminalTab(tab)
return tab
}
/**
* 如果当前选中的是 SSH 服务器 Tab那么直接打开 SFTP 通道
*/
fun connectHost(host: Host, tab: SFTPTerminalTab) {
val tabbed = tab.getData(TransportDataProviders.TransportPanel)
?.getData(TransportDataProviders.RightFileSystemTabbed) ?: return
// 如果已经有对应的连接
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getComponentAt(i)
if (c is SftpFileSystemPanel) {
if (c.host == host) {
tabbed.selectedIndex = i
return
}
}
}
// 寻找空的 Tab如果有则占用
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getComponentAt(i)
if (c is SftpFileSystemPanel) {
if (c.host == null) {
c.host = host
c.connect()
tabbed.selectedIndex = i
return
}
}
}
// 开启一个新的
tabbed.addTab(host.name, SftpFileSystemPanel(host).apply { connect() })
} }
} }

View File

@@ -21,15 +21,12 @@ import org.slf4j.LoggerFactory
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.CardLayout import java.awt.CardLayout
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.* import javax.swing.*
class SftpFileSystemPanel( class SftpFileSystemPanel(
private val transportManager: TransportManager, var host: Host? = null
private var host: Host? = null ) : JPanel(BorderLayout()), Disposable {
) : JPanel(BorderLayout()), Disposable,
FileSystemTransportListener.Provider {
companion object { companion object {
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java) private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java)
@@ -50,7 +47,6 @@ class SftpFileSystemPanel(
private val connectingPanel = ConnectingPanel() private val connectingPanel = ConnectingPanel()
private val selectHostPanel = SelectHostPanel() private val selectHostPanel = SelectHostPanel()
private val connectFailedPanel = ConnectFailedPanel() private val connectFailedPanel = ConnectFailedPanel()
private val listeners = mutableListOf<FileSystemTransportListener>()
private val isDisposed = AtomicBoolean(false) private val isDisposed = AtomicBoolean(false)
private var client: SshClient? = null private var client: SshClient? = null
@@ -108,15 +104,28 @@ class SftpFileSystemPanel(
private suspend fun doConnect() { private suspend fun doConnect() {
val host = this.host ?: return val thisHost = this.host ?: return
var host = thisHost.copy(authentication = thisHost.authentication.copy())
closeIO() closeIO()
try { try {
val client = SshClients.openClient(host).apply { client = this } val client = SshClients.openClient(host).apply { client = this }
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
client.userInteraction = val owner = SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel)
TerminalUserInteraction(SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel)) client.userInteraction = TerminalUserInteraction(owner)
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// 弹出授权框
if (host.authentication.type == AuthenticationType.No) {
val dialog = RequestAuthenticationDialog(owner)
val authentication = dialog.getAuthentication()
host = host.copy(authentication = authentication)
// save
if (dialog.isRemembered()) {
HostManager.getInstance()
.addHost(host.copy(authentication = authentication))
}
}
} }
val session = SshClients.openSession(host, client).apply { session = this } val session = SshClients.openSession(host, client).apply { session = this }
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session) fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
@@ -135,17 +144,7 @@ class SftpFileSystemPanel(
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
state = State.Connected state = State.Connected
val fileSystemPanel = FileSystemPanel(fileSystem, transportManager, host) val fileSystemPanel = FileSystemPanel(fileSystem, host)
fileSystemPanel.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(
fileSystemPanel: FileSystemPanel,
workdir: Path,
isDirectory: Boolean,
path: Path
) {
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
}
})
cardPanel.add(fileSystemPanel, State.Connected.name) cardPanel.add(fileSystemPanel, State.Connected.name)
cardLayout.show(cardPanel, State.Connected.name) cardLayout.show(cardPanel, State.Connected.name)
@@ -311,11 +310,4 @@ class SftpFileSystemPanel(
} }
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.add(listener)
}
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
listeners.remove(listener)
}
} }

View File

@@ -2,6 +2,7 @@ package app.termora.transport
import app.termora.Disposable import app.termora.Disposable
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.ObjectUtils
import org.apache.commons.net.io.CopyStreamEvent import org.apache.commons.net.io.CopyStreamEvent
import org.apache.commons.net.io.CopyStreamListener import org.apache.commons.net.io.CopyStreamListener
import org.apache.commons.net.io.Util import org.apache.commons.net.io.Util
@@ -31,10 +32,15 @@ abstract class Transport(
val target: Path, val target: Path,
val sourceHolder: Disposable, val sourceHolder: Disposable,
val targetHolder: Disposable, val targetHolder: Disposable,
val listener: TransportListener = TransportListener.EMPTY
) : Disposable, Runnable { ) : Disposable, Runnable {
private val listeners = ArrayList<TransportListener>() private val listeners = ArrayList<TransportListener>()
init {
listeners.add(listener)
}
@Volatile @Volatile
var state = TransportState.Waiting var state = TransportState.Waiting
protected set(value) { protected set(value) {
@@ -100,7 +106,10 @@ abstract class Transport(
if (fileSystem is SftpFileSystem) { if (fileSystem is SftpFileSystem) {
val clientSession = fileSystem.session val clientSession = fileSystem.session
if (clientSession is JGitClientSession) { if (clientSession is JGitClientSession) {
return clientSession.hostConfigEntry.host return ObjectUtils.defaultIfNull(
clientSession.hostConfigEntry.host,
clientSession.hostConfigEntry.hostName
)
} }
} }
return "file" return "file"
@@ -142,9 +151,9 @@ private class SlidingWindowByteCounter {
*/ */
class FileTransport( class FileTransport(
name: String, source: Path, target: Path, name: String, source: Path, target: Path,
sourceHolder: Disposable, targetHolder: Disposable, sourceHolder: Disposable, targetHolder: Disposable, listener: TransportListener = TransportListener.EMPTY
) : Transport( ) : Transport(
name, source, target, sourceHolder, targetHolder, name, source, target, sourceHolder, targetHolder, listener
), CopyStreamListener { ), CopyStreamListener {
companion object { companion object {

View File

@@ -0,0 +1,27 @@
package app.termora.transport
import java.nio.file.Path
data class TransportJob(
/**
* 发起方
*/
val fileSystemPanel: FileSystemPanel,
/**
* 发起方工作目录
*/
val workdir: Path,
/**
* 要传输的文件是否是文件夹
*/
val isDirectory: Boolean,
/**
* 要传输的文件/文件夹
*/
val path: Path,
/**
* 监听
*/
val listener: TransportListener? = null
)

View File

@@ -3,18 +3,33 @@ package app.termora.transport
import java.util.* import java.util.*
interface TransportListener : EventListener { interface TransportListener : EventListener {
companion object {
val EMPTY = object : TransportListener {
override fun onTransportAdded(transport: Transport) {
}
override fun onTransportRemoved(transport: Transport) {
}
override fun onTransportChanged(transport: Transport) {
}
}
}
/** /**
* Added * Added
*/ */
fun onTransportAdded(transport: Transport) fun onTransportAdded(transport: Transport){}
/** /**
* Removed * Removed
*/ */
fun onTransportRemoved(transport: Transport) fun onTransportRemoved(transport: Transport){}
/** /**
* 状态变化 * 状态变化
*/ */
fun onTransportChanged(transport: Transport) fun onTransportChanged(transport: Transport){}
} }

View File

@@ -107,32 +107,6 @@ class TransportPanel : JPanel(BorderLayout()), Disposable, DataProvider {
}) })
leftFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
val target = rightFileSystemTabbed.getSelectedFileSystemPanel() ?: return
transport(
fileSystemPanel.workdir, target.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = fileSystemPanel,
targetHolder = target,
)
}
})
rightFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
val target = leftFileSystemTabbed.getSelectedFileSystemPanel() ?: return
transport(
fileSystemPanel.workdir, target.workdir,
isSourceDirectory = isDirectory,
sourcePath = path,
sourceHolder = fileSystemPanel,
targetHolder = target,
)
}
})
} }
fun transport( fun transport(

View File

@@ -38,6 +38,9 @@ termora.doorman.mnemonic.title=Enter 12 mnemonic words
termora.doorman.mnemonic.incorrect=Incorrect mnemonic termora.doorman.mnemonic.incorrect=Incorrect mnemonic
# Hosts
termora.host.verify-server-key=Host [{0}] key has been changed<br/><br/>{1} key fingerprint is {2}<br/><br/>Are you sure you want to continue connecting?
termora.host.modified-server-key=HOST [{0}] IDENTIFICATION HAS CHANGED<br/><br/>Expected: {1} key fingerprint is {2}<br/><br/>Actual: {3} key fingerprint is {4}<br/><br/>Are you sure you want to continue connecting?
# Settings # Settings
@@ -67,6 +70,7 @@ termora.settings.terminal.beep=Beep
termora.settings.terminal.select-copy=Select copy termora.settings.terminal.select-copy=Select copy
termora.settings.terminal.cursor-style=Cursor type termora.settings.terminal.cursor-style=Cursor type
termora.settings.terminal.local-shell=Local shell termora.settings.terminal.local-shell=Local shell
termora.settings.terminal.floating-toolbar=Floating Toolbar
termora.settings.terminal.auto-close-tab=Auto Close Tab termora.settings.terminal.auto-close-tab=Auto Close Tab
termora.settings.terminal.auto-close-tab-description=Automatically close the tab when the terminal is disconnected normally termora.settings.terminal.auto-close-tab-description=Automatically close the tab when the terminal is disconnected normally
@@ -80,6 +84,7 @@ termora.settings.sync.import=${termora.keymgr.import}
termora.settings.sync.import.file-too-large=The file is too large termora.settings.sync.import.file-too-large=The file is too large
termora.settings.sync.import.successful=Import data successfully termora.settings.sync.import.successful=Import data successfully
termora.settings.sync.export-done=The export was successful termora.settings.sync.export-done=The export was successful
termora.settings.sync.export-encrypt=Enter password to encrypt file (optional)
termora.settings.sync.export-done-open-folder=The export was successful. Do you want to open the folder? termora.settings.sync.export-done-open-folder=The export was successful. Do you want to open the folder?
termora.settings.sync.range=Range termora.settings.sync.range=Range
termora.settings.sync.range.keys=My keys termora.settings.sync.range.keys=My keys
@@ -103,6 +108,9 @@ termora.settings.keymap.action=Action
termora.settings.keymap.already-exists=The shortcut [{0}] is already in use by [{1}] termora.settings.keymap.already-exists=The shortcut [{0}] is already in use by [{1}]
termora.settings.sftp.edit-command=Edit Command
termora.settings.restart.title=Restart termora.settings.restart.title=Restart
termora.settings.restart.message=Changes will take effect after restarting the application termora.settings.restart.message=Changes will take effect after restarting the application
@@ -120,7 +128,8 @@ termora.find-everywhere.double-shift-deprecated-instead=${termora.find-everywher
# Welcome # Welcome
termora.welcome.my-hosts=My hosts termora.welcome.my-hosts=My hosts
termora.welcome.contextmenu.open=Open termora.welcome.contextmenu.connect=Connect
termora.welcome.contextmenu.connect-with=Connect with
termora.welcome.contextmenu.open-in-new-window=${termora.tabbed.contextmenu.open-in-new-window} termora.welcome.contextmenu.open-in-new-window=${termora.tabbed.contextmenu.open-in-new-window}
termora.welcome.contextmenu.copy=${termora.copy} termora.welcome.contextmenu.copy=${termora.copy}
termora.welcome.contextmenu.remove=${termora.remove} termora.welcome.contextmenu.remove=${termora.remove}
@@ -132,6 +141,7 @@ termora.welcome.contextmenu.new.folder=${termora.folder}
termora.welcome.contextmenu.new.host=Host termora.welcome.contextmenu.new.host=Host
termora.welcome.contextmenu.new.folder.name=New Folder termora.welcome.contextmenu.new.folder.name=New Folder
termora.welcome.contextmenu.property=Properties termora.welcome.contextmenu.property=Properties
termora.welcome.contextmenu.show-more-info=Show more info
# New Host # New Host
termora.new-host.title=Create a new host termora.new-host.title=Create a new host
@@ -196,6 +206,8 @@ termora.keymgr.ssh-copy-id.end=End of public key copying
# Tabbed # Tabbed
termora.tabbed.contextmenu.rename=Rename termora.tabbed.contextmenu.rename=Rename
termora.tabbed.contextmenu.sftp-command=SFTP Command
termora.tabbed.contextmenu.sftp-not-install=SFTP programme not found, please install and try again
termora.tabbed.contextmenu.clone=Clone termora.tabbed.contextmenu.clone=Clone
termora.tabbed.contextmenu.open-in-new-window=Open in New Window termora.tabbed.contextmenu.open-in-new-window=Open in New Window
termora.tabbed.contextmenu.close=Close termora.tabbed.contextmenu.close=Close
@@ -255,6 +267,8 @@ termora.transport.table.owner=Owner
# contextmenu # contextmenu
termora.transport.table.contextmenu.transfer=Transfer termora.transport.table.contextmenu.transfer=Transfer
termora.transport.table.contextmenu.edit=${termora.keymgr.edit}
termora.transport.table.contextmenu.edit-command=You must configure the "Edit Command" in "Settings - SFTP" before you can edit the file
termora.transport.table.contextmenu.copy-path=Copy Path termora.transport.table.contextmenu.copy-path=Copy Path
termora.transport.table.contextmenu.open-in-folder=Open in {0} termora.transport.table.contextmenu.open-in-folder=Open in {0}
termora.transport.table.contextmenu.rename=${termora.welcome.contextmenu.rename} termora.transport.table.contextmenu.rename=${termora.welcome.contextmenu.rename}
@@ -320,6 +334,7 @@ termora.actions.zoom-reset-terminal=Reset Terminal Zoom
termora.actions.open-local-terminal=Open Local Terminal termora.actions.open-local-terminal=Open Local Terminal
termora.actions.open-find-everywhere=Open FindEverywhere termora.actions.open-find-everywhere=Open FindEverywhere
termora.actions.open-new-window=Open new Window termora.actions.open-new-window=Open new Window
termora.actions.clear-screen=Clear Terminal Screen
termora.actions.switch-tab=Switch to specific Tab [1..9] termora.actions.switch-tab=Switch to specific Tab [1..9]
# Terminal # Terminal

View File

@@ -36,6 +36,11 @@ termora.doorman.mnemonic.title=输入 12 个助记词
termora.doorman.mnemonic.incorrect=助记词错误 termora.doorman.mnemonic.incorrect=助记词错误
# Hosts
termora.host.verify-server-key=主机 [{0}] 密钥已经改变<br/><br/>{1} 的指纹 {2}<br/><br/>你确定要继续连接吗?
termora.host.modified-server-key=主机 [{0}] 身份已发生变化<br/><br/>期待: {1} 的指纹 {2}<br/><br/>实际: {3} 的指纹 {4}<br/><br/>你确定要继续连接吗?
termora.setting=设置 termora.setting=设置
termora.settings.restart.title=重启 termora.settings.restart.title=重启
termora.settings.restart.message=设置修改将在重启后生效 termora.settings.restart.message=设置修改将在重启后生效
@@ -72,6 +77,7 @@ termora.settings.terminal.beep=蜂鸣声
termora.settings.terminal.select-copy=选中复制 termora.settings.terminal.select-copy=选中复制
termora.settings.terminal.cursor-style=光标样式 termora.settings.terminal.cursor-style=光标样式
termora.settings.terminal.local-shell=本地终端 termora.settings.terminal.local-shell=本地终端
termora.settings.terminal.floating-toolbar=悬浮工具栏
termora.settings.terminal.auto-close-tab=自动关闭标签 termora.settings.terminal.auto-close-tab=自动关闭标签
termora.settings.terminal.auto-close-tab-description=当终端正常断开连接时自动关闭标签页 termora.settings.terminal.auto-close-tab-description=当终端正常断开连接时自动关闭标签页
@@ -81,6 +87,7 @@ termora.settings.sync.push=推送
termora.settings.sync.push-warning=推送将覆盖已有配置,建议先拉取再推送 termora.settings.sync.push-warning=推送将覆盖已有配置,建议先拉取再推送
termora.settings.sync.pull=拉取 termora.settings.sync.pull=拉取
termora.settings.sync.export-done=导出成功 termora.settings.sync.export-done=导出成功
termora.settings.sync.export-encrypt=输入密码加密文件 (可选)
termora.settings.sync.export-done-open-folder=导出成功,是否需要打开所在文件夹? termora.settings.sync.export-done-open-folder=导出成功,是否需要打开所在文件夹?
termora.settings.sync.range=范围 termora.settings.sync.range=范围
termora.settings.sync.range.keys=我的密钥 termora.settings.sync.range.keys=我的密钥
@@ -105,9 +112,14 @@ termora.settings.keymap.shortcut=快捷键
termora.settings.keymap.action=操作 termora.settings.keymap.action=操作
termora.settings.keymap.already-exists=快捷键 [{0}] 已经被 [{1}] 占用 termora.settings.keymap.already-exists=快捷键 [{0}] 已经被 [{1}] 占用
termora.settings.sftp.edit-command=编辑命令
# Welcome # Welcome
termora.welcome.my-hosts=我的主机 termora.welcome.my-hosts=我的主机
termora.welcome.contextmenu.open=打开 termora.welcome.contextmenu.connect=连接
termora.welcome.contextmenu.connect-with=连接到
termora.welcome.contextmenu.copy=${termora.copy} termora.welcome.contextmenu.copy=${termora.copy}
termora.welcome.contextmenu.remove=${termora.remove} termora.welcome.contextmenu.remove=${termora.remove}
termora.welcome.contextmenu.rename=重命名 termora.welcome.contextmenu.rename=重命名
@@ -118,6 +130,7 @@ termora.welcome.contextmenu.new.folder=文件夹
termora.welcome.contextmenu.new.host=主机 termora.welcome.contextmenu.new.host=主机
termora.welcome.contextmenu.new.folder.name=新建文件夹 termora.welcome.contextmenu.new.folder.name=新建文件夹
termora.welcome.contextmenu.property=属性 termora.welcome.contextmenu.property=属性
termora.welcome.contextmenu.show-more-info=显示更多信息
# New Host # New Host
termora.new-host.title=新建主机 termora.new-host.title=新建主机
@@ -185,6 +198,8 @@ termora.tools.multiple=将命令发送到所有会话
# Tabbed # Tabbed
termora.tabbed.contextmenu.rename=重命名 termora.tabbed.contextmenu.rename=重命名
termora.tabbed.contextmenu.sftp-command=SFTP 终端
termora.tabbed.contextmenu.sftp-not-install=没有找到 SFTP 程序,请安装后重试
termora.tabbed.contextmenu.clone=克隆 termora.tabbed.contextmenu.clone=克隆
termora.tabbed.contextmenu.open-in-new-window=在新窗口打开 termora.tabbed.contextmenu.open-in-new-window=在新窗口打开
termora.tabbed.contextmenu.close=关闭 termora.tabbed.contextmenu.close=关闭
@@ -245,6 +260,7 @@ termora.transport.table.owner=所有者
# contextmenu # contextmenu
termora.transport.table.contextmenu.transfer=传输 termora.transport.table.contextmenu.transfer=传输
termora.transport.table.contextmenu.copy-path=复制路径 termora.transport.table.contextmenu.copy-path=复制路径
termora.transport.table.contextmenu.edit-command=你必须在 “设置 - SFTP” 中配置 “编辑命令” 后才能编辑文件
termora.transport.table.contextmenu.open-in-folder=在{0}中打开 termora.transport.table.contextmenu.open-in-folder=在{0}中打开
termora.transport.table.contextmenu.change-permissions=更改权限... termora.transport.table.contextmenu.change-permissions=更改权限...
termora.transport.table.contextmenu.refresh=刷新 termora.transport.table.contextmenu.refresh=刷新
@@ -310,6 +326,7 @@ termora.actions.zoom-reset-terminal=重置终端缩放
termora.actions.open-local-terminal=打开本地终端 termora.actions.open-local-terminal=打开本地终端
termora.actions.open-find-everywhere=打开全局查找 termora.actions.open-find-everywhere=打开全局查找
termora.actions.open-new-window=打开新窗口 termora.actions.open-new-window=打开新窗口
termora.actions.clear-screen=清除终端屏幕
termora.actions.switch-tab=切换到特定标签页 [1..9] termora.actions.switch-tab=切换到特定标签页 [1..9]
# zmodem # zmodem

View File

@@ -35,6 +35,13 @@ termora.doorman.mnemonic-data-corrupted=無法從助記詞解密數據,資料
termora.doorman.mnemonic.title=輸入 12 個助記詞 termora.doorman.mnemonic.title=輸入 12 個助記詞
termora.doorman.mnemonic.incorrect=助記詞錯誤 termora.doorman.mnemonic.incorrect=助記詞錯誤
# Hosts
termora.host.verify-server-key=主機 [{0}] 金鑰已經改變<br/><br/>{1} 的指紋 {2}<br/><br/>你確定要繼續連線嗎?
termora.host.modified-server-key=主機 [{0}] 身分已變更<br/><br/>期待: {1} 的指紋 {2}<br/><br/>實際: {3} 的指紋 {4}<br/><br/>你確定要繼續連線嗎?
termora.setting=設定 termora.setting=設定
termora.settings.restart.title=重啟 termora.settings.restart.title=重啟
termora.settings.restart.message=設定修改將在重新啟動後生效 termora.settings.restart.message=設定修改將在重新啟動後生效
@@ -55,6 +62,8 @@ termora.settings.keymap.shortcut=快捷鍵
termora.settings.keymap.action=操作 termora.settings.keymap.action=操作
termora.settings.keymap.already-exists=快捷鍵 [{0}] 已經被 [{1}] 占用 termora.settings.keymap.already-exists=快捷鍵 [{0}] 已經被 [{1}] 占用
termora.settings.sftp.edit-command=編輯命令
# Find everywhere # Find everywhere
termora.find-everywhere=尋找 termora.find-everywhere=尋找
@@ -77,6 +86,7 @@ termora.settings.terminal.beep=蜂鳴聲
termora.settings.terminal.select-copy=選取複製 termora.settings.terminal.select-copy=選取複製
termora.settings.terminal.cursor-style=遊標風格 termora.settings.terminal.cursor-style=遊標風格
termora.settings.terminal.local-shell=本地端 termora.settings.terminal.local-shell=本地端
termora.settings.terminal.floating-toolbar=懸浮工具列
termora.settings.terminal.auto-close-tab=自動關閉標籤 termora.settings.terminal.auto-close-tab=自動關閉標籤
termora.settings.terminal.auto-close-tab-description=當終端正常斷開連線時自動關閉標籤頁 termora.settings.terminal.auto-close-tab-description=當終端正常斷開連線時自動關閉標籤頁
@@ -85,6 +95,7 @@ termora.settings.sync.push=推送
termora.settings.sync.push-warning=推送將覆蓋先前的配置,建議先拉取再推送 termora.settings.sync.push-warning=推送將覆蓋先前的配置,建議先拉取再推送
termora.settings.sync.pull=拉取 termora.settings.sync.pull=拉取
termora.settings.sync.export-done=匯出成功 termora.settings.sync.export-done=匯出成功
termora.settings.sync.export-encrypt=輸入密碼加密檔案 (可選)
termora.settings.sync.export-done-open-folder=匯出成功,是否需要打開所在資料夾? termora.settings.sync.export-done-open-folder=匯出成功,是否需要打開所在資料夾?
termora.settings.sync.range=範圍 termora.settings.sync.range=範圍
termora.settings.sync.range.keys=我的密鑰 termora.settings.sync.range.keys=我的密鑰
@@ -106,7 +117,8 @@ termora.settings.about.termora=<html><b>${termora.title}</b> ({0}) 是一個跨
# Welcome # Welcome
termora.welcome.my-hosts=我的主機 termora.welcome.my-hosts=我的主機
termora.welcome.contextmenu.open=打開 termora.welcome.contextmenu.connect=連接
termora.welcome.contextmenu.connect-with=連接到
termora.welcome.contextmenu.copy=複製 termora.welcome.contextmenu.copy=複製
termora.welcome.contextmenu.remove=${termora.remove} termora.welcome.contextmenu.remove=${termora.remove}
termora.welcome.contextmenu.rename=重新命名 termora.welcome.contextmenu.rename=重新命名
@@ -117,6 +129,7 @@ termora.welcome.contextmenu.new.folder=${termora.folder}
termora.welcome.contextmenu.new.host=主機 termora.welcome.contextmenu.new.host=主機
termora.welcome.contextmenu.new.folder.name=新建資料夾 termora.welcome.contextmenu.new.folder.name=新建資料夾
termora.welcome.contextmenu.property=屬性 termora.welcome.contextmenu.property=屬性
termora.welcome.contextmenu.show-more-info=顯示更多信息
# New Host # New Host
termora.new-host.title=新主機 termora.new-host.title=新主機
@@ -181,6 +194,8 @@ termora.tools.multiple=將指令傳送到所有會話
# Tabbed # Tabbed
termora.tabbed.contextmenu.rename=重新命名 termora.tabbed.contextmenu.rename=重新命名
termora.tabbed.contextmenu.sftp-command=SFTP 終端
termora.tabbed.contextmenu.sftp-not-install=沒有找到 SFTP 程序,請安裝後重試
termora.tabbed.contextmenu.clone=克隆 termora.tabbed.contextmenu.clone=克隆
termora.tabbed.contextmenu.open-in-new-window=在新視窗打開 termora.tabbed.contextmenu.open-in-new-window=在新視窗打開
termora.tabbed.contextmenu.close=關閉 termora.tabbed.contextmenu.close=關閉
@@ -239,6 +254,7 @@ termora.transport.table.owner=所有者
# contextmenu # contextmenu
termora.transport.table.contextmenu.transfer=傳輸 termora.transport.table.contextmenu.transfer=傳輸
termora.transport.table.contextmenu.copy-path=複製路徑 termora.transport.table.contextmenu.copy-path=複製路徑
termora.transport.table.contextmenu.edit-command=你必須在 “設定 - SFTP” 中設定 “編輯指令” 後才能編輯文件
termora.transport.table.contextmenu.open-in-folder=在{0}中打開 termora.transport.table.contextmenu.open-in-folder=在{0}中打開
termora.transport.table.contextmenu.change-permissions=更改權限... termora.transport.table.contextmenu.change-permissions=更改權限...
termora.transport.table.contextmenu.refresh=刷新 termora.transport.table.contextmenu.refresh=刷新
@@ -291,6 +307,7 @@ termora.actions.zoom-reset-terminal=重置終端縮放
termora.actions.open-local-terminal=開啟本地終端 termora.actions.open-local-terminal=開啟本地終端
termora.actions.open-find-everywhere=開啟全域搜尋 termora.actions.open-find-everywhere=開啟全域搜尋
termora.actions.open-new-window=開啟新視窗 termora.actions.open-new-window=開啟新視窗
termora.actions.clear-screen=清除終端機螢幕
termora.actions.switch-tab=切換到特定分頁 [1..9] termora.actions.switch-tab=切換到特定分頁 [1..9]

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.4939 4.48784C11.3002 4.28007 10.9724 4.27548 10.7729 4.47775L8.00074 7.28849L5.22871 4.47788C5.02922 4.27561 4.70143 4.2802 4.50768 4.48797C4.32506 4.68382 4.32933 4.98882 4.51736 5.17947L7.29908 7.99991L4.51756 10.8201C4.32953 11.0108 4.32526 11.3158 4.50788 11.5116C4.70163 11.7194 5.02942 11.724 5.22892 11.5217L8.00074 8.71133L10.7727 11.5219C10.9722 11.7241 11.3 11.7196 11.4937 11.5118C11.6764 11.3159 11.6721 11.0109 11.484 10.8203L8.7024 7.99991L11.4843 5.17934C11.6723 4.98869 11.6766 4.68368 11.4939 4.48784Z"
fill="#A8ADBD"/>
</svg>

After

Width:  |  Height:  |  Size: 844 B

View File

@@ -0,0 +1,7 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle opacity="0.1" cx="8" cy="8" r="8" fill="#313547"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.4939 4.48784C11.3002 4.28007 10.9724 4.27548 10.7729 4.47775L8.00074 7.28849L5.22871 4.47788C5.02922 4.27561 4.70143 4.2802 4.50768 4.48797C4.32506 4.68382 4.32933 4.98882 4.51736 5.17947L7.29908 7.99991L4.51756 10.8201C4.32953 11.0108 4.32526 11.3158 4.50788 11.5116C4.70163 11.7194 5.02942 11.724 5.22892 11.5217L8.00074 8.71133L10.7727 11.5219C10.9722 11.7241 11.3 11.7196 11.4937 11.5118C11.6764 11.3159 11.6721 11.0109 11.484 10.8203L8.7024 7.99991L11.4843 5.17934C11.6723 4.98869 11.6766 4.68368 11.4939 4.48784Z"
fill="#818594"/>
</svg>

After

Width:  |  Height:  |  Size: 907 B

View File

@@ -0,0 +1,7 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle opacity="0.13" cx="8" cy="8" r="8" fill="#F0F1F2"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.4939 4.48784C11.3002 4.28007 10.9724 4.27548 10.7729 4.47775L8.00074 7.28849L5.22871 4.47788C5.02922 4.27561 4.70143 4.2802 4.50768 4.48797C4.32506 4.68382 4.32933 4.98882 4.51736 5.17947L7.29908 7.99991L4.51756 10.8201C4.32953 11.0108 4.32526 11.3158 4.50788 11.5116C4.70163 11.7194 5.02942 11.724 5.22892 11.5217L8.00074 8.71133L10.7727 11.5219C10.9722 11.7241 11.3 11.7196 11.4937 11.5118C11.6764 11.3159 11.6721 11.0109 11.484 10.8203L8.7024 7.99991L11.4843 5.17934C11.6723 4.98869 11.6766 4.68368 11.4939 4.48784Z"
fill="#868A91"/>
</svg>

After

Width:  |  Height:  |  Size: 908 B

View File

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.4939 4.48784C11.3002 4.28007 10.9724 4.27548 10.7729 4.47775L8.00074 7.28849L5.22871 4.47788C5.02922 4.27561 4.70143 4.2802 4.50768 4.48797C4.32506 4.68382 4.32933 4.98882 4.51736 5.17947L7.29908 7.99991L4.51756 10.8201C4.32953 11.0108 4.32526 11.3158 4.50788 11.5116C4.70163 11.7194 5.02942 11.724 5.22892 11.5217L8.00074 8.71133L10.7727 11.5219C10.9722 11.7241 11.3 11.7196 11.4937 11.5118C11.6764 11.3159 11.6721 11.0109 11.484 10.8203L8.7024 7.99991L11.4843 5.17934C11.6723 4.98869 11.6766 4.68368 11.4939 4.48784Z"
fill="#6F737A"/>
</svg>

After

Width:  |  Height:  |  Size: 844 B

View File

@@ -0,0 +1,5 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 15.5V9.5M9.90193 14L15.0981 11M15.0981 14L9.90192 11" stroke="#6C707E" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 5.41421V13C3 14.1046 3.89543 15 5 15H8.75777C8.55127 14.6915 8.3819 14.356 8.25606 14H5C4.44772 14 4 13.5523 4 13L4 6H6C7.10457 6 8 5.10457 8 4V2L11 2C11.5523 2 12 2.44772 12 3V8.02746C12.1642 8.00932 12.331 8 12.5 8C12.669 8 12.8358 8.00932 13 8.02746V3C13 1.89543 12.1046 1 11 1H7.41421C7.149 1 6.89464 1.10536 6.70711 1.29289L3.29289 4.70711C3.10536 4.89464 3 5.149 3 5.41421ZM7 2.41421L4.41421 5H6C6.55228 5 7 4.55228 7 4V2.41421Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 846 B

View File

@@ -0,0 +1,5 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 15.5V9.5M9.90193 14L15.0981 11M15.0981 14L9.90192 11" stroke="#CED0D6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 5.41421V13C3 14.1046 3.89543 15 5 15H8.75777C8.55127 14.6915 8.3819 14.356 8.25606 14H5C4.44772 14 4 13.5523 4 13L4 6H6C7.10457 6 8 5.10457 8 4V2L11 2C11.5523 2 12 2.44772 12 3V8.02746C12.1642 8.00932 12.331 8 12.5 8C12.669 8 12.8358 8.00932 13 8.02746V3C13 1.89543 12.1046 1 11 1H7.41421C7.149 1 6.89464 1.10536 6.70711 1.29289L3.29289 4.70711C3.10536 4.89464 3 5.149 3 5.41421ZM7 2.41421L4.41421 5H6C6.55228 5 7 4.55228 7 4V2.41421Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 846 B