Compare commits

...

32 Commits

Author SHA1 Message Date
hstyi
5050aa37f5 release: 2.0.0-beta.5 2025-07-07 09:47:35 +08:00
hstyi
53d3d96a06 chore: dynamically modify icons 2025-07-07 09:35:55 +08:00
hstyi
d40b8a4c9c fix: windows drive list failure 2025-07-06 16:48:50 +08:00
hstyi
728671509c feat: transfer support disconnection and reconnection 2025-07-06 16:16:43 +08:00
hstyi
b7178a30fb chore: telnet supports backspace key setting 2025-07-06 11:01:44 +08:00
hstyi
939d6a1fd7 chore: improve flatlaf 2025-07-05 16:02:56 +08:00
hstyi
2986a9cc46 fix: binary compatibility 2025-07-05 14:35:42 +08:00
hstyi
f36afaf5d3 chore: telnet default port 23 2025-07-05 14:07:10 +08:00
hstyi
8cec835583 feat: support telnet 2025-07-05 14:07:10 +08:00
hstyi
a32838dad6 feat: support clone session 2025-07-05 12:07:33 +08:00
hstyi
d54671757e fix: tab drag and drop 2025-07-05 10:17:57 +08:00
hstyi
d1dba56bcd chore: improve sidebar 2025-07-04 16:36:37 +08:00
hstyi
919c06779d chore: improve rm -rf 2025-07-04 15:32:51 +08:00
hstyi
1c90fb4e18 release: 2.0.0-beta.4 2025-07-04 14:35:50 +08:00
dependabot[bot]
c1f1d5185e chore(deps): bump com.fazecast:jSerialComm from 2.11.0 to 2.11.2
Bumps [com.fazecast:jSerialComm](https://github.com/Fazecast/jSerialComm) from 2.11.0 to 2.11.2.
- [Release notes](https://github.com/Fazecast/jSerialComm/releases)
- [Commits](https://github.com/Fazecast/jSerialComm/compare/v2.11.0...v2.11.2)

---
updated-dependencies:
- dependency-name: com.fazecast:jSerialComm
  dependency-version: 2.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 12:20:45 +08:00
dependabot[bot]
aa4863712d chore(deps): bump org.jetbrains.kotlinx:kotlinx-serialization-json
Bumps [org.jetbrains.kotlinx:kotlinx-serialization-json](https://github.com/Kotlin/kotlinx.serialization) from 1.8.1 to 1.9.0.
- [Release notes](https://github.com/Kotlin/kotlinx.serialization/releases)
- [Changelog](https://github.com/Kotlin/kotlinx.serialization/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Kotlin/kotlinx.serialization/compare/v1.8.1...v1.9.0)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlinx:kotlinx-serialization-json
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 12:20:34 +08:00
dependabot[bot]
247640f2e5 chore(deps): bump org.dom4j:dom4j from 2.1.4 to 2.2.0
Bumps [org.dom4j:dom4j](https://github.com/dom4j/dom4j) from 2.1.4 to 2.2.0.
- [Release notes](https://github.com/dom4j/dom4j/releases)
- [Commits](https://github.com/dom4j/dom4j/compare/version-2.1.4...version/2.2.0)

---
updated-dependencies:
- dependency-name: org.dom4j:dom4j
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 12:20:24 +08:00
dependabot[bot]
5b1f803fa8 chore(deps): bump com.aliyun.oss:aliyun-sdk-oss from 3.18.2 to 3.18.3
Bumps [com.aliyun.oss:aliyun-sdk-oss](https://github.com/aliyun/aliyun-oss-java-sdk) from 3.18.2 to 3.18.3.
- [Release notes](https://github.com/aliyun/aliyun-oss-java-sdk/releases)
- [Commits](https://github.com/aliyun/aliyun-oss-java-sdk/commits)

---
updated-dependencies:
- dependency-name: com.aliyun.oss:aliyun-sdk-oss
  dependency-version: 3.18.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 12:20:15 +08:00
hstyi
accf590c17 feat: support fallback font 2025-07-04 12:19:56 +08:00
hstyi
19fbeab817 fix: shortcut keys cannot be saved 2025-07-04 12:13:01 +08:00
hstyi
a785ab4680 chore: disable opengl 2025-07-04 09:58:08 +08:00
hstyi
5ee23cb379 fix: cursor style not working 2025-07-04 09:14:58 +08:00
hstyi
145d2de001 chore: tiny images 2025-07-03 15:54:38 +08:00
hstyi
8d3f5fe622 chore: README 2025-07-03 15:47:19 +08:00
hstyi
9ce4a88041 feat: use extension for floating toolbar 2025-07-03 12:00:02 +08:00
dependabot[bot]
c0ecc9fa7d chore(deps): bump exposed from 1.0.0-beta-2 to 1.0.0-beta-3
Bumps `exposed` from 1.0.0-beta-2 to 1.0.0-beta-3.

Updates `org.jetbrains.exposed:exposed-core` from 1.0.0-beta-2 to 1.0.0-beta-3
- [Release notes](https://github.com/JetBrains/Exposed/releases)
- [Changelog](https://github.com/JetBrains/Exposed/blob/main/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/Exposed/compare/1.0.0-beta-2...1.0.0-beta-3)

Updates `org.jetbrains.exposed:exposed-crypt` from 1.0.0-beta-2 to 1.0.0-beta-3
- [Release notes](https://github.com/JetBrains/Exposed/releases)
- [Changelog](https://github.com/JetBrains/Exposed/blob/main/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/Exposed/compare/1.0.0-beta-2...1.0.0-beta-3)

Updates `org.jetbrains.exposed:exposed-jdbc` from 1.0.0-beta-2 to 1.0.0-beta-3
- [Release notes](https://github.com/JetBrains/Exposed/releases)
- [Changelog](https://github.com/JetBrains/Exposed/blob/main/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/Exposed/compare/1.0.0-beta-2...1.0.0-beta-3)

Updates `org.jetbrains.exposed:exposed-migration` from 1.0.0-beta-2 to 1.0.0-beta-3
- [Release notes](https://github.com/JetBrains/Exposed/releases)
- [Changelog](https://github.com/JetBrains/Exposed/blob/main/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/Exposed/compare/1.0.0-beta-2...1.0.0-beta-3)

---
updated-dependencies:
- dependency-name: org.jetbrains.exposed:exposed-core
  dependency-version: 1.0.0-beta-3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.exposed:exposed-crypt
  dependency-version: 1.0.0-beta-3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.exposed:exposed-jdbc
  dependency-version: 1.0.0-beta-3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.exposed:exposed-migration
  dependency-version: 1.0.0-beta-3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-03 11:38:43 +08:00
hstyi
cb33a4468a chore: show close button 2025-07-03 10:48:15 +08:00
hstyi
168c4c5c64 chore: improve team sync 2025-07-03 08:49:00 +08:00
hstyi
9916edbd13 feat: support custom layout 2025-07-02 12:15:28 +08:00
hstyi
ab6b6a2127 chore: improve Windows task icon 2025-07-01 13:42:50 +08:00
hstyi
c45f5f4c92 fix: plugins compatibility 2025-07-01 13:21:53 +08:00
hstyi
92ee2d72f2 chore: only show flags on macOS 2025-07-01 12:03:27 +08:00
160 changed files with 3703 additions and 1173 deletions

127
README.md
View File

@@ -1,53 +1,102 @@
<div align="center">
<a href="./README.zh_CN.md">🇨🇳 简体中文</a>
<a href="./README.zh_CN.md">简体中文</a>
</div>
# Termora
**Termora** is a terminal emulator and SSH client for Windows, macOS and Linux.
**Termora** is a cross-platform terminal emulator and SSH client, available on **Windows, macOS, and Linux**.
<div align="center">
<img src="./docs/readme.png" alt="termora" />
<img src="docs/readme.png" alt="Readme" />
</div>
**Termora** is developed using [Kotlin/JVM](https://kotlinlang.org) and partially implements the [XTerm](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) protocol (with ongoing improvements). Its ultimate vision is to achieve full platform support (including Android, iOS, and iPadOS) through [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html).
## Features
- SSH and local terminal support
- Serial port protocol support
- [SFTP](./docs/sftp.png?raw=1) & [Command](./docs/sftp-command.png?raw=1) file transfer support
- Compatible with Windows, macOS, and Linux
- Zmodem protocol support
- SSH port forwarding & Jump hosts
- Support for X11 and SSH-Agent
- Terminal log
- Configuration synchronization via [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- Macro support (record and replay scripts)
- Keyword highlighting
- Key management
- Broadcast commands to multiple sessions
- [Find Everywhere](./docs/findeverywhere.png?raw=1) quick navigation
- Data encryption
- Support [plugins](https://www.termora.app/plugins)
- ...
## Download
- [Latest release](https://github.com/TermoraDev/termora/releases/latest)
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora`
- [WinGet](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/TermoraDev/Termora): `winget install termora`
## Development
It is recommended to use the [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) version of the JDK and run the program via `./gradlew :run` to run the program.
The program can be run via `./gradlew dist` to automatically build the local version. On macOS: `dmg`, on Windows: `zip`, on Linux: `tar.gz`.
Termora is developed using [**Kotlin/JVM**](https://kotlinlang.org/) and partially implements the [**XTerm control sequence protocol**](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html). Its long-term goal is to achieve **full platform support** (including Android, iOS, and iPadOS) via [**Kotlin Multiplatform**](https://kotlinlang.org/docs/multiplatform.html).
## LICENSE
## ✨ Features
- 🧬 Cross-platform support
- 🔐 Built-in key manager
- 🖼️ X11 forwarding
- 🧑‍💻 SSH-Agent integration
- 💻 System information display
- 📁 GUI-based SFTP file management
- 📊 Nvidia GPU usage monitoring
- ⚡ Quick command shortcuts
## 🚀 File Transfer
- Direct transfers between server A ↔ B
- Recursive folder support
- Up to **6 concurrent transfer tasks**
<div align="center">
<img src="docs/transfer.png" alt="Transfer" />
</div>
## 📝 File Editing
- Auto-upload after editing and saving
- Rename files and folders
- Quick deletion of large folders (`rm -rf` supported)
- Visual permission editing
- Create new files and folders
<div align="center">
<img src="docs/transfer-edit.png" alt="Transfer Edit" />
</div>
## 💻 Hosts
- Tree-like hierarchical structure, similar to folders
- Assign tags to individual hosts
- Import hosts from other tools
- Open with the transfer tool
<div align="center">
<img src="docs/host.png" alt="Transfer Edit" />
</div>
## 🧩 Plugins
- 🌍 Geo: Display geolocation of hosts
- 🔄 Sync: Sync settings to Gist or WebDAV
- 🗂️ WebDAV: Connect to WebDAV storage
- 📝 Editor: Built-in SFTP file editor
- 📡 SMB: Connect to [SMB](https://en.wikipedia.org/wiki/Server_Message_Block)
- ☁️ S3: Connect to S3 object storage
- ☁️ Huawei OBS: Connect to Huawei Cloud OBS
- ☁️ Tencent COS: Connect to Tencent Cloud COS
- ☁️ Alibaba OSS: Connect to Alibaba Cloud OSS
- 👉 [View all plugins...](https://www.termora.app/plugins)
## 📦 Download
- 🧾 [Latest Release](https://github.com/TermoraDev/termora/releases/latest)
- 🍺 **Homebrew**: `brew install --cask termora`
- 🔨 **WinGet**: `winget install termora`
## 🛠️ Development
We recommend using the [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) JDK for development.
- Run locally: `./gradlew :run`
- Build for current OS: `./gradlew :dist`
## 📄 License
This software is distributed under a dual-license model. You may choose one of the following options:
- AGPL-3.0: Use, distribute, and modify the software under the terms of the [AGPL-3.0](https://opensource.org/license/agpl-v3).
- Proprietary License: For closed-source or proprietary use, please contact the author to obtain a commercial license.
- **AGPL-3.0**: Use, distribute, and modify the software under the terms of the [AGPL-3.0](https://opensource.org/license/agpl-v3).
- **Proprietary License**: For closed-source or proprietary use, please contact the author to obtain a commercial license.

View File

@@ -1,48 +1,100 @@
# Termora
**Termora** 是一终端模拟器和 SSH 客户端,支持 WindowsmacOSLinux。
**Termora** 是一款跨平台终端模拟器和 SSH 客户端,支持 **WindowsmacOSLinux**
<div align="center">
<img src="./docs/readme-zh_CN.png" alt="termora" />
<img src="docs/readme-zh_CN.png" alt="Readme" />
</div>
**Termora**用 [Kotlin/JVM](https://kotlinlang.org/) 开发并实现了 [XTerm](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 协议(尚未完全实现),它的最终目标是通过 [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) 实现全平台(含 Android、iOS、iPadOS 等
Termora 使用 [**Kotlin/JVM**](https://kotlinlang.org/) 开发,支持(正在实现中) [**XTerm 控制序列协议**](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html)。未来目标是借助 [**Kotlin Multiplatform**](https://kotlinlang.org/docs/multiplatform.html) 实现 **全平台支持**,包括 Android、iOS、iPadOS 等。
## 功能特性
- 支持 SSH 和本地终端
- 支持串口协议
- 支持 [SFTP](./docs/sftp-zh_CN.png?raw=1) & [命令行](./docs/sftp-command.png?raw=1) 文件传输
- 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议
- 支持 SSH 端口转发和跳板机
- 支持 X11 和 SSH-Agent
- 终端日志记录
- 支持配置同步到 [Gist](https://gist.github.com) & [WebDAV](https://developer.mozilla.org/docs/Glossary/WebDAV)
- 支持宏(录制脚本并回放)
- 支持关键词高亮
- 支持密钥管理器
- 支持将命令发送到多个会话
- 支持 [Find Everywhere](./docs/findeverywhere-zh_CN.png?raw=1) 快速跳转
- 支持数据加密
- 支持[插件](https://www.termora.app/plugins)
- ...
## 下载
## ✨ 功能特性
- [Latest release](https://github.com/TermoraDev/termora/releases/latest)
- [Homebrew](https://formulae.brew.sh/cask/termora): `brew install --cask termora`
- [WinGet](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/TermoraDev/Termora): `winget install termora`
- 🧬 跨平台运行
- 🔐 内建密钥管理器
- 🖼️ 支持 X11 转发
- 🧑‍💻 SSH-Agent 集成
- 💻 系统信息展示
- 📁 图形化 SFTP 文件管理
- 📊 Nvidia 显卡使用率查看
- ⚡ 快捷指令支持
## 开发
建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) 的 JDK 版本,通过 `./gradlew :run` 即可运行程序。
## 🚀 文件传输
通过 `./gradlew dist` 可以自动构建适用于本机的版本。在 macOS 上是:`dmg`,在 Windows 上是:`zip`,在 Linux 上是:`tar.gz`
- 支持 A ↔ B 服务器间直接传输
- 文件夹递归复制支持
- 最多可同时运行 **6 个传输任务**
## 协议
<div align="center">
<img src="docs/transfer-zh_CN.png" alt="Transfer" />
</div>
本软件采用双重许可模式,您可以选择以下任意一种许可方式:
- AGPL-3.0:根据 [AGPL-3.0](https://opensource.org/license/agpl-v3) 的条款,您可以自由使用、分发和修改本软件。
- 专有许可:如果希望在闭源或专有环境中使用,请联系作者获取许可。
## 📝 文件编辑功能
- 保存后自动上传修改内容
- 文件 / 文件夹 重命名
- 快速删除大文件夹:`rm -rf` 支持
- 可视化更改权限
- 支持新建文件 / 文件夹
<div align="center">
<img src="docs/transfer-edit-zh_CN.png" alt="Transfer Edit" />
</div>
## 💻 主机
- 类似文件夹树形结构
- 给主机添加标签
- 从其它软件导入
- 使用传输工具打开
<div align="center">
<img src="docs/host-zh_CN.png" alt="Transfer Edit" />
</div>
## 🧩 插件
- 🌍 Geo显示主机位置信息
- 🔄 Sync将配置同步至 Gist 或 WebDAV
- 🗂️ WebDAV连接 WebDAV 对象存储
- 📝 Editor内置 SFTP 文件编辑器
- 📡 SMB: 连接 [SMB](https://baike.baidu.com/item/smb/4750512) 文件共享协议
- ☁️ S3连接 S3 对象存储
- ☁️ Huawei OBS连接华为云对象存储
- ☁️ Tencent COS连接腾讯云 COS
- ☁️ Alibaba OSS连接阿里云 OSS
- 👉 [查看所有插件...](https://www.termora.cn/plugins)
## 📦 下载
- 🧾 [Latest release](https://github.com/TermoraDev/termora/releases/latest)
- 🍺 **Homebrew**`brew install --cask termora`
- 🪟 **WinGet**`winget install termora`
## 🛠️ 开发指南
建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) JDK 运行环境。
- 本地运行:`./gradlew :run`
- 构建当前系统安装包:`./gradlew :dist`
## 📄 授权协议
Termora 采用双重许可方式,您可以选择:
- **AGPL-3.0**:自由使用、修改、分发(遵循 [AGPL 条款](https://opensource.org/license/agpl-v3)
- **专有许可**:如需闭源或商业用途,请联系作者获取授权

View File

@@ -1 +1 @@
2.0.0-beta.3
2.0.0-beta.5

View File

@@ -137,10 +137,6 @@ application {
args.add("-DTERMORA_PLUGIN_DIRECTORY=${layout.buildDirectory.get().asFile.absolutePath}${File.separator}plugins")
if (os.isLinux) {
args.add("-Dsun.java2d.opengl=true")
}
applicationDefaultJvmArgs = args
mainClass = "app.termora.MainKt"
}
@@ -441,6 +437,7 @@ tasks.register<Exec>("jpackage") {
// NSWindow
options.add("-Dapple.awt.application.appearance=system")
options.add("--add-opens java.desktop/java.awt=ALL-UNNAMED")
options.add("--add-opens java.desktop/sun.font=ALL-UNNAMED")
options.add("--add-opens java.desktop/sun.lwawt=ALL-UNNAMED")
options.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
@@ -448,7 +445,6 @@ tasks.register<Exec>("jpackage") {
}
if (os.isLinux) {
options.add("-Dsun.java2d.opengl=true")
if (isDeb) {
options.add("-Djpackage.app-layout=deb")
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

BIN
docs/host-zh_CN.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
docs/host.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
docs/plugins-zh_CN.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
docs/plugins.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

BIN
docs/tags-zh_CN.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
docs/tags.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/transfer-edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
docs/transfer-zh_CN.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
docs/transfer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -5,7 +5,7 @@ pty4j = "0.13.6"
tinylog = "2.7.0"
kotlinx-coroutines = "1.10.2"
flatlaf = "3.6"
kotlinx-serialization-json = "1.8.1"
kotlinx-serialization-json = "1.9.0"
commons-codec = "1.18.0"
commons-lang3 = "3.17.0"
commons-csv = "1.14.0"
@@ -37,17 +37,17 @@ rhino = "1.8.0"
delight-rhino-sandbox = "0.0.17"
testcontainers = "1.21.3"
mixpanel = "1.5.3"
jSerialComm = "2.11.0"
jSerialComm = "2.11.2"
ini4j = "0.5.5-2"
restart4j = "0.0.1"
eddsa = "0.3.0"
exposed = "1.0.0-beta-2"
exposed = "1.0.0-beta-3"
h2 = "2.3.232"
sqlite = "3.50.2.0"
jug = "5.1.0"
semver4j = "6.0.0"
jsvg = "1.4.0"
dom4j = "2.1.4"
dom4j = "2.2.0"
[libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }

View File

@@ -2,7 +2,7 @@ plugins {
alias(libs.plugins.kotlin.jvm)
}
project.version = "0.0.2"
project.version = "0.0.3"

View File

@@ -1,5 +1,6 @@
package app.termora.plugins.cos
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -13,7 +14,7 @@ class COSProtocolHostPanelExtension private constructor() : ProtocolHostPanelExt
return COSProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return COSProtocolHostPanel()
}
}

View File

@@ -2,7 +2,7 @@ plugins {
alias(libs.plugins.kotlin.jvm)
}
project.version = "0.0.5"
project.version = "0.0.6"
dependencies {
testImplementation(kotlin("test"))

View File

@@ -5,6 +5,7 @@ import app.termora.tree.HostTreeNode
import app.termora.tree.MarkerSimpleTreeCellAnnotation
import app.termora.tree.SimpleTreeCellAnnotation
import app.termora.tree.SimpleTreeCellRendererExtension
import com.formdev.flatlaf.util.SystemInfo
import java.awt.Color
import javax.swing.JTree
@@ -33,7 +34,7 @@ class GeoSimpleTreeCellRendererExtension private constructor() : SimpleTreeCellR
if (GeoHostTreeShowMoreEnableExtension.instance.isShowMore().not()) return emptyList()
val country = geo.country(node.data.host) ?: return emptyList()
val text = "${countryCodeToFlagEmoji(country.isoCode)}${country.name}"
val text = if (SystemInfo.isMacOS) "${countryCodeToFlagEmoji(country.isoCode)}${country.name}" else country.name
return listOf(
MarkerSimpleTreeCellAnnotation(
text,

View File

@@ -3,7 +3,7 @@ plugins {
}
project.version = "0.0.1"
project.version = "0.0.2"
dependencies {

View File

@@ -1,5 +1,6 @@
package app.termora.plugins.obs
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -13,7 +14,7 @@ class OBSProtocolHostPanelExtension private constructor() : ProtocolHostPanelExt
return OBSProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return OBSProtocolHostPanel()
}
}

View File

@@ -2,11 +2,11 @@ plugins {
alias(libs.plugins.kotlin.jvm)
}
project.version = "0.0.2"
project.version = "0.0.3"
dependencies {
testImplementation(kotlin("test"))
implementation("com.aliyun.oss:aliyun-sdk-oss:3.18.2")
implementation("com.aliyun.oss:aliyun-sdk-oss:3.18.3")
implementation("javax.xml.bind:jaxb-api:2.3.1")
implementation("javax.activation:activation:1.1.1")
implementation("org.glassfish.jaxb:jaxb-runtime:2.3.3")

View File

@@ -1,5 +1,6 @@
package app.termora.plugins.oss
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -13,7 +14,7 @@ class OSSProtocolHostPanelExtension private constructor() : ProtocolHostPanelExt
return OSSProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return OSSProtocolHostPanel()
}
}

View File

@@ -3,7 +3,7 @@ plugins {
}
project.version = "0.0.5"
project.version = "0.0.6"
dependencies {

View File

@@ -1,5 +1,6 @@
package app.termora.plugins.s3
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -13,7 +14,7 @@ class S3ProtocolHostPanelExtension private constructor() : ProtocolHostPanelExte
return S3ProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return S3ProtocolHostPanel()
}
}

View File

@@ -2,7 +2,7 @@ plugins {
alias(libs.plugins.kotlin.jvm)
}
project.version = "0.0.1"
project.version = "0.0.2"
dependencies {
testImplementation(kotlin("test"))

View File

@@ -1,5 +1,6 @@
package app.termora.plugins.smb
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -13,7 +14,7 @@ class SMBProtocolHostPanelExtension private constructor() : ProtocolHostPanelExt
return SMBProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return SMBProtocolHostPanel()
}
}

View File

@@ -3,7 +3,7 @@ plugins {
}
project.version = "0.0.2"
project.version = "0.0.3"
dependencies {

View File

@@ -2,7 +2,7 @@ plugins {
alias(libs.plugins.kotlin.jvm)
}
project.version = "0.0.1"
project.version = "0.0.2"
dependencies {
testImplementation(kotlin("test"))

View File

@@ -1,5 +1,6 @@
package app.termora.plugins.webdav
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -13,7 +14,7 @@ class WebDAVProtocolHostPanelExtension private constructor() : ProtocolHostPanel
return WebDAVProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return WebDAVProtocolHostPanel()
}
}

View File

@@ -37,7 +37,7 @@ record ExtensionProxy(Plugin plugin, Extension extension) implements InvocationH
}
throw new IllegalCallerException(target.getMessage(), target);
}
throw e;
throw target;
}
}
}

View File

@@ -22,10 +22,7 @@ import org.apache.commons.lang3.LocaleUtils
import org.apache.commons.lang3.SystemUtils
import org.json.JSONObject
import org.slf4j.LoggerFactory
import java.awt.MenuItem
import java.awt.PopupMenu
import java.awt.SystemTray
import java.awt.TrayIcon
import java.awt.*
import java.awt.desktop.AppReopenedEvent
import java.awt.desktop.AppReopenedListener
import java.awt.desktop.SystemEventListener
@@ -173,7 +170,6 @@ class ApplicationRunner {
private fun setupLaf() {
System.setProperty(FlatSystemProperties.USE_WINDOW_DECORATIONS, "${SystemInfo.isLinux || SystemInfo.isWindows}")
System.setProperty(FlatSystemProperties.USE_ROUNDED_POPUP_BORDER, "false")
if (SystemInfo.isLinux) {
JFrame.setDefaultLookAndFeelDecorated(true)
@@ -197,12 +193,13 @@ class ApplicationRunner {
themeManager.change(theme, true)
if (Application.isBetaVersion()) {
FlatInspector.install("ctrl shift X")
}
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)
UIManager.put("TitlePane.useWindowDecorations", false)
UIManager.put(FlatClientProperties.POPUP_FORCE_HEAVY_WEIGHT, true)
UIManager.put("Component.arc", 5)
UIManager.put("TextComponent.arc", UIManager.getInt("Component.arc"))
@@ -213,7 +210,6 @@ class ApplicationRunner {
UIManager.put("Dialog.width", 650)
UIManager.put("Dialog.height", 550)
if (SystemInfo.isMacOS) {
UIManager.put("TabbedPane.tabHeight", UIManager.getInt("TitleBar.height"))
} else if (SystemInfo.isLinux) {
@@ -231,15 +227,33 @@ class ApplicationRunner {
UIManager.put("Table.rowHeight", 24)
UIManager.put("Table.focusCellHighlightBorder", FlatTableCellBorder.Default())
UIManager.put("Table.focusSelectedCellHighlightBorder", FlatTableCellBorder.Default())
UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Tree.rowHeight", 24)
UIManager.put("Tree.background", DynamicColor("window"))
UIManager.put("Tree.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Tree.showCellFocusIndicator", false)
UIManager.put("Tree.repaintWholeRow", true)
// Linux 更多的是尖锐风格
if (SystemInfo.isMacOS || SystemInfo.isWindows) {
val selectionInsets = Insets(0, 2, 0, 2)
UIManager.put("Tree.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Tree.selectionInsets", selectionInsets)
UIManager.put("List.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("List.selectionInsets", selectionInsets)
UIManager.put("ComboBox.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("ComboBox.selectionInsets", selectionInsets)
UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Table.selectionInsets", selectionInsets)
UIManager.put("MenuBar.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("MenuBar.selectionInsets", selectionInsets)
UIManager.put("MenuItem.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("MenuItem.selectionInsets", selectionInsets)
}
}

View File

@@ -23,7 +23,7 @@ class BannerPanel(fontSize: Int = 11, val beautiful: Boolean = false) : JCompone
size = preferredSize
}
override fun paintComponent(g: Graphics) {
public override fun paintComponent(g: Graphics) {
if (g is Graphics2D) {
g.setRenderingHints(
RenderingHints(
@@ -33,7 +33,7 @@ class BannerPanel(fontSize: Int = 11, val beautiful: Boolean = false) : JCompone
}
g.font = font
g.color = UIManager.getColor("TextField.placeholderForeground")
g.color = foreground ?: UIManager.getColor("TextField.placeholderForeground")
val height = g.fontMetrics.height
val descent = g.fontMetrics.descent

View File

@@ -0,0 +1,71 @@
package app.termora
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.util.FontUtils
import java.awt.Component
import java.awt.Dimension
import javax.swing.DefaultListCellRenderer
import javax.swing.JList
import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener
class FontComboBox : FlatComboBox<String>() {
private var fontsLoaded = false
init {
val fontComboBox = this
fontComboBox.renderer = object : DefaultListCellRenderer() {
init {
preferredSize = Dimension(preferredSize.width, fontComboBox.preferredSize.height - 2)
maximumSize = Dimension(preferredSize.width, preferredSize.height)
}
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value
if (text is String) {
if (text.isBlank()) {
text = "&lt;None&gt;"
}
return super.getListCellRendererComponent(
list,
"<html><font face='$text'>$text</font></html>",
index,
isSelected,
cellHasFocus
)
}
return super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
}
}
fontComboBox.maximumSize = fontComboBox.preferredSize
fontComboBox.addPopupMenuListener(object : PopupMenuListener {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
if (fontsLoaded) return
val selectedItem = fontComboBox.selectedItem
val families = getItems()
for (family in FontUtils.getAvailableFontFamilyNames()) {
if (families.contains(family).not()) fontComboBox.addItem(family)
}
fontComboBox.selectedItem = selectedItem
fontsLoaded = true
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {}
override fun popupMenuCanceled(e: PopupMenuEvent) {}
})
}
fun getItems(): Set<String> {
val families = mutableSetOf<String>()
for (i in 0 until itemCount) families.add(getItemAt(i))
return families
}
}

View File

@@ -1,5 +1,6 @@
package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders
import app.termora.terminal.*
@@ -8,7 +9,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.StringUtils
import java.beans.PropertyChangeEvent
import java.util.*
import javax.swing.Icon
abstract class HostTerminalTab(
@@ -20,6 +23,10 @@ abstract class HostTerminalTab(
val Host = DataKey(app.termora.Host::class)
}
protected val terminalTabbedManager
get() = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
.getData(DataProviders.TerminalTabbedManager)
protected val coroutineScope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Swing) }
protected val terminalModel get() = terminal.getTerminalModel()
protected var unread = false
@@ -42,9 +49,12 @@ abstract class HostTerminalTab(
if (hasFocus || unread) {
return
}
// 如果当前选中的不是这个 Tab那么设置成未读
if (terminalTabbedManager?.getSelectedTerminalTab() != this@HostTerminalTab) {
unread = true
}
}
}
})
}

View File

@@ -2,6 +2,7 @@ package app.termora
object Icons {
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
val dataColumn by lazy { DynamicIcon("icons/dataColumn.svg", "icons/dataColumn_dark.svg") }
val dbms by lazy { DynamicIcon("icons/dbms.svg", "icons/dbms_dark.svg") }
val newUI by lazy { DynamicIcon("icons/newUI.svg", "icons/newUI.svg") }
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
@@ -33,6 +34,7 @@ object Icons {
val empty by lazy { DynamicIcon("icons/empty.svg") }
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") }
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val breakpoint by lazy { DynamicIcon("icons/breakpoint.svg", "icons/breakpoint_dark.svg") }
val softWrap by lazy { DynamicIcon("icons/softWrap.svg", "icons/softWrap_dark.svg") }
val scrollUp by lazy { DynamicIcon("icons/scrollUp.svg", "icons/scrollUp_dark.svg") }
val reformatCode by lazy { DynamicIcon("icons/reformatCode.svg", "icons/reformatCode_dark.svg") }
@@ -77,6 +79,7 @@ object Icons {
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") }
val terminal by lazy { DynamicIcon("icons/terminal.svg", "icons/terminal_dark.svg") }
val telnet by lazy { DynamicIcon("icons/telnet.svg", "icons/telnet_dark.svg") }
val ssh by lazy { DynamicIcon("icons/ssh.svg", "icons/ssh_dark.svg") }
val ftp by lazy { DynamicIcon("icons/ftp.svg", "icons/ftp_dark.svg") }
val minio by lazy { DynamicIcon("icons/minio.svg", "icons/minio_dark.svg") }

View File

@@ -0,0 +1,126 @@
package app.termora
import com.formdev.flatlaf.ui.FlatSplitPaneUI
import java.awt.BorderLayout
import java.awt.Cursor
import java.awt.Graphics
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseMotionAdapter
import java.util.function.Supplier
import javax.swing.*
class SplitPaneUI : FlatSplitPaneUI() {
public override fun startDragging() {
super.startDragging()
}
public override fun dragDividerTo(location: Int) {
super.dragDividerTo(location)
}
public override fun finishDraggingTo(location: Int) {
super.finishDraggingTo(location)
}
}
class JSplitPaneWithZeroSizeDivider(
private val splitPane: JSplitPane,
private val topOffset: Supplier<Int>,
) : JPanel(BorderLayout()) {
private val dividerDragSize = 7
private val layeredPane = LayeredPane()
private val divider = Divider()
init {
layeredPane.add(splitPane, JLayeredPane.DEFAULT_LAYER as Any)
layeredPane.add(divider, JLayeredPane.PALETTE_LAYER as Any)
add(layeredPane, BorderLayout.CENTER)
}
private inner class Divider : JComponent() {
private var dragging = false
private var dragStartX = 0
private var initialDividerLocation = 0
private val splitPaneUI get() = splitPane.ui as SplitPaneUI
init {
cursor = Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR)
addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e)) {
dragging = true
dragStartX = e.xOnScreen
initialDividerLocation = splitPane.dividerLocation
splitPaneUI.startDragging()
}
}
override fun mouseReleased(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e)) {
if (dragging) {
val deltaX = e.xOnScreen - dragStartX
val newLocation = initialDividerLocation + deltaX
splitPaneUI.finishDraggingTo(newLocation)
}
dragging = false
}
}
})
addMouseMotionListener(object : MouseMotionAdapter() {
override fun mouseDragged(e: MouseEvent) {
if (dragging) {
val deltaX = e.xOnScreen - dragStartX
val newLocation = initialDividerLocation + deltaX
splitPaneUI.dragDividerTo(newLocation)
}
}
})
}
override fun paint(g: Graphics) {
g.color = UIManager.getColor("controlShadow")
g.fillRect(width / 2, 0, 1, height)
}
}
private inner class LayeredPane : JLayeredPane() {
private val w get() = (dividerDragSize - 1) / 2
override fun doLayout() {
synchronized(treeLock) {
for (c in components) {
if (c == divider) {
c.isVisible = splitPane.leftComponent.isVisible
c.setBounds(
splitPane.dividerLocation - w,
topOffset.get(),
dividerDragSize,
height - topOffset.get()
)
} else {
c.setBounds(0, 0, width, height)
}
}
}
}
override fun paint(g: Graphics) {
super.paint(g)
if (divider.isVisible) {
g.color = UIManager.getColor("controlShadow")
g.fillRect(splitPane.dividerLocation, 0, 1, topOffset.get())
}
}
}
override fun doLayout() {
super.doLayout()
layeredPane.doLayout()
}
}

View File

@@ -9,6 +9,12 @@ import java.awt.Window
object NativeMacLibrary {
private val log = LoggerFactory.getLogger(NativeMacLibrary::class.java)
enum class NSWindowButton {
NSWindowCloseButton,
NSWindowMiniaturizeButton,
NSWindowZoomButton,
}
fun getNSWindow(window: Window): Long? {
try {
val peerField = Component::class.java.getDeclaredField("peer") ?: return null
@@ -31,13 +37,17 @@ object NativeMacLibrary {
}
}
fun setControlsVisible(window: Window, visible: Boolean) {
fun setControlsVisible(
window: Window,
visible: Boolean,
buttons: Array<NSWindowButton> = NSWindowButton.entries.toTypedArray()
) {
val nsWindow = ID(getNSWindow(window) ?: return)
try {
Foundation.executeOnMainThread(true, true) {
for (i in 0..2) {
val button = Foundation.invoke(nsWindow, "standardWindowButton:", i)
Foundation.invoke(button, "setHidden:", !visible)
for (button in buttons) {
val button = Foundation.invoke(nsWindow, "standardWindowButton:", button.ordinal)
Foundation.invoke(button, "setHidden:", visible.not())
}
}
} catch (e: Exception) {

View File

@@ -1,5 +1,6 @@
package app.termora
import app.termora.account.AccountOwner
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.protocol.*
@@ -18,7 +19,11 @@ import java.awt.Dimension
import java.awt.Window
import javax.swing.*
class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : DialogWrapper(owner) {
class NewHostDialogV2(
owner: Window,
private val editHost: Host? = null,
private val accountOwner: AccountOwner,
) : DialogWrapper(owner) {
private object Current {
var card: ProtocolHostPanel? = null
@@ -65,11 +70,11 @@ class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : Dialo
toolbar.add(Box.createHorizontalGlue())
val extensions = ProtocolHostPanelExtension.extensions
.filter { it.canCreateProtocolHostPanel() }
.filter { it.canCreateProtocolHostPanel(accountOwner) }
for ((index, extension) in extensions.withIndex()) {
val protocol = extension.getProtocolProvider().getProtocol()
val icon = ScaleIcon(extension.getProtocolProvider().getIcon(), 22)
val hostPanel = extension.createProtocolHostPanel()
val hostPanel = extension.createProtocolHostPanel(accountOwner)
val button = JToggleButton(protocol, icon).apply { buttonGroup.add(this) }
button.setVerticalTextPosition(SwingConstants.BOTTOM)
button.setHorizontalTextPosition(SwingConstants.CENTER)

View File

@@ -23,7 +23,10 @@ abstract class PtyHostTerminalTab(
private var readerJob: Job? = null
private val ptyConnectorDelegate = PtyConnectorDelegate()
protected val terminalPanel = TerminalPanelFactory.getInstance().createTerminalPanel(terminal, ptyConnectorDelegate)
protected val terminalPanel = TerminalPanelFactory.getInstance().createTerminalPanel(
this,
terminal, ptyConnectorDelegate
)
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance()
override fun start() {

View File

@@ -33,7 +33,7 @@ open class Scope(
return get(clazz)
}
synchronized(clazz) {
synchronized(this) {
if (beans.containsKey(clazz)) {
return get(clazz)
}

View File

@@ -15,7 +15,6 @@ import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.util.FontUtils
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
@@ -29,7 +28,6 @@ import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.Toolkit
import java.awt.event.ActionEvent
import java.awt.event.ItemEvent
@@ -112,6 +110,7 @@ class SettingsOptionsPane : OptionsPane() {
private inner class AppearanceOption : JPanel(BorderLayout()), Option {
val themeManager = ThemeManager.getInstance()
val themeComboBox = FlatComboBox<String>()
val layoutComboBox = FlatComboBox<TermoraLayout>()
val languageComboBox = FlatComboBox<String>()
val backgroundComBoBox = YesOrNoComboBox()
val confirmTabCloseComBoBox = YesOrNoComboBox()
@@ -129,6 +128,38 @@ class SettingsOptionsPane : OptionsPane() {
private fun initView() {
layoutComboBox.addItem(TermoraLayout.Screen)
layoutComboBox.addItem(TermoraLayout.Fence)
layoutComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value
if (value == TermoraLayout.Screen) {
text = I18n.getString("termora.settings.appearance.layout.screen")
} else if (value == TermoraLayout.Fence) {
text = I18n.getString("termora.settings.appearance.layout.fence")
}
val c = super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
icon = null
if (value == TermoraLayout.Screen) {
icon = if (isSelected) Icons.uiForm.dark else Icons.uiForm
} else if (value == TermoraLayout.Fence) {
icon = if (isSelected) Icons.dataColumn.dark else Icons.dataColumn
}
return c
}
}
layoutComboBox.selectedItem = runCatching { TermoraLayout.valueOf(appearance.layout) }
.getOrNull() ?: TermoraLayout.Layout
backgroundComBoBox.isEnabled = SystemInfo.isWindows || SystemInfo.isMacOS
opacitySpinner.isEnabled = SystemInfo.isMacOS || SystemInfo.isWindows
@@ -184,6 +215,17 @@ class SettingsOptionsPane : OptionsPane() {
}
}
layoutComboBox.addItemListener(object : ItemListener {
override fun itemStateChanged(e: ItemEvent) {
if (e.stateChange == ItemEvent.SELECTED) {
appearance.layout = layoutComboBox.selectedItem?.toString() ?: return
if (TermoraLayout.Layout.name != appearance.layout) {
SwingUtilities.invokeLater { TermoraRestarter.getInstance().scheduleRestart(owner) }
}
}
}
})
opacitySpinner.addChangeListener {
val opacity = opacitySpinner.value
if (opacity is Double) {
@@ -307,7 +349,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun getFormPanel(): JPanel {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, default, default:grow",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
val box = FlatToolBar()
box.add(followSystemCheckBox)
@@ -329,6 +371,9 @@ class SettingsOptionsPane : OptionsPane() {
})).xy(5, rows).apply { rows += step }
builder.add("${I18n.getString("termora.settings.appearance.layout")}:").xy(1, rows)
.add(layoutComboBox).xy(3, rows).apply { rows += step }
builder.add("${I18n.getString("termora.settings.appearance.opacity")}:").xy(1, rows)
.add(opacitySpinner).xy(3, rows).apply { rows += step }
@@ -352,7 +397,8 @@ class SettingsOptionsPane : OptionsPane() {
private val debugComboBox = YesOrNoComboBox()
private val beepComboBox = YesOrNoComboBox()
private val cursorBlinkComboBox = YesOrNoComboBox()
private val fontComboBox = FlatComboBox<String>()
private val fontComboBox = FontComboBox()
private val fallbackFontComboBox = FontComboBox()
private val shellComboBox = FlatComboBox<String>()
private val maxRowsTextField = IntSpinner(0, 0)
private val fontSizeTextField = IntSpinner(0, 9, 99)
@@ -371,6 +417,13 @@ class SettingsOptionsPane : OptionsPane() {
}
}
fallbackFontComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
terminalSetting.fallbackFont = fallbackFontComboBox.selectedItem as String
fireFontChanged()
}
}
autoCloseTabComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.autoCloseTabWhenDisconnected = autoCloseTabComboBox.selectedItem as Boolean
@@ -479,33 +532,6 @@ class SettingsOptionsPane : OptionsPane() {
}
}
fontComboBox.renderer = object : DefaultListCellRenderer() {
init {
preferredSize = Dimension(preferredSize.width, fontComboBox.preferredSize.height - 2)
maximumSize = Dimension(preferredSize.width, preferredSize.height)
}
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
if (value is String) {
return super.getListCellRendererComponent(
list,
"<html><font face='$value'>$value</font></html>",
index,
isSelected,
cellHasFocus
)
}
return super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
}
}
fontComboBox.maximumSize = fontComboBox.preferredSize
cursorStyleComboBox.addItem(CursorStyle.Block)
cursorStyleComboBox.addItem(CursorStyle.Bar)
cursorStyleComboBox.addItem(CursorStyle.Underline)
@@ -519,29 +545,18 @@ class SettingsOptionsPane : OptionsPane() {
shellComboBox.selectedItem = terminalSetting.localShell
fontComboBox.addItem(terminalSetting.font)
var fontsLoaded = false
fontComboBox.addPopupMenuListener(object : PopupMenuListener {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
if (!fontsLoaded) {
val selectedItem = fontComboBox.selectedItem
fontComboBox.removeAllItems();
fontComboBox.addItem("JetBrains Mono")
fontComboBox.addItem("Source Code Pro")
fontComboBox.addItem("Monospaced")
FontUtils.getAvailableFontFamilyNames().forEach {
fontComboBox.addItem(it)
}
fontComboBox.selectedItem = selectedItem
fontsLoaded = true
}
val items = fontComboBox.getItems()
for (family in listOf("JetBrains Mono", "Source Code Pro", "Monospaced")) {
if (items.contains(family).not()) fontComboBox.addItem(family)
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {}
override fun popupMenuCanceled(e: PopupMenuEvent) {}
})
if (terminalSetting.fallbackFont.isNotBlank()) {
fallbackFontComboBox.addItem(StringUtils.EMPTY)
}
fallbackFontComboBox.addItem(terminalSetting.fallbackFont)
fontComboBox.selectedItem = terminalSetting.font
fallbackFontComboBox.selectedItem = terminalSetting.fallbackFont
debugComboBox.selectedItem = terminalSetting.debug
beepComboBox.selectedItem = terminalSetting.beep
hyperlinkComboBox.selectedItem = terminalSetting.hyperlink
@@ -580,7 +595,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, left:pref, $FORM_MARGIN, pref, default:grow",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
val beepBtn = JButton(Icons.run)
@@ -596,6 +611,8 @@ class SettingsOptionsPane : OptionsPane() {
.add(fontComboBox).xy(3, rows)
.add("${I18n.getString("termora.settings.terminal.size")}:").xy(5, rows)
.add(fontSizeTextField).xy(7, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.fallback-font")}:").xy(1, rows)
.add(fallbackFontComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.max-rows")}:").xy(1, rows)
.add(maxRowsTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.debug")}:").xy(1, rows)

View File

@@ -38,9 +38,9 @@ class TerminalPanelFactory : Disposable {
}
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
fun createTerminalPanel(tab: TerminalTab?, terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
val writer = MyTerminalWriter(ptyConnector)
val terminalPanel = TerminalPanel(terminal, writer)
val terminalPanel = TerminalPanel(tab, terminal, writer)
// processDeviceStatusReport
terminal.getTerminalModel().setData(DataKey.TerminalWriter, writer)

View File

@@ -1,6 +1,7 @@
package app.termora
import app.termora.account.AccountManager
import app.termora.actions.*
import app.termora.database.DatabaseChangedExtension
import app.termora.database.DatabaseManager
@@ -8,10 +9,8 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProviderExtension
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.plugin.ExtensionManager
import app.termora.plugin.internal.extension.DynamicExtensionHandler
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
import app.termora.plugin.internal.sftppty.SFTPPtyTerminalTab
import app.termora.plugin.internal.ssh.SSHProtocolProvider
import app.termora.terminal.DataKey
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu
@@ -23,7 +22,6 @@ import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.beans.PropertyChangeListener
import java.util.*
import javax.swing.*
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
import kotlin.math.min
@@ -32,6 +30,7 @@ class TerminalTabbed(
private val windowScope: WindowScope,
private val termoraToolBar: TermoraToolBar,
private val tabbedPane: FlatTabbedPane,
private val layout: TermoraLayout,
) : JPanel(BorderLayout()), Disposable, TerminalTabbedManager, DataProvider {
private val tabs = mutableListOf<TerminalTab>()
private val customizeToolBarAWTEventListener = CustomizeToolBarAWTEventListener()
@@ -110,20 +109,6 @@ class TerminalTabbed(
}
})
// 点击
tabbedPane.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e)) {
val index = tabbedPane.indexAtLocation(e.x, e.y)
if (index > 0) {
tabbedPane.getComponentAt(index).requestFocusInWindow()
}
}
}
})
// 注册全局搜索
DynamicExtensionHandler.getInstance()
.register(FindEverywhereProviderExtension::class.java, object : FindEverywhereProviderExtension {
@@ -206,11 +191,13 @@ class TerminalTabbed(
// remove ele
tabs.removeAt(index)
if (tabbedPane.tabCount > 0) {
// 新的获取到焦点
tabs[tabbedPane.selectedIndex].onGrabFocus()
// 新的真正获取焦点
tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow()
}
if (disposable) {
Disposer.dispose(tab)
@@ -221,6 +208,15 @@ class TerminalTabbed(
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
val c = tabbedPane.getComponentAt(tabIndex) as JComponent
val tab = tabs[tabIndex]
val extensions = ExtensionManager.getInstance().getExtensions(TerminalTabbedContextMenuExtension::class.java)
val menuItems = mutableListOf<JMenuItem>()
for (extension in extensions) {
try {
menuItems.add(extension.createJMenuItem(windowScope, tab))
} catch (_: UnsupportedOperationException) {
continue
}
}
val popupMenu = FlatPopupMenu()
@@ -242,7 +238,7 @@ class TerminalTabbed(
}
// 克隆
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
val clone = popupMenu.add(I18n.getString("termora.copy"))
clone.addActionListener { evt ->
if (tab is HostTerminalTab) {
actionManager
@@ -255,10 +251,15 @@ class TerminalTabbed(
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
edit.addActionListener(object : AnAction() {
private val hostManager get() = HostManager.getInstance()
private val accountManager get() = AccountManager.getInstance()
override fun actionPerformed(evt: AnActionEvent) {
if (tab is HostTerminalTab) {
val host = hostManager.getHost(tab.host.id) ?: return
val dialog = NewHostDialogV2(evt.window, host)
val dialog = NewHostDialogV2(
evt.window, host,
accountManager.getOwners().first { it.id == host.ownerId },
)
dialog.setLocationRelativeTo(evt.window)
dialog.isVisible = true
@@ -289,14 +290,10 @@ class TerminalTabbed(
}
})
if (tab is HostTerminalTab) {
val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST)
if (openHostAction != null) {
if (tab.host.protocol == SSHProtocolProvider.PROTOCOL || tab.host.protocol == SFTPPtyProtocolProvider.PROTOCOL) {
if (menuItems.isNotEmpty()) {
popupMenu.addSeparator()
val sftpCommand = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
sftpCommand.addActionListener { openSFTPPtyTab(tab, openHostAction, it) }
}
for (item in menuItems) {
popupMenu.add(item)
}
}
@@ -388,36 +385,6 @@ class TerminalTabbed(
}
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 == SSHProtocolProvider.PROTOCOL) {
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 = SFTPPtyProtocolProvider.PROTOCOL,
options = host.options.copy(env = envs.toPropertiesString())
)
}
openHostAction.actionPerformed(OpenHostActionEvent(this, host, evt))
}
/**
* 对着 ToolBar 右键
*/
@@ -497,6 +464,32 @@ class TerminalTabbed(
}
}
override fun paint(g: Graphics) {
super.paint(g)
}
private val border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
private val banner = BannerPanel(fontSize = 13).apply {
foreground = UIManager.getColor("textInactiveText")
}
override fun paintComponent(g: Graphics) {
super.paintComponent(g)
if (layout == TermoraLayout.Fence) {
if (g is Graphics2D) {
if (tabbedPane.tabCount < 1) {
border.paintBorder(this, g, 0, tabbedPane.tabHeight, width, tabbedPane.tabHeight)
banner.setBounds(0, 0, width, height)
g.save()
g.translate(0, 180)
banner.paintComponent(g)
g.restore()
}
}
}
}
override fun dispose() {
}

View File

@@ -0,0 +1,12 @@
package app.termora
import app.termora.plugin.Extension
import javax.swing.JMenuItem
interface TerminalTabbedContextMenuExtension : Extension {
/**
* 抛出 [UnsupportedOperationException] 表示不支持
*/
fun createJMenuItem(windowScope: WindowScope, tab: TerminalTab): JMenuItem
}

View File

@@ -0,0 +1,163 @@
package app.termora
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.tree.NewHostTree
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.util.SystemInfo
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Font
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import javax.swing.*
import kotlin.math.max
class TermoraFencePanel(
private val terminalTabbed: TerminalTabbed,
private val tabbed: FlatTabbedPane,
private val moveMouseAdapter: MouseAdapter,
) : JPanel(BorderLayout()), Disposable {
private val splitPane = object : JSplitPane() {
override fun updateUI() {
setUI(SplitPaneUI())
revalidate()
}
}
private val leftTreePanel = LeftTreePanel()
private val mySplitPane = JSplitPaneWithZeroSizeDivider(splitPane) { tabbed.tabHeight }
private val enableManager get() = EnableManager.getInstance()
private val toolbar = FlatToolBar().apply { isFloatable = false }
private var dividerLocation = 0
init {
initView()
initEvents()
}
private fun initView() {
splitPane.border = null
splitPane.leftComponent = leftTreePanel
splitPane.rightComponent = terminalTabbed
splitPane.dividerSize = 0
splitPane.dividerLocation = enableManager.getFlag("Termora.Fence.dividerLocation", 220)
leftTreePanel.preferredSize = Dimension(180, -1)
tabbed.tabType = FlatTabbedPane.TabType.underlined
tabbed.tabAreaInsets = null
// macOS 避开控制栏
if (SystemInfo.isMacOS) {
toolbar.add(Box.createHorizontalStrut(76))
}
toolbar.add(createColspanAction())
tabbed.leadingComponent = toolbar
toolbar.isVisible = false
add(mySplitPane, BorderLayout.CENTER)
}
private fun initEvents() {
Disposer.register(this, leftTreePanel)
splitPane.addPropertyChangeListener("dividerLocation") { mySplitPane.doLayout() }
leftTreePanel.addComponentListener(object : ComponentAdapter() {
override fun componentHidden(e: ComponentEvent) {
toolbar.isVisible = true
}
override fun componentShown(e: ComponentEvent) {
toolbar.isVisible = false
}
})
actionMap.put("toggle", createColspanAction())
getInputMap(WHEN_IN_FOCUSED_WINDOW).put(
KeyStroke.getKeyStroke(
KeyEvent.VK_B,
toolkit.menuShortcutKeyMaskEx or KeyEvent.SHIFT_DOWN_MASK
), "toggle"
)
}
private inner class LeftTreePanel : JPanel(BorderLayout()), Disposable {
val hostTree = NewHostTree()
private val box = JToolBar().apply { isFloatable = false }
init {
initView()
initEvents()
}
private fun initView() {
val scrollPane = JScrollPane(hostTree)
hostTree.name = "FenceHostTree"
hostTree.restoreExpansions()
box.preferredSize = Dimension(-1, tabbed.tabHeight)
val label = JLabel(Application.getName())
label.foreground = UIManager.getColor("textInactiveText")
label.font = label.font.deriveFont(Font.BOLD)
// 与最后一个按钮对冲,使其宽度和谐
box.add(JButton(Icons.empty))
box.add(Box.createHorizontalGlue())
if (SystemInfo.isMacOS.not()) {
box.add(label)
}
box.add(Box.createHorizontalGlue())
box.add(createColspanAction())
if (SystemInfo.isMacOS || SystemInfo.isLinux) {
box.addMouseListener(moveMouseAdapter)
box.addMouseMotionListener(moveMouseAdapter)
}
scrollPane.verticalScrollBar.unitIncrement = 16
scrollPane.horizontalScrollBar.unitIncrement = 16
scrollPane.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(4, 4, 0, 4)
)
add(box, BorderLayout.NORTH)
add(scrollPane, BorderLayout.CENTER)
}
private fun initEvents() {
Disposer.register(this, hostTree)
}
}
private fun createColspanAction(): Action {
return object : AnAction(Icons.dataColumn) {
init {
val text = I18n.getString("termora.welcome.toggle-sidebar")
putValue(SHORT_DESCRIPTION, "$text (${if (SystemInfo.isMacOS) '⌘' else "Ctrl"} + Shift + B)")
}
override fun actionPerformed(evt: AnActionEvent) {
if (leftTreePanel.isVisible) dividerLocation = splitPane.dividerLocation
leftTreePanel.isVisible = leftTreePanel.isVisible.not()
if (leftTreePanel.isVisible) splitPane.dividerLocation = dividerLocation
}
}
}
override fun dispose() {
enableManager.setFlag("Termora.Fence.dividerLocation", max(splitPane.dividerLocation, 10))
}
fun getHostTree(): NewHostTree {
return leftTreePanel.hostTree
}
}

View File

@@ -4,21 +4,29 @@ package app.termora
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders
import app.termora.actions.OpenHostAction
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProviderExtension
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.plugin.ExtensionManager
import app.termora.plugin.internal.extension.DynamicExtensionHandler
import app.termora.plugin.internal.ssh.SSHProtocolProvider
import app.termora.terminal.DataKey
import app.termora.tree.NewHostTreeModel
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.ui.FlatRootPaneUI
import com.formdev.flatlaf.ui.FlatTitlePane
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import org.apache.commons.lang3.ArrayUtils
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager
import java.awt.*
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseListener
import java.awt.event.MouseMotionListener
import java.awt.event.*
import java.util.*
import javax.imageio.ImageIO
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JFrame
import javax.swing.SwingUtilities.isEventDispatchThread
@@ -32,15 +40,16 @@ fun assertEventDispatchThread() {
class TermoraFrame : JFrame(), DataProvider {
private val layout get() = TermoraLayout.Layout
private val titleBarHeight = computedTitleBarHeight()
private val id = UUID.randomUUID().toString()
private val windowScope = ApplicationScope.forWindowScope(this)
private val tabbedPane = MyTabbedPane()
private val tabbedPane = MyTabbedPane().apply { tabHeight = titleBarHeight }
private val toolbar = TermoraToolBar(windowScope, this)
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane, layout)
private val dataProviderSupport = DataProviderSupport()
private val welcomePanel = WelcomePanel(windowScope)
private var notifyListeners = emptyArray<NotifyListener>()
private val moveMouseAdapter = createMoveMouseAdaptor()
init {
@@ -50,7 +59,201 @@ class TermoraFrame : JFrame(), DataProvider {
private fun initEvents() {
if (SystemInfo.isLinux) {
val mouseAdapter = object : MouseAdapter() {
toolbar.getJToolBar().addMouseListener(moveMouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(moveMouseAdapter)
} else if (SystemInfo.isMacOS) {
terminalTabbed.addMouseListener(moveMouseAdapter)
terminalTabbed.addMouseMotionListener(moveMouseAdapter)
tabbedPane.addMouseListener(moveMouseAdapter)
tabbedPane.addMouseMotionListener(moveMouseAdapter)
toolbar.getJToolBar().addMouseListener(moveMouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(moveMouseAdapter)
}
// FindEverywhere
DynamicExtensionHandler.getInstance()
.register(FindEverywhereProviderExtension::class.java, object : FindEverywhereProviderExtension {
private val hostTreeModel get() = NewHostTreeModel.getInstance()
private val provider = object : FindEverywhereProvider {
override fun find(pattern: String, scope: Scope): List<FindEverywhereResult> {
if (scope != windowScope) return emptyList()
var filter = hostTreeModel.root.getAllChildren()
.filter { it.isFolder.not() }
.map { it.host }
if (pattern.isNotBlank()) {
filter = filter.filter {
if (it.protocol == SSHProtocolProvider.PROTOCOL) {
it.name.contains(pattern, true) || it.host.contains(pattern, true)
} else {
it.name.contains(pattern, true)
}
}
}
return filter.map { HostFindEverywhereResult(it) }
}
override fun group(): String {
return I18n.getString("termora.find-everywhere.groups.open-new-hosts")
}
override fun order(): Int {
return Integer.MIN_VALUE + 2
}
}
override fun getFindEverywhereProvider(): FindEverywhereProvider {
return provider
}
private inner class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
private val showMoreInfo get() = EnableManager.getInstance().isShowMoreInfo()
override fun actionPerformed(e: ActionEvent) {
ActionManager.getInstance()
.getAction(OpenHostAction.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(e.source, host, e))
}
override fun getIcon(isSelected: Boolean): Icon {
if (isSelected) {
if (!FlatLaf.isLafDark()) {
return Icons.terminal.dark
}
}
return Icons.terminal
}
override fun getText(isSelected: Boolean): String {
if (showMoreInfo) {
val color = UIManager.getColor(if (isSelected) "textHighlightText" else "textInactiveText")
val moreInfo = when (host.protocol) {
SSHProtocolProvider.PROTOCOL -> "${host.username}@${host.host}"
"Serial" -> host.options.serialComm.port
else -> StringUtils.EMPTY
}
if (moreInfo.isNotBlank()) {
return "<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;<font color=rgb(${color.red},${color.green},${color.blue})>${moreInfo}</font></html>"
}
}
return host.name
}
}
}).let { Disposer.register(windowScope, it) }
}
private fun initView() {
// macOS 要避开左边的控制栏
if (SystemInfo.isMacOS) {
tabbedPane.tabAreaInsets = Insets(0, 76, 0, 0)
} else if (SystemInfo.isWindows) {
// Windows 10 会有1像素误差
tabbedPane.tabAreaInsets = Insets(if (SystemInfo.isWindows_11_orLater) 1 else 2, 2, 0, 0)
} else if (SystemInfo.isLinux) {
tabbedPane.tabAreaInsets = Insets(1, 2, 0, 0)
}
val height = UIManager.getInt("TabbedPane.tabHeight") + tabbedPane.tabAreaInsets.top
if (SystemInfo.isWindows || SystemInfo.isLinux) {
rootPane.putClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT, true)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICON, false)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, false)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, height)
}
if (SystemInfo.isWindows || SystemInfo.isLinux) {
val sizes = listOf(16, 20, 24, 28, 32, 48, 64, 128)
val loader = TermoraFrame::class.java.classLoader
val images = sizes.mapNotNull { e ->
loader.getResourceAsStream("icons/termora_${e}x${e}.png")?.use { ImageIO.read(it) }
}
iconImages = images
}
minimumSize = Dimension(640, 400)
val glassPane = GlassPane()
rootPane.glassPane = glassPane
glassPane.isOpaque = false
glassPane.isVisible = true
for (extension in ExtensionManager.getInstance().getExtensions(GlassPaneAwareExtension::class.java)) {
extension.setGlassPane(this, glassPane)
}
if (layout == TermoraLayout.Fence) {
val fencePanel = TermoraFencePanel(terminalTabbed, tabbedPane, moveMouseAdapter)
add(fencePanel, BorderLayout.CENTER)
dataProviderSupport.addData(DataProviders.Welcome.HostTree, fencePanel.getHostTree())
Disposer.register(windowScope, fencePanel)
} else {
val screenPanel = TermoraScreenPanel(windowScope, terminalTabbed)
add(screenPanel, BorderLayout.CENTER)
dataProviderSupport.addData(DataProviders.Welcome.HostTree, screenPanel.getHostTree())
}
Disposer.register(windowScope, terminalTabbed)
dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane)
dataProviderSupport.addData(DataProviders.TermoraFrame, this)
dataProviderSupport.addData(DataProviders.WindowScope, windowScope)
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey) ?: terminalTabbed.getData(dataKey)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TermoraFrame
return id == other.id
}
override fun hashCode(): Int {
return id.hashCode()
}
fun addNotifyListener(listener: NotifyListener) {
notifyListeners += listener
}
fun removeNotifyListener(listener: NotifyListener) {
notifyListeners = ArrayUtils.removeElements(notifyListeners, listener)
}
override fun addNotify() {
super.addNotify()
notifyListeners.forEach { it.addNotify() }
}
private fun computedTitleBarHeight(): Int {
val tabHeight = UIManager.getInt("TabbedPane.tabHeight")
if (SystemInfo.isWindows) {
// Windows 10 会有1像素误差
return tabHeight + if (SystemInfo.isWindows_11_orLater) 1 else 2
} else if (SystemInfo.isLinux) {
return tabHeight + 1
}
return tabHeight
}
private fun createMoveMouseAdaptor(): MouseAdapter {
if (SystemInfo.isLinux) {
return object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
getMouseHandler()?.mouseClicked(e)
}
@@ -97,8 +300,6 @@ class TermoraFrame : JFrame(), DataProvider {
return titlePaneField.get(ui) as? FlatTitlePane
}
}
toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
}
/// force hit
@@ -145,111 +346,13 @@ class TermoraFrame : JFrame(), DataProvider {
}
}
terminalTabbed.addMouseListener(mouseAdapter)
terminalTabbed.addMouseMotionListener(mouseAdapter)
tabbedPane.addMouseListener(mouseAdapter)
tabbedPane.addMouseMotionListener(mouseAdapter)
toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
JBR.getWindowDecorations().setCustomTitleBar(this, customTitleBar)
}
return mouseAdapter
}
}
private fun initView() {
// macOS 要避开左边的控制栏
if (SystemInfo.isMacOS) {
tabbedPane.tabAreaInsets = Insets(0, 76, 0, 0)
} else if (SystemInfo.isWindows) {
// Windows 10 会有1像素误差
tabbedPane.tabAreaInsets = Insets(if (SystemInfo.isWindows_11_orLater) 1 else 2, 2, 0, 0)
} else if (SystemInfo.isLinux) {
tabbedPane.tabAreaInsets = Insets(1, 2, 0, 0)
}
val height = UIManager.getInt("TabbedPane.tabHeight") + tabbedPane.tabAreaInsets.top
if (SystemInfo.isWindows || SystemInfo.isLinux) {
rootPane.putClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT, true)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICON, false)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, false)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, height)
} else if (SystemInfo.isMacOS) {
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
rootPane.putClientProperty(
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING,
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING_MEDIUM
)
}
if (SystemInfo.isWindows || SystemInfo.isLinux) {
val sizes = listOf(16, 20, 24, 28, 32, 48, 64)
val loader = TermoraFrame::class.java.classLoader
val images = sizes.mapNotNull { e ->
loader.getResourceAsStream("icons/termora_${e}x${e}.png")?.use { ImageIO.read(it) }
}
iconImages = images
}
minimumSize = Dimension(640, 400)
terminalTabbed.addTerminalTab(welcomePanel)
val glassPane = GlassPane()
rootPane.glassPane = glassPane
glassPane.isOpaque = false
glassPane.isVisible = true
for (extension in ExtensionManager.getInstance().getExtensions(GlassPaneAwareExtension::class.java)) {
extension.setGlassPane(this, glassPane)
}
Disposer.register(windowScope, terminalTabbed)
add(terminalTabbed, BorderLayout.CENTER)
dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane)
dataProviderSupport.addData(DataProviders.TermoraFrame, this)
dataProviderSupport.addData(DataProviders.WindowScope, windowScope)
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey)
?: terminalTabbed.getData(dataKey)
?: welcomePanel.getData(dataKey)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TermoraFrame
return id == other.id
}
override fun hashCode(): Int {
return id.hashCode()
}
fun addNotifyListener(listener: NotifyListener) {
notifyListeners += listener
}
fun removeNotifyListener(listener: NotifyListener) {
notifyListeners = ArrayUtils.removeElements(notifyListeners, listener)
}
override fun addNotify() {
super.addNotify()
notifyListeners.forEach { it.addNotify() }
return object : MouseAdapter() {}
}

View File

@@ -0,0 +1,18 @@
package app.termora
import app.termora.database.DatabaseManager
enum class TermoraLayout {
/**
* Split
*/
Fence,
Screen, ;
companion object {
val Layout by lazy {
runCatching { TermoraLayout.valueOf(DatabaseManager.getInstance().appearance.layout) }.getOrNull() ?: Screen
}
}
}

View File

@@ -0,0 +1,30 @@
package app.termora
import app.termora.actions.DataProviders
import app.termora.tree.NewHostTree
import java.awt.BorderLayout
import java.util.*
import javax.swing.JPanel
class TermoraScreenPanel(private val windowScope: WindowScope, private val terminalTabbed: TerminalTabbed) :
JPanel(BorderLayout()) {
private val welcomePanel = WelcomePanel()
init {
initView()
initEvents()
}
private fun initView() {
add(terminalTabbed, BorderLayout.CENTER)
terminalTabbed.addTerminalTab(welcomePanel, true)
}
private fun initEvents() {
Disposer.register(windowScope, welcomePanel)
}
fun getHostTree(): NewHostTree {
return Objects.requireNonNull<NewHostTree>(welcomePanel.getData(DataProviders.Welcome.HostTree))
}
}

View File

@@ -10,7 +10,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import java.util.function.Consumer
import javax.swing.PopupFactory
import javax.swing.SwingUtilities
import javax.swing.UIManager
@@ -118,11 +117,7 @@ internal class ThemeManager private constructor() {
private fun immediateChange(classname: String) {
try {
val oldPopupFactory = PopupFactory.getSharedInstance()
UIManager.setLookAndFeel(classname)
PopupFactory.setSharedInstance(oldPopupFactory)
} catch (ex: Exception) {
log.error(ex.message, ex)
}

View File

@@ -4,14 +4,9 @@ package app.termora
import app.termora.actions.*
import app.termora.database.DatabaseManager
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProviderExtension
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.plugin.internal.extension.DynamicExtensionHandler
import app.termora.plugin.internal.ssh.SSHProtocolProvider
import app.termora.terminal.DataKey
import app.termora.tree.*
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.FlatButton
import org.apache.commons.lang3.StringUtils
@@ -24,8 +19,7 @@ import java.awt.event.*
import javax.swing.*
import kotlin.math.max
class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()), Disposable, TerminalTab,
DataProvider {
class WelcomePanel() : JPanel(BorderLayout()), Disposable, TerminalTab, DataProvider {
private val properties get() = DatabaseManager.getInstance().properties
private val rootPanel = JPanel(BorderLayout())
@@ -52,6 +46,7 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
val panel = JPanel(BorderLayout())
panel.add(createSearchPanel(), BorderLayout.NORTH)
panel.add(createHostPanel(), BorderLayout.CENTER)
bannerPanel.foreground = UIManager.getColor("TextField.placeholderForeground")
if (!fullContent) {
rootPanel.add(bannerPanel, BorderLayout.NORTH)
@@ -209,44 +204,6 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
}
})
DynamicExtensionHandler.getInstance()
.register(FindEverywhereProviderExtension::class.java, object : FindEverywhereProviderExtension {
private val provider = object : FindEverywhereProvider {
override fun find(pattern: String, scope: Scope): List<FindEverywhereResult> {
if (scope != windowScope) return emptyList()
var filter = hostTreeModel.root.getAllChildren()
.map { it.host }
.filter { it.isFolder.not() }
if (pattern.isNotBlank()) {
filter = filter.filter {
if (it.protocol == SSHProtocolProvider.PROTOCOL) {
it.name.contains(pattern, true) || it.host.contains(pattern, true)
} else {
it.name.contains(pattern, true)
}
}
}
return filter.map { HostFindEverywhereResult(it) }
}
override fun group(): String {
return I18n.getString("termora.find-everywhere.groups.open-new-hosts")
}
override fun order(): Int {
return Integer.MIN_VALUE + 2
}
}
override fun getFindEverywhereProvider(): FindEverywhereProvider {
return provider
}
}).let { Disposer.register(this, it) }
}
private fun perform() {
@@ -302,40 +259,6 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
properties.putString("WelcomeFullContent", fullContent.toString())
}
private inner class HostFindEverywhereResult(val host: Host) : FindEverywhereResult {
private val showMoreInfo get() = EnableManager.getInstance().isShowMoreInfo()
override fun actionPerformed(e: ActionEvent) {
ActionManager.getInstance()
.getAction(OpenHostAction.OPEN_HOST)
?.actionPerformed(OpenHostActionEvent(e.source, host, e))
}
override fun getIcon(isSelected: Boolean): Icon {
if (isSelected) {
if (!FlatLaf.isLafDark()) {
return Icons.terminal.dark
}
}
return Icons.terminal
}
override fun getText(isSelected: Boolean): String {
if (showMoreInfo) {
val color = UIManager.getColor(if (isSelected) "textHighlightText" else "textInactiveText")
val moreInfo = when (host.protocol) {
SSHProtocolProvider.PROTOCOL -> "${host.username}@${host.host}"
"Serial" -> host.options.serialComm.port
else -> StringUtils.EMPTY
}
if (moreInfo.isNotBlank()) {
return "<html>${host.name}&nbsp;&nbsp;&nbsp;&nbsp;<font color=rgb(${color.red},${color.green},${color.blue})>${moreInfo}</font></html>"
}
}
return host.name
}
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey)
}

View File

@@ -183,8 +183,8 @@ object AccountHttp {
if (isRefreshing.compareAndSet(false, true)) {
try {
// 刷新 token
accountManager.refreshToken()
// 刷新 token 和用户
accountManager.refresh()
} finally {
lock.withLock {
isRefreshing.set(false)

View File

@@ -14,12 +14,15 @@ import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.codec.binary.Base64
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.security.PrivateKey
import java.security.PublicKey
import javax.swing.SwingUtilities
class AccountManager private constructor() : ApplicationRunnerExtension {
companion object {
private val log = LoggerFactory.getLogger(AccountManager::class.java)
fun getInstance(): AccountManager {
return ApplicationScope.forApplicationScope()
.getOrCreate(AccountManager::class) { AccountManager() }
@@ -30,6 +33,7 @@ class AccountManager private constructor() : ApplicationRunnerExtension {
}
}
private val serverManager get() = ServerManager.getInstance()
private var account = locally()
private val accountProperties get() = AccountProperties.getInstance()
@@ -48,10 +52,14 @@ class AccountManager private constructor() : ApplicationRunnerExtension {
fun getAccessToken() = account.accessToken
fun getRefreshToken() = account.refreshToken
fun getOwnerIds() = account.teams.map { it.id }.toMutableList().apply { add(getAccountId()) }.toSet()
fun getOwners() =
account.teams.map { AccountOwner(it.id, it.name, OwnerType.Team) }
.toMutableList().apply { AccountOwner(getAccountId(), getEmail(), OwnerType.User) }
.toSet()
fun getOwners(): Set<AccountOwner> {
val owners = mutableSetOf<AccountOwner>()
owners.add(AccountOwner(getAccountId(), getEmail(), OwnerType.User))
for (team in getTeams()) {
owners.add(AccountOwner(team.id, team.name, OwnerType.Team))
}
return owners
}
fun isFreePlan(): Boolean {
return isLocally() || getSubscription().plan == SubscriptionPlan.Free
@@ -126,6 +134,7 @@ class AccountManager private constructor() : ApplicationRunnerExtension {
* 设置账户信息,可以多次调用,每次修改用户信息都要通过这个方法
*/
internal fun login(account: Account) {
synchronized(this) {
val oldAccount = this.account
@@ -158,6 +167,7 @@ class AccountManager private constructor() : ApplicationRunnerExtension {
// 通知变化
notifyAccountChanged(oldAccount, account)
}
}
private fun notifyAccountChanged(oldAccount: Account, newAccount: Account) {
if (SwingUtilities.isEventDispatchThread()) {
@@ -220,7 +230,7 @@ class AccountManager private constructor() : ApplicationRunnerExtension {
override fun ready() {
if (isLocally().not()) {
swingCoroutineScope.launch(Dispatchers.IO) { refreshToken() }
swingCoroutineScope.launch(Dispatchers.IO) { refresh() }
}
}
@@ -228,8 +238,34 @@ class AccountManager private constructor() : ApplicationRunnerExtension {
/**
* 刷新用户
*/
fun refresh(accessToken: String = getAccessToken()) {
fun refresh() {
runCatching { refreshToken() }.onSuccess {
refreshAccount()
}.onFailure {
if (log.isErrorEnabled) {
log.error(it.message, it)
}
}
}
fun refreshAccount() {
try {
val me = serverManager.callMe(account.server, getAccessToken())
val teams = me.teams.map {
Team(
id = it.id,
name = it.name,
secretKey = RSA.decrypt(getPrivateKey(), Base64.decodeBase64(it.secretKey)),
role = it.role
)
}
// 重新登录
login(account.copy(teams = teams, subscriptions = me.subscriptions))
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
class AccountApplicationRunnerExtension private constructor() : ApplicationRunnerExtension {

View File

@@ -75,8 +75,10 @@ class AccountOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable {
val subscription = accountManager.getSubscription()
val isFreePlan = accountManager.isFreePlan()
val isLocally = accountManager.isLocally()
val validTo = if (isFreePlan) "-" else if (subscription.endAt >= Long.MAX_VALUE)
I18n.getString("termora.settings.account.lifetime") else
val validTo = if (isFreePlan) "-"
else if (subscription.endAt >= Long.MAX_VALUE)
I18n.getString("termora.settings.account.lifetime")
else
DateFormatUtils.format(Date(subscription.endAt), I18n.getString("termora.date-format"))
val lastSynchronizationOn = if (isFreePlan) "-" else
DateFormatUtils.format(
@@ -158,9 +160,19 @@ class AccountOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable {
if (isFreePlan.not()) {
actions.add(JXHyperlink(object : AnAction(I18n.getString("termora.settings.account.sync-now")) {
override fun actionPerformed(evt: AnActionEvent) {
// 全量同步
accountProperties.nextSynchronizationSince = 0
// 拉取
PullService.getInstance().trigger()
// 推送
PushService.getInstance().trigger()
swingCoroutineScope.launch(Dispatchers.IO) {
// 刷新账户
accountManager.refreshAccount()
withContext(Dispatchers.Swing) {
isEnabled = false
lastSynchronizationOnLabel.text = DateFormatUtils.format(

View File

@@ -2,5 +2,4 @@ package app.termora.account
import app.termora.database.OwnerType
data class AccountOwner(val id: String, val name: String, val type: OwnerType) {
}
data class AccountOwner(val id: String, val name: String, val type: OwnerType)

View File

@@ -48,6 +48,7 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) {
Server(I18n.getString("termora.settings.account.server-singapore"), "https://account.termora.app")
private val chinaServer =
Server(I18n.getString("termora.settings.account.server-china"), "https://account.termora.cn")
private val serverManager get() = ServerManager.getInstance()
init {
isModal = true
@@ -359,7 +360,7 @@ class LoginServerDialog(owner: Window) : DialogWrapper(owner) {
val loginJob = swingCoroutineScope.launch(Dispatchers.IO) {
try {
ServerManager.getInstance().login(
serverManager.login(
server, usernameTextField.text,
String(passwordField.password), mfaTextField.text.trim()
)

View File

@@ -45,6 +45,15 @@ class PullService private constructor() : SyncService(), Disposable, Application
lastChangeHash = StringUtils.EMPTY
}
// 团队变了,全量同步
if (oldAccount.id == newAccount.id) {
if (oldAccount.teams != newAccount.teams) {
accountProperties.nextSynchronizationSince = 0
trigger()
return
}
}
if (oldAccount.isLocally && newAccount.isLocally.not()) {
trigger()
}
@@ -281,7 +290,7 @@ class PullService private constructor() : SyncService(), Disposable, Application
ownerId = ownerId,
ownerType = ownerType,
type = type,
data = decryptData(id, data),
data = decryptData(id, data, ownerId),
version = version,
// 因为已经是拉取最新版本了,所以这里无需再同步了
synced = true,
@@ -298,11 +307,8 @@ class PullService private constructor() : SyncService(), Disposable, Application
if (log.isDebugEnabled) {
log.debug("数据: {}, 类型: {} 云端已经删除,本地即将删除", id, type)
}
databaseManager.delete(
id, type,
DatabaseChangedExtension.Source.Sync
)
databaseManager.delete(id, type, DatabaseChangedExtension.Source.Sync)
if (log.isInfoEnabled) {
log.info("数据: {}, 类型: {} 已从本地删除", id, type)
@@ -340,7 +346,7 @@ class PullService private constructor() : SyncService(), Disposable, Application
ownerId = ownerId,
ownerType = ownerType,
type = type,
data = decryptData(id, data),
data = decryptData(id, data, ownerId),
version = version,
// 因为已经是拉取最新版本了,所以这里无需再同步了
synced = true,
@@ -377,7 +383,7 @@ class PullService private constructor() : SyncService(), Disposable, Application
pullChanges()
// N 秒拉一次
val result = withTimeoutOrNull(Random.nextInt(5, 15).seconds) {
val result = withTimeoutOrNull(Random.nextInt(3, 10).seconds) {
channel.receiveCatching()
} ?: continue
if (result.isFailure) break

View File

@@ -4,6 +4,7 @@ import app.termora.*
import app.termora.Application.ohMyJson
import app.termora.database.Data
import app.termora.database.DatabaseChangedExtension
import app.termora.database.OwnerType
import app.termora.plugin.DispatchThread
import app.termora.plugin.internal.extension.DynamicExtensionHandler
import kotlinx.coroutines.Dispatchers
@@ -106,7 +107,20 @@ class PushService private constructor() : SyncService(), Disposable, Application
.delete()
.build()
try {
AccountHttp.execute(request = request)
} catch (e: Exception) {
if (e is ResponseException) {
if (e.code == 403) {
// 如果是 Team 发现没有权限,那么很有可能是被提出团队
if (data.ownerType == OwnerType.Team.name) {
// 刷新用户
accountManager.refreshAccount()
}
}
}
throw e
}
// 修改为已经同步
updateData(data.id, synced = true)
@@ -153,6 +167,12 @@ class PushService private constructor() : SyncService(), Disposable, Application
}
// 标记为已经同步
updateData(data.id, synced = true, version = data.version)
// 如果是 Team 发现没有权限,那么很有可能是被提出团队
if (data.ownerType == OwnerType.Team.name) {
// 刷新用户
accountManager.refreshAccount()
}
return
} else if (response.code == 409) { // 版本冲突,一般来说是云端版本大于本地版本
val json = ohMyJson.decodeFromString<JsonObject>(text)

View File

@@ -54,7 +54,7 @@ class ServerManager private constructor() {
val loginResponse = callLogin(serverInfo, server, username, password, mfa)
// call me
val meResponse = callMe(server, loginResponse.accessToken)
val meResponse = callMe(server.server, loginResponse.accessToken)
// 解密
val salt = "${serverInfo.salt}:${username}".toByteArray()
@@ -139,9 +139,9 @@ class ServerManager private constructor() {
}
private fun callMe(server: Server, accessToken: String): MeResponse {
fun callMe(server: String, accessToken: String): MeResponse {
val request = Request.Builder()
.url("${server.server}/v1/users/me")
.url("${server}/v1/users/me")
.header("Authorization", "Bearer $accessToken")
.build()
val text = AccountHttp.execute(request = request)
@@ -149,13 +149,13 @@ class ServerManager private constructor() {
}
@Serializable
private data class ServerInfo(val salt: String)
data class ServerInfo(val salt: String)
@Serializable
private data class LoginResponse(val accessToken: String, val refreshToken: String)
data class LoginResponse(val accessToken: String, val refreshToken: String)
@Serializable
private data class MeResponse(
data class MeResponse(
val id: String,
val email: String,
val publicKey: String,
@@ -167,5 +167,5 @@ class ServerManager private constructor() {
@Serializable
private data class MeTeam(val id: String, val name: String, val role: TeamRole, val secretKey: String)
data class MeTeam(val id: String, val name: String, val role: TeamRole, val secretKey: String)
}

View File

@@ -78,28 +78,23 @@ abstract class SyncService {
protected fun encryptData(id: String, data: String, ownerId: String): String {
val iv = DigestUtils.sha256(id).copyOf(12)
var secretKey = EMPTY_BYTE_ARRAY
if (ownerId != accountManager.getAccountId()) {
val team = accountManager.getTeams().firstOrNull { it.id == ownerId }
if (team == null) {
return StringUtils.EMPTY
} else {
secretKey = team.secretKey
}
} else if (ownerId == accountManager.getAccountId()) {
secretKey = accountManager.getSecretKey()
}
val secretKey = getSecretKey(ownerId)
if (secretKey.isEmpty()) return StringUtils.EMPTY
return Base64.encodeBase64String(AES.GCM.encrypt(secretKey, iv, data.toByteArray()))
}
protected fun decryptData(id: String, data: String): String {
protected fun getSecretKey(ownerId: String): ByteArray {
if (ownerId == accountManager.getAccountId()) {
return accountManager.getSecretKey()
}
val team = accountManager.getTeams().firstOrNull { it.id == ownerId }
return team?.secretKey ?: EMPTY_BYTE_ARRAY
}
protected fun decryptData(id: String, data: String, ownerId: String): String {
val iv = DigestUtils.sha256(id).copyOf(12)
return String(
AES.GCM.decrypt(
accountManager.getSecretKey(), iv,
Base64.decodeBase64(data)
)
)
val secretKey = getSecretKey(ownerId)
if (secretKey.isEmpty()) throw IllegalStateException("根据 ownerId 无法获取对应密钥")
return String(AES.GCM.decrypt(secretKey, iv, Base64.decodeBase64(data)))
}
}

View File

@@ -26,4 +26,22 @@ class Team(
* 所属角色
*/
val role: TeamRole,
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Team
if (id != other.id) return false
if (name != other.name) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + name.hashCode()
return result
}
}

View File

@@ -1,5 +1,6 @@
package app.termora.actions
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.BoundAction
import java.awt.event.ActionEvent
import javax.swing.Icon
@@ -20,7 +21,7 @@ abstract class AnAction : BoundAction {
if (evt is AnActionEvent) {
actionPerformed(evt)
} else {
actionPerformed(AnActionEvent(evt.source, evt.actionCommand, evt))
actionPerformed(AnActionEvent(evt.source, StringUtils.defaultString(evt.actionCommand), evt))
}
}

View File

@@ -1,6 +1,7 @@
package app.termora.actions
import app.termora.NewHostDialogV2
import app.termora.account.AccountManager
import app.termora.tree.HostTreeNode
import javax.swing.tree.TreePath
@@ -14,6 +15,8 @@ class NewHostAction : AnAction() {
}
private val accountManager get() = AccountManager.getInstance()
override fun actionPerformed(evt: AnActionEvent) {
val tree = evt.getData(DataProviders.Welcome.HostTree) ?: return
var lastNode = (tree.lastSelectedPathComponent ?: tree.model.root) as? HostTreeNode ?: return
@@ -27,7 +30,7 @@ class NewHostAction : AnAction() {
}
val lastHost = lastNode.host
val dialog = NewHostDialogV2(evt.window)
val dialog = NewHostDialogV2(evt.window, accountOwner = accountManager.getOwners().first { it.id == lastHost.ownerId })
dialog.setLocationRelativeTo(evt.window)
dialog.isVisible = true
val host = (dialog.host ?: return).copy(

View File

@@ -11,7 +11,6 @@ import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.macro.MacroManager
import app.termora.plugin.ExtensionManager
import app.termora.plugin.internal.extension.DynamicExtensionHandler
import app.termora.snippet.SnippetManager
import app.termora.terminal.CursorStyle
@@ -23,6 +22,7 @@ import org.jetbrains.exposed.v1.core.statements.StatementType
import org.jetbrains.exposed.v1.jdbc.*
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.*
import java.util.concurrent.locks.ReentrantLock
@@ -32,11 +32,8 @@ import kotlin.reflect.KProperty
class DatabaseManager private constructor() : Disposable {
companion object {
val log: Logger = LoggerFactory.getLogger(DatabaseManager::class.java)
private const val DB_PASSWORD = "DB_PASSWORD"
private const val DB_SALT = "DB_SALT"
val log = LoggerFactory.getLogger(DatabaseManager::class.java)!!
fun getInstance(): DatabaseManager {
return ApplicationScope.forApplicationScope()
.getOrCreate(DatabaseManager::class) { DatabaseManager() }
@@ -52,14 +49,6 @@ class DatabaseManager private constructor() : Disposable {
val sftp by lazy { SFTP(this) }
@Volatile
internal var dbPassword = StringUtils.EMPTY
private set
@Volatile
internal var dbSalt = StringUtils.EMPTY
private set
private val map = Collections.synchronizedMap<String, String?>(mutableMapOf())
private val accountManager get() = AccountManager.getInstance()
@@ -101,11 +90,6 @@ class DatabaseManager private constructor() : Disposable {
// 注册动态扩展
registerDynamicExtensions()
for (extension in ExtensionManager.getInstance().getExtensions(DatabaseReadyExtension::class.java)) {
extension.ready(this)
}
}
private fun registerDynamicExtensions() {
@@ -308,6 +292,7 @@ class DatabaseManager private constructor() : Disposable {
DataEntity.update({ DataEntity.id eq id }) {
it[DataEntity.deleted] = true
// 如果是本地用户,那么删除是不需要同步的,云端用户才需要同步
// 云端用户也会判断,如果来源 Sync 那么默认同步了
it[DataEntity.synced] = accountManager.isLocally()
it[DataEntity.data] = StringUtils.EMPTY
}
@@ -367,7 +352,6 @@ class DatabaseManager private constructor() : Disposable {
private inner class AccountDataTransferExtension : AccountExtension {
private val hostManager get() = HostManager.getInstance()
override fun onAccountChanged(oldAccount: Account, newAccount: Account) {
if (oldAccount.isLocally && newAccount.isLocally) {
return
@@ -481,13 +465,10 @@ class DatabaseManager private constructor() : Disposable {
return
}
// 如果团队变更,那么删除所有旧的团队数据,静默删除
if (oldAccount.id == newAccount.id) {
return
}
if (oldAccount.teams != newAccount.teams) {
for (team in oldAccount.teams) {
// 如果被踢出团队,那么移除该团队的所有资产
if (newAccount.teams.none { it.id == team.id }) {
lock.withLock {
transaction(database) {
DataEntity.deleteWhere {
@@ -499,6 +480,7 @@ class DatabaseManager private constructor() : Disposable {
}
}
}
}
}
@@ -637,6 +619,11 @@ class DatabaseManager private constructor() : Disposable {
*/
var font by StringPropertyDelegate("JetBrains Mono")
/**
* 回退字体
*/
var fallbackFont by StringPropertyDelegate(StringUtils.EMPTY)
/**
* 默认终端
*/
@@ -722,6 +709,11 @@ class DatabaseManager private constructor() : Disposable {
*/
var theme by StringPropertyDelegate("Light")
/**
* 布局
*/
var layout by StringPropertyDelegate(TermoraLayout.Screen.name)
/**
* 跟随系统
*/

View File

@@ -1,37 +0,0 @@
package app.termora.database
import app.termora.database.DatabaseManager.Companion.log
import app.termora.plugin.DispatchThread
import app.termora.plugin.Extension
import app.termora.plugin.ExtensionManager
import javax.swing.SwingUtilities
interface DatabaseReadyExtension : Extension {
companion object {
fun fireReady(databaseManager: DatabaseManager) {
if (SwingUtilities.isEventDispatchThread()) {
for (extension in ExtensionManager.getInstance().getExtensions(DatabaseReadyExtension::class.java)) {
try {
extension.ready(databaseManager)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
} else {
SwingUtilities.invokeLater { fireReady(databaseManager) }
}
}
}
/**
* 数据库初始化完成
*/
fun ready(databaseManager: DatabaseManager) {}
override fun getDispatchThread(): DispatchThread {
return DispatchThread.BGT
}
}

View File

@@ -107,7 +107,7 @@ class KeymapManager private constructor() : Disposable {
id = keymap.id,
ownerId = accountId,
ownerType = OwnerType.User.name,
type = DataType.KeywordHighlight.name,
type = DataType.Keymap.name,
data = keymap.toJSON(),
)
)

View File

@@ -130,7 +130,7 @@ class KeymapPanel : JPanel(BorderLayout()) {
)
if (!text.isNullOrBlank()) {
if (text != keymap.name) {
keymapManager.removeKeymap(keymap.name)
keymapManager.removeKeymap(keymap.id)
val newKeymap = cloneKeymap(text, keymap)
keymapManager.addKeymap(newKeymap)
keymapModel.removeElementAt(index)
@@ -152,7 +152,7 @@ class KeymapPanel : JPanel(BorderLayout()) {
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
keymapManager.removeKeymap(keymap.name)
keymapManager.removeKeymap(keymap.id)
keymapModel.removeElementAt(index)
}
}

View File

@@ -19,6 +19,7 @@ class KeyManagerDialog(
owner: Window,
private val selectMode: Boolean = false,
size: Dimension = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height")),
private val accountOwner: AccountOwner? = null,
) : DialogWrapper(owner) {
var ok: Boolean = false
@@ -56,12 +57,40 @@ class KeyManagerDialog(
tabbed.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
tabbed.tabPlacement = JTabbedPane.TOP
if (accountOwner == null || accountOwner.type == OwnerType.User) {
tabbed.addTab(
I18n.getString("termora.keymgr.my-keys"),
Icons.user,
KeyManagerPanel(AccountOwner(accountManager.getAccountId(), accountManager.getEmail(), OwnerType.User))
KeyManagerPanel(
AccountOwner(
accountManager.getAccountId(),
accountManager.getEmail(),
OwnerType.User
)
)
)
}
if (accountOwner != null && accountManager.hasTeamFeature()) {
for (team in accountManager.getTeams()) {
if (team.id == accountOwner.id) {
tabbed.addTab(
team.name,
Icons.cwmUsers,
KeyManagerPanel(
AccountOwner(
team.id,
team.name,
OwnerType.Team
)
)
)
return tabbed
}
}
}
if (accountManager.hasTeamFeature()) {
for (team in accountManager.getTeams()) {

View File

@@ -2,6 +2,7 @@ package app.termora.keymgr
import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.plugin.internal.ssh.SshClients
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnectorDelegate
@@ -40,7 +41,7 @@ class SSHCopyIdDialog(
}
}
private val terminalPanel by lazy {
terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate())
terminalPanelFactory.createTerminalPanel(null, terminal, PtyConnectorDelegate())
.apply { enableFloatingToolbar = false }
}
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

View File

@@ -11,8 +11,10 @@ import app.termora.plugin.internal.rdp.RDPInternalPlugin
import app.termora.plugin.internal.serial.SerialInternalPlugin
import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin
import app.termora.plugin.internal.ssh.SSHInternalPlugin
import app.termora.plugin.internal.telnet.TelnetInternalPlugin
import app.termora.plugin.internal.wsl.WSLInternalPlugin
import app.termora.swingCoroutineScope
import app.termora.terminal.panel.vw.FloatingToolbarPlugin
import app.termora.transfer.internal.local.LocalPlugin
import app.termora.transfer.internal.sftp.SFTPPlugin
import com.formdev.flatlaf.util.SystemInfo
@@ -110,12 +112,14 @@ internal class PluginManager private constructor() {
// ssh plugin
plugins.add(PluginDescriptor(SSHInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// serial plugin
plugins.add(PluginDescriptor(SerialInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// local plugin
plugins.add(PluginDescriptor(LocalInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// rdp plugin
plugins.add(PluginDescriptor(RDPInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// telnet plugin
plugins.add(PluginDescriptor(TelnetInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// serial plugin
plugins.add(PluginDescriptor(SerialInternalPlugin(), origin = PluginOrigin.Internal, version = version))
// wsl plugin
if (SystemUtils.IS_OS_WINDOWS) {
plugins.add(PluginDescriptor(WSLInternalPlugin(), origin = PluginOrigin.Internal, version = version))
@@ -127,6 +131,9 @@ internal class PluginManager private constructor() {
plugins.add(PluginDescriptor(LocalPlugin(), origin = PluginOrigin.Internal, version = version))
// sftp transfer plugin
plugins.add(PluginDescriptor(SFTPPlugin(), origin = PluginOrigin.Internal, version = version))
// floating
plugins.add(PluginDescriptor(FloatingToolbarPlugin(), origin = PluginOrigin.Internal, version = version))
}
private fun loadSystemPlugins() {

View File

@@ -10,7 +10,10 @@ import java.awt.Component
import java.awt.event.ItemEvent
import javax.swing.*
class BasicProxyOption(private val proxyTypes: List<ProxyType> = listOf(ProxyType.HTTP, ProxyType.SOCKS5)) :
class BasicProxyOption(
private val proxyTypes: List<ProxyType> = listOf(ProxyType.HTTP, ProxyType.SOCKS5),
private val authenticationTypes: List<AuthenticationType> = listOf(AuthenticationType.Password),
) :
JPanel(BorderLayout()), Option {
private val formMargin = "7dlu"
@@ -21,6 +24,10 @@ class BasicProxyOption(private val proxyTypes: List<ProxyType> = listOf(ProxyTyp
val proxyPortTextField = PortSpinner(1080)
val proxyAuthenticationTypeComboBox = FlatComboBox<AuthenticationType>()
constructor(proxyTypes: List<ProxyType> = listOf(ProxyType.HTTP, ProxyType.SOCKS5)) : this(
proxyTypes,
listOf(AuthenticationType.Password)
)
init {
initView()
@@ -67,7 +74,9 @@ class BasicProxyOption(private val proxyTypes: List<ProxyType> = listOf(ProxyTyp
}
proxyAuthenticationTypeComboBox.addItem(AuthenticationType.No)
proxyAuthenticationTypeComboBox.addItem(AuthenticationType.Password)
for (type in authenticationTypes) {
proxyAuthenticationTypeComboBox.addItem(type)
}
proxyUsernameTextField.text = "root"

View File

@@ -1,5 +1,6 @@
package app.termora.plugin.internal.local
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -14,7 +15,11 @@ internal class LocalProtocolHostPanelExtension private constructor() : ProtocolH
return LocalProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return LocalProtocolHostPanel()
}
override fun ordered(): Long {
return 1
}
}

View File

@@ -3,10 +3,8 @@ package app.termora.plugin.internal.local
import app.termora.*
import app.termora.actions.DataProvider
import app.termora.protocol.GenericProtocolProvider
import app.termora.protocol.ProtocolTestRequest
import app.termora.protocol.ProtocolTester
internal class LocalProtocolProvider private constructor() : GenericProtocolProvider, ProtocolTester {
internal class LocalProtocolProvider private constructor() : GenericProtocolProvider {
companion object {
val instance by lazy { LocalProtocolProvider() }
const val PROTOCOL = "local"
@@ -20,9 +18,6 @@ internal class LocalProtocolProvider private constructor() : GenericProtocolProv
return Icons.powershell
}
override fun canTestConnection(requester: ProtocolTestRequest): Boolean {
return true
}
override fun createTerminalTab(dataProvider: DataProvider, windowScope: WindowScope, host: Host): TerminalTab {
return LocalTerminalTab(windowScope, host)

View File

@@ -232,7 +232,8 @@ class PluginPanel(val descriptor: PluginPluginDescriptor) : JPanel(), Disposable
// 当有多个插件正在安装时,那么最后一个安装成功的询问是否重启
if (installing.get() <= 1) {
restarter.scheduleRestart(owner)
// 不阻塞按钮状态变更
SwingUtilities.invokeLater { restarter.scheduleRestart(owner) }
}
// 如果是更新,那么也需要刷新 InstalledPanel 下的按钮状态

View File

@@ -2,6 +2,7 @@ package app.termora.plugin.internal.plugin
import app.termora.*
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.util.SystemInfo
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
@@ -22,7 +23,6 @@ internal class PluginRepositoryDialog(owner: Window) : DialogWrapper(owner) {
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
isModal = true
isResizable = false
controlsVisible = false
title = "Custom Plugin Repository"
list.fixedCellHeight = UIManager.getInt("Tree.rowHeight")
for (url in PluginRepositoryManager.getInstance().getRepositories()) {
@@ -98,6 +98,21 @@ internal class PluginRepositoryDialog(owner: Window) : DialogWrapper(owner) {
return panel
}
override fun addNotify() {
super.addNotify()
if (SystemInfo.isMacOS) {
NativeMacLibrary.setControlsVisible(
this,
false,
arrayOf(
NativeMacLibrary.NSWindowButton.NSWindowZoomButton,
NativeMacLibrary.NSWindowButton.NSWindowMiniaturizeButton
)
)
}
}
override fun createSouthPanel(): JComponent? {
return null
}

View File

@@ -1,5 +1,6 @@
package app.termora.plugin.internal.rdp
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -14,7 +15,11 @@ internal class RDPProtocolHostPanelExtension private constructor() : ProtocolHos
return RDPProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return RDPProtocolHostPanel()
}
override fun ordered(): Long {
return 2
}
}

View File

@@ -1,5 +1,6 @@
package app.termora.plugin.internal.serial
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -14,8 +15,11 @@ internal class SerialProtocolHostPanelExtension private constructor() : Protocol
return SerialProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return SerialProtocolHostPanel()
}
override fun ordered(): Long {
return 5
}
}

View File

@@ -4,6 +4,7 @@ import app.termora.*
import app.termora.database.DatabaseManager
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.plugin.internal.ssh.SshClients
import app.termora.terminal.*
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.Charsets

View File

@@ -0,0 +1,44 @@
package app.termora.plugin.internal.ssh
import app.termora.I18n
import app.termora.TerminalTab
import app.termora.TerminalTabbedContextMenuExtension
import app.termora.WindowScope
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import javax.swing.JMenuItem
class CloneSessionTerminalTabbedContextMenuExtension private constructor() : TerminalTabbedContextMenuExtension {
companion object {
val instance = CloneSessionTerminalTabbedContextMenuExtension()
}
override fun createJMenuItem(
windowScope: WindowScope,
tab: TerminalTab
): JMenuItem {
if (tab is SSHTerminalTab) {
if (tab.host.protocol == SSHProtocolProvider.PROTOCOL) {
val cloneSession = JMenuItem(I18n.getString("termora.tabbed.contextmenu.clone-session"))
val c = tab.getData(SSHTerminalTab.MySshHandler)
cloneSession.isEnabled = c?.channel?.isOpen == true
if (c != null) {
cloneSession.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val handler = c.copy(channel = null)
val newTab = SSHTerminalTab(windowScope, tab.host, handler)
terminalTabbedManager.addTerminalTab(newTab)
newTab.start()
}
})
}
return cloneSession
}
}
throw UnsupportedOperationException()
}
}

View File

@@ -1,6 +1,7 @@
package app.termora.plugin.internal.ssh
import app.termora.*
import app.termora.account.AccountOwner
import app.termora.keymgr.KeyManager
import app.termora.keymgr.KeyManagerDialog
import app.termora.plugin.internal.BasicProxyOption
@@ -29,7 +30,7 @@ import javax.swing.table.DefaultTableModel
import kotlin.math.max
@Suppress("CascadeIf")
open class SSHHostOptionsPane : OptionsPane() {
open class SSHHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPane() {
protected val tunnelingOption = TunnelingOption()
protected val generalOption = GeneralOption()
protected val proxyOption = BasicProxyOption()
@@ -375,6 +376,7 @@ open class SSHHostOptionsPane : OptionsPane() {
val dialog = KeyManagerDialog(
owner,
selectMode = true,
accountOwner = accountOwner,
)
dialog.pack()
dialog.setLocationRelativeTo(owner)
@@ -383,7 +385,7 @@ open class SSHHostOptionsPane : OptionsPane() {
val selectedItem = publicKeyComboBox.selectedItem
publicKeyComboBox.removeAllItems()
for (keyPair in KeyManager.getInstance().getOhKeyPairs()) {
for (keyPair in KeyManager.getInstance().getOhKeyPairs(accountOwner.id)) {
publicKeyComboBox.addItem(keyPair.id)
}
publicKeyComboBox.selectedItem = selectedItem
@@ -465,7 +467,7 @@ open class SSHHostOptionsPane : OptionsPane() {
if (authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
val selectedItem = publicKeyComboBox.selectedItem
publicKeyComboBox.removeAllItems()
for (pair in KeyManager.getInstance().getOhKeyPairs()) {
for (pair in KeyManager.getInstance().getOhKeyPairs(accountOwner.id)) {
publicKeyComboBox.addItem(pair.id)
}
publicKeyComboBox.selectedItem = selectedItem

View File

@@ -1,5 +1,6 @@
package app.termora.plugin.internal.ssh
import app.termora.TerminalTabbedContextMenuExtension
import app.termora.plugin.Extension
import app.termora.plugin.InternalPlugin
import app.termora.protocol.ProtocolHostPanelExtension
@@ -9,6 +10,8 @@ internal class SSHInternalPlugin : InternalPlugin() {
init {
support.addExtension(ProtocolProviderExtension::class.java) { SSHProtocolProviderExtension.instance }
support.addExtension(ProtocolHostPanelExtension::class.java) { SSHProtocolHostPanelExtension.instance }
support.addExtension(TerminalTabbedContextMenuExtension::class.java) { SftpCommandTerminalTabbedContextMenuExtension.instance }
support.addExtension(TerminalTabbedContextMenuExtension::class.java) { CloneSessionTerminalTabbedContextMenuExtension.instance }
}
override fun getName(): String {

View File

@@ -2,12 +2,13 @@ package app.termora.plugin.internal.ssh
import app.termora.Disposer
import app.termora.Host
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import java.awt.BorderLayout
class SSHProtocolHostPanel : ProtocolHostPanel() {
class SSHProtocolHostPanel(accountOwner: AccountOwner) : ProtocolHostPanel() {
private val pane = SSHHostOptionsPane()
private val pane = SSHHostOptionsPane(accountOwner)
init {
initView()

View File

@@ -1,5 +1,6 @@
package app.termora.plugin.internal.ssh
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -14,8 +15,12 @@ internal class SSHProtocolHostPanelExtension private constructor() : ProtocolHos
return SSHProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
return SSHProtocolHostPanel()
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return SSHProtocolHostPanel(accountOwner)
}
override fun ordered(): Long {
return 0
}
}

View File

@@ -1,7 +1,6 @@
package app.termora.plugin.internal.ssh
import app.termora.*
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.actions.TabReconnectAction
import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor
@@ -11,45 +10,36 @@ import app.termora.keymap.KeymapManager
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import org.apache.commons.io.Charsets
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.SshConstants
import org.apache.sshd.common.channel.Channel
import org.apache.sshd.common.channel.ChannelListener
import org.apache.sshd.common.session.Session
import org.apache.sshd.common.session.SessionListener
import org.apache.sshd.common.future.CloseFuture
import org.apache.sshd.common.future.SshFutureListener
import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets
import java.util.*
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.SwingUtilities
import kotlin.time.Duration.Companion.milliseconds
class SSHTerminalTab(
windowScope: WindowScope, host: Host,
private val handler: SshHandler = SshHandler()
) : PtyHostTerminalTab(windowScope, host) {
class SSHTerminalTab(windowScope: WindowScope, host: Host) :
PtyHostTerminalTab(windowScope, host) {
companion object {
val SSHSession = DataKey(ClientSession::class)
internal val MySshHandler = DataKey(SshHandler::class)
private val log = LoggerFactory.getLogger(SSHTerminalTab::class.java)
}
private val mutex = Mutex()
private val tab = this
private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null
private var sshChannelShell: ChannelShell? = null
private val terminalTabbedManager
get() = AnActionEvent(getJComponent(), StringUtils.EMPTY, EventObject(getJComponent()))
.getData(DataProviders.TerminalTabbedManager)
private val owner get() = SwingUtilities.getWindowAncestor(terminalPanel)
private val tab get() = this
init {
terminalPanel.dropFiles = false
@@ -60,12 +50,10 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
return terminalPanel
}
override fun canReconnect(): Boolean {
return !mutex.isLocked
return mutex.isLocked.not()
}
override suspend fun openPtyConnector(): PtyConnector {
if (mutex.tryLock()) {
try {
@@ -87,74 +75,32 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
// hide cursor
terminalModel.setData(DataKey.Companion.ShowCursor, false)
// print
terminal.write("SSH client is opening...\r\n")
terminal.write("Connecting to remote server ")
}
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
val client = SshClients.openClient(host, owner).also { sshClient = it }
val sessionListener = MySessionListener()
val channelListener = MyChannelListener()
withContext(Dispatchers.Swing) { terminal.write("SSH client opened successfully.\r\n\r\n") }
client.addSessionListener(sessionListener)
client.addChannelListener(channelListener)
val (session, channel) = try {
val session = SshClients.openSession(host, client).also { sshSession = it }
val channel = SshClients.openShell(
host,
terminalPanel.winSize(),
session
).also { sshChannelShell = it }
Pair(session, channel)
} finally {
client.removeSessionListener(sessionListener)
client.removeChannelListener(channelListener)
}
// newline
withContext(Dispatchers.Swing) {
terminal.write("\r\n")
}
channel.addChannelListener(object : ChannelListener {
private val reconnectShortcut
get() = KeymapManager.Companion.getInstance().getActiveKeymap()
.getShortcut(TabReconnectAction.Companion.RECONNECT_TAB).firstOrNull()
override fun channelClosed(channel: Channel, reason: Throwable?) {
coroutineScope.launch(Dispatchers.Swing) {
terminal.write("\r\n\r\n${ControlCharacters.Companion.ESC}[31m")
terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
if (reconnectShortcut is KeyShortcut) {
terminal.write(
I18n.getString(
"termora.terminal.channel-reconnect",
reconnectShortcut.toString()
)
)
}
terminal.write("\r\n")
terminal.write("${ControlCharacters.Companion.ESC}[0m")
terminalModel.setData(DataKey.Companion.ShowCursor, false)
if (DatabaseManager.getInstance().terminal.autoCloseTabWhenDisconnected) {
terminalTabbedManager?.let { manager ->
SwingUtilities.invokeLater {
manager.closeTerminalTab(tab, true)
}
val loading = coroutineScope.launch(Dispatchers.Swing) {
var c = 0
while (isActive) {
if (++c > 6) c = 1
terminal.write("${ControlCharacters.ESC}[1;32m")
terminal.write(".".repeat(c))
terminal.write(" ".repeat(6 - c))
terminal.write("${ControlCharacters.ESC}[0m")
delay(350.milliseconds)
terminal.write("${ControlCharacters.BS}".repeat(6))
}
}
// stop
stop()
}
}
})
val channel: ChannelShell
try {
val client = openClient()
val session = openSession(client)
channel = openChannel(session)
// 打开隧道
openTunnelings(session, host)
} finally {
loading.cancel()
}
// 隐藏提示
withContext(Dispatchers.Swing) {
@@ -199,10 +145,68 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
}
}
private fun openClient(): SshClient {
val client = handler.client
if (client != null) return client
return SshClients.openClient(host, owner).also { handler.client = it }
}
private fun openSession(client: SshClient): ClientSession {
val session = handler.session
if (session != null) return SshSessionPool.register(session, client)
return SshClients.openSession(host, client).also { handler.session = SshSessionPool.register(it, client) }
}
private fun openChannel(session: ClientSession): ChannelShell {
val channel = SshClients.openShell(host, terminalPanel.winSize(), session)
handler.channel = channel
channel.addCloseFutureListener(object : SshFutureListener<CloseFuture> {
private val reconnectShortcut
get() = KeymapManager.Companion.getInstance().getActiveKeymap()
.getShortcut(TabReconnectAction.Companion.RECONNECT_TAB).firstOrNull()
private val autoCloseTabWhenDisconnected get() = DatabaseManager.getInstance().terminal.autoCloseTabWhenDisconnected
override fun operationComplete(future: CloseFuture) {
coroutineScope.launch(Dispatchers.Swing) {
terminal.write("\r\n\r\n${ControlCharacters.Companion.ESC}[31m")
terminal.write(I18n.getString("termora.terminal.channel-disconnected"))
if (reconnectShortcut is KeyShortcut) {
terminal.write(
I18n.getString(
"termora.terminal.channel-reconnect",
reconnectShortcut.toString()
)
)
}
terminal.write("\r\n")
terminal.write("${ControlCharacters.Companion.ESC}[0m")
terminalModel.setData(DataKey.Companion.ShowCursor, false)
if (autoCloseTabWhenDisconnected) {
terminalTabbedManager?.let { manager ->
SwingUtilities.invokeLater {
manager.closeTerminalTab(tab, true)
}
}
}
// stop
stop()
}
}
})
return channel
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == SSHSession) {
return sshSession as T?
return handler.session as T?
}
if (dataKey == MySshHandler) {
return handler as T?
}
return super.getData(dataKey)
}
@@ -211,16 +215,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
if (mutex.tryLock()) {
try {
super.stop()
sshChannelShell?.close(true)
sshSession?.disableSessionHeartbeat()
sshSession?.disconnect(SshConstants.SSH2_DISCONNECT_BY_APPLICATION, StringUtils.EMPTY)
sshSession?.close(true)
sshClient?.close(true)
sshChannelShell = null
sshSession = null
sshClient = null
handler.close()
} finally {
mutex.unlock()
}
@@ -236,36 +231,4 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
terminalPanel.storeVisualWindows(host.id)
}
private inner class MySessionListener : SessionListener, Disposable {
override fun sessionEvent(session: Session, event: SessionListener.Event) {
coroutineScope.launch {
when (event) {
SessionListener.Event.KeyEstablished -> terminal.write("Session Key exchange successful.\r\n")
SessionListener.Event.Authenticated -> terminal.write("Session authentication successful.\r\n\r\n")
SessionListener.Event.KexCompleted -> terminal.write("Session KEX negotiation successful.\r\n")
}
}
}
override fun sessionEstablished(session: Session) {
coroutineScope.launch { terminal.write("Session established.\r\n") }
}
override fun sessionCreated(session: Session?) {
coroutineScope.launch { terminal.write("Session created.\r\n") }
}
}
private inner class MyChannelListener : ChannelListener, Disposable {
override fun channelOpenSuccess(channel: Channel) {
coroutineScope.launch { terminal.write("Channel shell opened successfully.\r\n") }
}
override fun channelInitialized(channel: Channel) {
coroutineScope.launch { terminal.write("Channel shell initialization successful.\r\n") }
}
}
}

View File

@@ -0,0 +1,72 @@
package app.termora.plugin.internal.ssh
import app.termora.*
import app.termora.actions.*
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
import app.termora.plugin.internal.sftppty.SFTPPtyTerminalTab
import app.termora.terminal.DataKey
import org.apache.commons.lang3.StringUtils
import java.util.*
import javax.swing.Action
import javax.swing.JMenuItem
import javax.swing.JOptionPane
class SftpCommandTerminalTabbedContextMenuExtension private constructor() : TerminalTabbedContextMenuExtension {
companion object {
val instance = SftpCommandTerminalTabbedContextMenuExtension()
}
private val actionManager = ActionManager.getInstance()
override fun createJMenuItem(
windowScope: WindowScope,
tab: TerminalTab
): JMenuItem {
if (tab is HostTerminalTab) {
val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST)
if (openHostAction != null) {
if (tab.host.protocol == SSHProtocolProvider.PROTOCOL || tab.host.protocol == SFTPPtyProtocolProvider.PROTOCOL) {
val sftpCommand = JMenuItem(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
sftpCommand.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
openSFTPPtyTab(tab, openHostAction, evt)
}
})
return sftpCommand
}
}
}
throw UnsupportedOperationException()
}
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
if (SFTPPtyTerminalTab.canSupports.not()) {
OptionPane.showMessageDialog(
tab.windowScope.window,
I18n.getString("termora.tabbed.contextmenu.sftp-not-install"),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
var host = tab.host
if (host.protocol == SSHProtocolProvider.PROTOCOL) {
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 = SFTPPtyProtocolProvider.PROTOCOL,
options = host.options.copy(env = envs.toPropertiesString())
)
}
openHostAction.actionPerformed(OpenHostActionEvent(evt.source, host, evt))
}
}

View File

@@ -1,5 +1,6 @@
package app.termora
package app.termora.plugin.internal.ssh
import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize
@@ -29,7 +30,6 @@ import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.client.session.ClientSessionImpl
import org.apache.sshd.client.session.SessionFactory
import org.apache.sshd.common.AttributeRepository
import org.apache.sshd.common.SshConstants
import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.ChannelFactory
import org.apache.sshd.common.channel.PtyChannelConfiguration
@@ -63,7 +63,7 @@ import org.eclipse.jgit.internal.transport.sshd.proxy.AbstractClientProxyConnect
import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector
import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector
import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.SshConstants.IDENTITY_AGENT
import org.eclipse.jgit.transport.SshConstants
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory
import org.slf4j.LoggerFactory
@@ -89,7 +89,7 @@ object SshClients {
val HOST_KEY = AttributeRepository.AttributeKey<Host>()
private val timeout = Duration.ofSeconds(30)
private val hostManager get() = HostManager.getInstance()
private val hostManager get() = HostManager.Companion.getInstance()
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
/**
@@ -166,7 +166,7 @@ object SshClients {
}
val jumpHosts = mutableListOf<Host>()
val hosts = HostManager.getInstance().hosts().associateBy { it.id }
val hosts = HostManager.Companion.getInstance().hosts().associateBy { it.id }
for (jumpHostId in h.options.jumpHosts) {
val e = hosts[jumpHostId]
if (e == null) {
@@ -235,16 +235,16 @@ object SshClients {
if (SystemInfo.isMacOS) {
val file = FileUtils.getFile(Application.getBaseDataDir(), "config", "ssh-agent.sock")
if (file.exists()) {
entry.setProperty(IDENTITY_AGENT, file.absolutePath)
entry.setProperty(SshConstants.IDENTITY_AGENT, file.absolutePath)
}
}
if (entry.getProperty(IDENTITY_AGENT).isNullOrBlank()) {
if (entry.getProperty(SshConstants.IDENTITY_AGENT).isNullOrBlank()) {
if (host.authentication.password.isNotBlank())
entry.setProperty(IDENTITY_AGENT, host.authentication.password)
entry.setProperty(SshConstants.IDENTITY_AGENT, host.authentication.password)
else if (SystemInfo.isWindows)
entry.setProperty(IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent)
entry.setProperty(SshConstants.IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent)
else
entry.setProperty(IDENTITY_AGENT, UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
entry.setProperty(SshConstants.IDENTITY_AGENT, UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
}
}
@@ -272,7 +272,7 @@ object SshClients {
throw SshException("Authentication failed")
}
} catch (e: Exception) {
if (e !is SshException || e.disconnectCode != SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e
if (e !is SshException || e.disconnectCode != org.apache.sshd.common.SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e
val owner = client.properties["owner"] as Window? ?: throw e
val askUserInfo = ask(host, entry, owner) ?: throw e
if (askUserInfo.authentication.type == AuthenticationType.No) throw e
@@ -383,7 +383,7 @@ object SshClients {
val channelFactories = mutableListOf<ChannelFactory>()
channelFactories.addAll(ClientBuilder.DEFAULT_CHANNEL_FACTORIES)
channelFactories.add(X11ChannelFactory.INSTANCE)
channelFactories.add(X11ChannelFactory.Companion.INSTANCE)
builder.channelFactories(channelFactories)
val sshClient = builder.build() as JGitSshClient
@@ -726,4 +726,3 @@ object SshClients {
}
}

View File

@@ -0,0 +1,27 @@
package app.termora.plugin.internal.ssh
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.channel.Channel
data class SshHandler(
var client: SshClient? = null,
var session: ClientSession? = null,
var channel: Channel? = null
) : AutoCloseable {
override fun close() {
channel?.close(true)?.await()
session?.close(true)?.await()
channel = null
session = null
// client 由 SshSessionPool 负责关闭
if (client?.isClosing == true || client?.isClosed == true) {
client = null
}
}
}

View File

@@ -0,0 +1,394 @@
package app.termora.plugin.internal.ssh
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ChannelExec
import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.client.session.forward.DynamicPortForwardingTracker
import org.apache.sshd.client.session.forward.ExplicitPortForwardingTracker
import org.apache.sshd.common.AttributeRepository
import org.apache.sshd.common.channel.Channel
import org.apache.sshd.common.channel.PtyChannelConfigurationHolder
import org.apache.sshd.common.channel.throttle.ChannelStreamWriter
import org.apache.sshd.common.channel.throttle.ChannelStreamWriterResolver
import org.apache.sshd.common.future.CloseFuture
import org.apache.sshd.common.future.DefaultCloseFuture
import org.apache.sshd.common.io.IoWriteFuture
import org.apache.sshd.common.session.SessionHeartbeatController
import org.apache.sshd.common.util.buffer.Buffer
import org.apache.sshd.common.util.net.SshdSocketAddress
import java.io.OutputStream
import java.net.SocketAddress
import java.nio.charset.Charset
import java.time.Duration
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.function.Function
internal object SshSessionPool {
private val map = WeakHashMap<ClientSession, MyClientSession>()
fun register(session: ClientSession, client: SshClient): ClientSession {
if (session is MyClientSession) {
session.refCount.incrementAndGet()
return session
}
synchronized(session) {
val delegate = map[session] ?: MyClientSession(client, session)
map[session] = delegate
delegate.refCount.incrementAndGet()
return delegate
}
}
private class MyClientSession(
private val client: SshClient,
private val delegate: ClientSession
) : ClientSession by delegate {
val refCount = AtomicInteger(0)
override fun createShellChannel(): ChannelShell? {
return delegate.createShellChannel()
}
override fun createExecChannel(command: String?): ChannelExec? {
return delegate.createExecChannel(command)
}
override fun createExecChannel(
command: String?,
ptyConfig: PtyChannelConfigurationHolder?,
env: Map<String?, *>?
): ChannelExec? {
return delegate.createExecChannel(command, ptyConfig, env)
}
override fun executeRemoteCommand(command: String?): String? {
return delegate.executeRemoteCommand(command)
}
override fun executeRemoteCommand(
command: String?,
stderr: OutputStream?,
charset: Charset?
): String? {
return delegate.executeRemoteCommand(command, stderr, charset)
}
override fun executeRemoteCommand(
command: String?,
stdout: OutputStream?,
stderr: OutputStream?,
charset: Charset?
) {
delegate.executeRemoteCommand(command, stdout, stderr, charset)
}
override fun createLocalPortForwardingTracker(
localPort: Int,
remote: SshdSocketAddress?
): ExplicitPortForwardingTracker? {
return delegate.createLocalPortForwardingTracker(localPort, remote)
}
override fun createLocalPortForwardingTracker(
local: SshdSocketAddress?,
remote: SshdSocketAddress?
): ExplicitPortForwardingTracker? {
return delegate.createLocalPortForwardingTracker(local, remote)
}
override fun createRemotePortForwardingTracker(
remote: SshdSocketAddress?,
local: SshdSocketAddress?
): ExplicitPortForwardingTracker? {
return delegate.createRemotePortForwardingTracker(remote, local)
}
override fun createDynamicPortForwardingTracker(local: SshdSocketAddress?): DynamicPortForwardingTracker? {
return delegate.createDynamicPortForwardingTracker(local)
}
override fun waitFor(
mask: Collection<ClientSession.ClientSessionEvent?>?,
timeout: Duration?
): Set<ClientSession.ClientSessionEvent?>? {
return delegate.waitFor(mask, timeout)
}
override fun createBuffer(cmd: Byte): Buffer? {
return delegate.createBuffer(cmd)
}
override fun writePacket(
buffer: Buffer?,
timeout: Duration?
): IoWriteFuture? {
return delegate.writePacket(buffer, timeout)
}
override fun writePacket(
buffer: Buffer?,
maxWaitMillis: Long
): IoWriteFuture? {
return delegate.writePacket(buffer, maxWaitMillis)
}
override fun request(
request: String?,
buffer: Buffer?,
timeout: Long,
unit: TimeUnit?
): Buffer? {
return delegate.request(request, buffer, timeout, unit)
}
override fun request(
request: String?,
buffer: Buffer?,
timeout: Duration?
): Buffer? {
return delegate.request(request, buffer, timeout)
}
override fun getLocalAddress(): SocketAddress? {
return delegate.getLocalAddress()
}
override fun getRemoteAddress(): SocketAddress? {
return delegate.getRemoteAddress()
}
override fun <T : Any?> resolveAttribute(key: AttributeRepository.AttributeKey<T?>?): T? {
return delegate.resolveAttribute(key)
}
override fun getSessionHeartbeatType(): SessionHeartbeatController.HeartbeatType? {
return delegate.getSessionHeartbeatType()
}
override fun getSessionHeartbeatInterval(): Duration? {
return delegate.getSessionHeartbeatInterval()
}
override fun disableSessionHeartbeat() {
delegate.disableSessionHeartbeat()
}
override fun setSessionHeartbeat(
type: SessionHeartbeatController.HeartbeatType?,
unit: TimeUnit?,
count: Long
) {
delegate.setSessionHeartbeat(type, unit, count)
}
override fun setSessionHeartbeat(
type: SessionHeartbeatController.HeartbeatType?,
interval: Duration?
) {
delegate.setSessionHeartbeat(type, interval)
}
override fun isEmpty(): Boolean {
return delegate.isEmpty()
}
override fun getLongProperty(name: String?, def: Long): Long {
return delegate.getLongProperty(name, def)
}
override fun getLong(name: String?): Long? {
return delegate.getLong(name)
}
override fun getIntProperty(name: String?, def: Int): Int {
return delegate.getIntProperty(name, def)
}
override fun getInteger(name: String?): Int? {
return delegate.getInteger(name)
}
override fun getBooleanProperty(name: String?, def: Boolean): Boolean {
return delegate.getBooleanProperty(name, def)
}
override fun getBoolean(name: String?): Boolean? {
return delegate.getBoolean(name)
}
override fun getStringProperty(name: String?, def: String?): String? {
return delegate.getStringProperty(name, def)
}
override fun getString(name: String?): String? {
return delegate.getString(name)
}
override fun getObject(name: String?): Any? {
return delegate.getObject(name)
}
override fun getCharset(
name: String?,
defaultValue: Charset?
): Charset? {
return delegate.getCharset(name, defaultValue)
}
override fun <T : Any?> computeAttributeIfAbsent(
key: AttributeRepository.AttributeKey<T?>?,
resolver: Function<in AttributeRepository.AttributeKey<T>, out T?>?
): T? {
return delegate.computeAttributeIfAbsent(key, resolver)
}
override fun close() {
close(true)?.await()
}
override fun close(immediately: Boolean): CloseFuture? {
synchronized(delegate) {
if (refCount.decrementAndGet() < 1) {
delegate.close(immediately).await()
return client.close(immediately)
}
}
return DefaultCloseFuture(this, this).apply { setClosed() }
}
override fun isOpen(): Boolean {
return delegate.isOpen()
}
override fun getCipherFactoriesNameList(): String? {
return delegate.getCipherFactoriesNameList()
}
override fun getCipherFactoriesNames(): List<String?>? {
return delegate.getCipherFactoriesNames()
}
override fun setCipherFactoriesNameList(names: String?) {
delegate.setCipherFactoriesNameList(names)
}
override fun setCipherFactoriesNames(vararg names: String?) {
delegate.setCipherFactoriesNames(*names)
}
override fun setCipherFactoriesNames(names: Collection<String?>?) {
delegate.setCipherFactoriesNames(names)
}
override fun getCompressionFactoriesNameList(): String? {
return delegate.getCompressionFactoriesNameList()
}
override fun getCompressionFactoriesNames(): List<String?>? {
return delegate.getCompressionFactoriesNames()
}
override fun setCompressionFactoriesNameList(names: String?) {
delegate.setCompressionFactoriesNameList(names)
}
override fun setCompressionFactoriesNames(vararg names: String?) {
delegate.setCompressionFactoriesNames(*names)
}
override fun setCompressionFactoriesNames(names: Collection<String?>?) {
delegate.setCompressionFactoriesNames(names)
}
override fun getMacFactoriesNameList(): String? {
return delegate.getMacFactoriesNameList()
}
override fun getMacFactoriesNames(): List<String?>? {
return delegate.getMacFactoriesNames()
}
override fun setMacFactoriesNameList(names: String?) {
delegate.setMacFactoriesNameList(names)
}
override fun setMacFactoriesNames(vararg names: String?) {
delegate.setMacFactoriesNames(*names)
}
override fun setMacFactoriesNames(names: Collection<String?>?) {
delegate.setMacFactoriesNames(names)
}
override fun setSignatureFactoriesNameList(names: String?) {
delegate.setSignatureFactoriesNameList(names)
}
override fun setSignatureFactoriesNames(vararg names: String?) {
delegate.setSignatureFactoriesNames(*names)
}
override fun setSignatureFactoriesNames(names: Collection<String?>?) {
delegate.setSignatureFactoriesNames(names)
}
override fun getSignatureFactoriesNameList(): String? {
return delegate.getSignatureFactoriesNameList()
}
override fun getSignatureFactoriesNames(): List<String?>? {
return delegate.getSignatureFactoriesNames()
}
override fun resolveChannelStreamWriterResolver(): ChannelStreamWriterResolver? {
return delegate.resolveChannelStreamWriterResolver()
}
override fun resolveChannelStreamWriter(
channel: Channel?,
cmd: Byte
): ChannelStreamWriter? {
return delegate.resolveChannelStreamWriter(channel, cmd)
}
override fun isLocalPortForwardingStartedForPort(port: Int): Boolean {
return delegate.isLocalPortForwardingStartedForPort(port)
}
override fun isRemotePortForwardingStartedForPort(port: Int): Boolean {
return delegate.isRemotePortForwardingStartedForPort(port)
}
override fun setUserAuthFactoriesNames(names: Collection<String?>?) {
delegate.setUserAuthFactoriesNames(names)
}
override fun setUserAuthFactoriesNames(vararg names: String?) {
delegate.setUserAuthFactoriesNames(*names)
}
override fun getUserAuthFactoriesNameList(): String? {
return delegate.getUserAuthFactoriesNameList()
}
override fun getUserAuthFactoriesNames(): List<String?>? {
return delegate.getUserAuthFactoriesNames()
}
override fun setUserAuthFactoriesNameList(names: String?) {
delegate.setUserAuthFactoriesNameList(names)
}
override fun startLocalPortForwarding(
localPort: Int,
remote: SshdSocketAddress?
): SshdSocketAddress? {
return delegate.startLocalPortForwarding(localPort, remote)
}
}
}

View File

@@ -0,0 +1,448 @@
package app.termora.plugin.internal.telnet
import app.termora.*
import app.termora.account.AccountOwner
import app.termora.keymgr.KeyManager
import app.termora.plugin.internal.BasicProxyOption
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.ui.FlatTextBorder
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout
import java.awt.Component
import java.awt.KeyboardFocusManager
import java.awt.Window
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.nio.charset.Charset
import javax.swing.*
class TelnetHostOptionsPane(private val accountOwner: AccountOwner) : OptionsPane() {
protected val generalOption = GeneralOption()
// telnet 不支持代理密码
private val proxyOption = BasicProxyOption(authenticationTypes = listOf())
private val terminalOption = TerminalOption()
private val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init {
addOption(generalOption)
addOption(proxyOption)
addOption(terminalOption)
}
fun getHost(): Host {
val name = generalOption.nameTextField.text
val protocol = TelnetProtocolProvider.PROTOCOL
val host = generalOption.hostTextField.text
val port = (generalOption.portTextField.value ?: 23) as Int
var authentication = Authentication.No
var proxy = Proxy.Companion.No
val authenticationType = generalOption.authenticationTypeComboBox.selectedItem as AuthenticationType
if (authenticationType == AuthenticationType.Password) {
authentication = authentication.copy(
type = authenticationType,
password = String(generalOption.passwordTextField.password)
)
}
if (proxyOption.proxyTypeComboBox.selectedItem != ProxyType.No) {
proxy = proxy.copy(
type = proxyOption.proxyTypeComboBox.selectedItem as ProxyType,
host = proxyOption.proxyHostTextField.text,
username = proxyOption.proxyUsernameTextField.text,
password = String(proxyOption.proxyPasswordTextField.password),
port = proxyOption.proxyPortTextField.value as Int,
authenticationType = proxyOption.proxyAuthenticationTypeComboBox.selectedItem as AuthenticationType,
)
}
val serialComm = SerialComm()
val options = Options.Companion.Default.copy(
encoding = terminalOption.charsetComboBox.selectedItem as String,
env = terminalOption.environmentTextArea.text,
startupCommand = terminalOption.startupCommandTextField.text,
serialComm = serialComm,
extras = mutableMapOf("backspace" to (terminalOption.backspaceComboBox.selectedItem as Backspace).name)
)
return Host(
name = name,
protocol = protocol,
host = host,
port = port,
username = generalOption.usernameTextField.text,
authentication = authentication,
proxy = proxy,
sort = System.currentTimeMillis(),
remark = generalOption.remarkTextArea.text,
options = options,
)
}
fun setHost(host: Host) {
generalOption.portTextField.value = host.port
generalOption.nameTextField.text = host.name
generalOption.usernameTextField.text = host.username
generalOption.hostTextField.text = host.host
generalOption.remarkTextArea.text = host.remark
generalOption.authenticationTypeComboBox.selectedItem = host.authentication.type
if (host.authentication.type == AuthenticationType.Password) {
generalOption.passwordTextField.text = host.authentication.password
}
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
proxyOption.proxyHostTextField.text = host.proxy.host
proxyOption.proxyPasswordTextField.text = host.proxy.password
proxyOption.proxyUsernameTextField.text = host.proxy.username
proxyOption.proxyPortTextField.value = host.proxy.port
proxyOption.proxyAuthenticationTypeComboBox.selectedItem = host.proxy.authenticationType
terminalOption.charsetComboBox.selectedItem = host.options.encoding
terminalOption.environmentTextArea.text = host.options.env
terminalOption.startupCommandTextField.text = host.options.startupCommand
terminalOption.backspaceComboBox.selectedItem =
Backspace.valueOf(host.options.extras["backspace"] ?: Backspace.Delete.name)
}
fun validateFields(): Boolean {
val host = getHost()
// general
if (validateField(generalOption.nameTextField)
|| validateField(generalOption.hostTextField)
) {
return false
}
if (host.authentication.type == AuthenticationType.Password) {
if (validateField(generalOption.usernameTextField)) {
return false
}
if (validateField(generalOption.passwordTextField)) {
return false
}
}
// proxy
if (host.proxy.type != ProxyType.No) {
if (validateField(proxyOption.proxyHostTextField)
) {
return false
}
if (host.proxy.authenticationType != AuthenticationType.No) {
if (validateField(proxyOption.proxyUsernameTextField)
|| validateField(proxyOption.proxyPasswordTextField)
) {
return false
}
}
}
return true
}
/**
* 返回 true 表示有错误
*/
private fun validateField(textField: JTextField): Boolean {
if (textField.isEnabled && textField.text.isBlank()) {
setOutlineError(textField)
return true
}
return false
}
private fun setOutlineError(textField: JTextField) {
selectOptionJComponent(textField)
textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
textField.requestFocusInWindow()
}
/**
* 返回 true 表示有错误
*/
private fun validateField(comboBox: JComboBox<*>): Boolean {
val selectedItem = comboBox.selectedItem
if (comboBox.isEnabled && (selectedItem == null || (selectedItem is String && selectedItem.isBlank()))) {
selectOptionJComponent(comboBox)
comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
comboBox.requestFocusInWindow()
return true
}
return false
}
protected inner class GeneralOption : JPanel(BorderLayout()), Option {
val portTextField = PortSpinner(23)
val nameTextField = OutlineTextField(128)
val usernameTextField = OutlineTextField(128)
val hostTextField = OutlineTextField(255)
val passwordTextField = OutlinePasswordField(255)
val publicKeyComboBox = OutlineComboBox<String>()
val remarkTextArea = FixedLengthTextArea(512)
val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
publicKeyComboBox.isEditable = false
publicKeyComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = StringUtils.EMPTY
if (value is String) {
text = KeyManager.getInstance().getOhKeyPair(value)?.name ?: text
}
return super.getListCellRendererComponent(
list,
text,
index,
isSelected,
cellHasFocus
)
}
}
authenticationTypeComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value?.toString() ?: ""
when (value) {
AuthenticationType.Password -> {
text = "Password"
}
AuthenticationType.PublicKey -> {
text = "Public Key"
}
AuthenticationType.KeyboardInteractive -> {
text = "Keyboard Interactive"
}
}
return super.getListCellRendererComponent(
list,
text,
index,
isSelected,
cellHasFocus
)
}
}
authenticationTypeComboBox.addItem(AuthenticationType.No)
authenticationTypeComboBox.addItem(AuthenticationType.Password)
authenticationTypeComboBox.selectedItem = AuthenticationType.Password
}
private fun initEvents() {
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
SwingUtilities.invokeLater { nameTextField.requestFocusInWindow() }
removeComponentListener(this)
}
})
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.settings
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.general")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, pref, $FORM_MARGIN, default",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS)
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS)
)
remarkTextArea.rows = 8
remarkTextArea.lineWrap = true
remarkTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.new-host.general.name")}:").xy(1, rows)
.add(nameTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.host")}:").xy(1, rows)
.add(hostTextField).xy(3, rows)
.add("${I18n.getString("termora.new-host.general.port")}:").xy(5, rows)
.add(portTextField).xy(7, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.username")}:").xy(1, rows)
.add(usernameTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, rows)
.add(authenticationTypeComboBox).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, rows)
.add(passwordTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.remark")}:").xy(1, rows)
.add(JScrollPane(remarkTextArea).apply { border = FlatTextBorder() })
.xyw(3, rows, 5).apply { rows += step }
.build()
return panel
}
}
private inner class TerminalOption : JPanel(BorderLayout()), Option {
val charsetComboBox = JComboBox<String>()
val backspaceComboBox = JComboBox<Backspace>()
val startupCommandTextField = OutlineTextField()
val environmentTextArea = FixedLengthTextArea(2048)
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
backspaceComboBox.addItem(Backspace.Delete)
backspaceComboBox.addItem(Backspace.Backspace)
backspaceComboBox.addItem(Backspace.VT220)
environmentTextArea.setFocusTraversalKeys(
KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS)
)
environmentTextArea.setFocusTraversalKeys(
KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS)
)
environmentTextArea.rows = 8
environmentTextArea.lineWrap = true
environmentTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
for (e in Charset.availableCharsets()) {
charsetComboBox.addItem(e.key)
}
charsetComboBox.selectedItem = "UTF-8"
}
private fun initEvents() {
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.terminal
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.terminal")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.new-host.terminal.encoding")}:").xy(1, rows)
.add(charsetComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.backspace")}:").xy(1, rows)
.add(backspaceComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.startup-commands")}:").xy(1, rows)
.add(startupCommandTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.terminal.env")}:").xy(1, rows)
.add(JScrollPane(environmentTextArea).apply { border = FlatTextBorder() }).xy(3, rows)
.apply { rows += step }
.build()
return panel
}
}
enum class Backspace {
/**
* 0x08
*/
Backspace,
/**
* 0x7F 默认
*/
Delete,
/**
* ESC[3~
*/
VT220, ;
override fun toString(): String {
return when (this) {
Backspace -> "ASCII Backspace (0x08)"
Delete -> "ASCII Delete (0x7F)"
VT220 -> "VT220 Delete (ESC[3~)"
}
}
}
}

View File

@@ -0,0 +1,24 @@
package app.termora.plugin.internal.telnet
import app.termora.plugin.Extension
import app.termora.plugin.InternalPlugin
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProviderExtension
internal class TelnetInternalPlugin : InternalPlugin() {
init {
support.addExtension(ProtocolProviderExtension::class.java) { TelnetProtocolProviderExtension.instance }
support.addExtension(ProtocolHostPanelExtension::class.java) { TelnetProtocolHostPanelExtension.instance }
}
override fun getName(): String {
return "Telnet Protocol"
}
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
return support.getExtensions(clazz)
}
}

View File

@@ -0,0 +1,38 @@
package app.termora.plugin.internal.telnet
import app.termora.Disposer
import app.termora.Host
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import java.awt.BorderLayout
class TelnetProtocolHostPanel(accountOwner: AccountOwner) : ProtocolHostPanel() {
private val pane = TelnetHostOptionsPane(accountOwner)
init {
initView()
initEvents()
}
private fun initView() {
add(pane, BorderLayout.CENTER)
Disposer.register(this, pane)
}
private fun initEvents() {}
override fun getHost(): Host {
return pane.getHost()
}
override fun setHost(host: Host) {
pane.setHost(host)
}
override fun validateFields(): Boolean {
return pane.validateFields()
}
}

View File

@@ -0,0 +1,25 @@
package app.termora.plugin.internal.telnet
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
internal class TelnetProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension {
companion object {
val instance = TelnetProtocolHostPanelExtension()
}
override fun getProtocolProvider(): ProtocolProvider {
return TelnetProtocolProvider.instance
}
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return TelnetProtocolHostPanel(accountOwner)
}
override fun ordered(): Long {
return 4
}
}

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