Compare commits

...

19 Commits

Author SHA1 Message Date
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
113 changed files with 1647 additions and 693 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.4

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

@@ -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,7 +49,10 @@ abstract class HostTerminalTab(
if (hasFocus || unread) {
return
}
unread = true
// 如果当前选中的不是这个 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") }

View File

@@ -0,0 +1,123 @@
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.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)
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
val items = fontComboBox.getItems()
for (family in listOf("JetBrains Mono", "Source Code Pro", "Monospaced")) {
if (items.contains(family).not()) fontComboBox.addItem(family)
}
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
}
}
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
@@ -32,6 +33,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 +112,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 +194,13 @@ class TerminalTabbed(
// remove ele
tabs.removeAt(index)
// 新的获取到焦点
tabs[tabbedPane.selectedIndex].onGrabFocus()
if (tabbedPane.tabCount > 0) {
// 新的获取到焦点
tabs[tabbedPane.selectedIndex].onGrabFocus()
// 新的真正获取焦点
tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow()
// 新的真正获取焦点
tabbedPane.getComponentAt(tabbedPane.selectedIndex).requestFocusInWindow()
}
if (disposable) {
Disposer.dispose(tab)
@@ -255,10 +245,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
@@ -497,6 +492,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,106 @@
package app.termora
import app.termora.tree.NewHostTree
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.util.SystemInfo
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Font
import java.awt.event.MouseAdapter
import javax.swing.*
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()
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
add(mySplitPane, BorderLayout.CENTER)
}
private fun initEvents() {
Disposer.register(this, leftTreePanel)
splitPane.addPropertyChangeListener("dividerLocation") { mySplitPane.doLayout() }
}
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(Box.createHorizontalGlue())
if (SystemInfo.isMacOS.not()) box.add(label)
box.add(Box.createHorizontalGlue())
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)
}
}
override fun dispose() {
enableManager.setFlag("Termora.Fence.dividerLocation", splitPane.dividerLocation)
}
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,209 @@ 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)
} 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, 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 +308,6 @@ class TermoraFrame : JFrame(), DataProvider {
return titlePaneField.get(ui) as? FlatTitlePane
}
}
toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
}
/// force hit
@@ -145,111 +354,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

@@ -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,37 +134,39 @@ class AccountManager private constructor() : ApplicationRunnerExtension {
* 设置账户信息,可以多次调用,每次修改用户信息都要通过这个方法
*/
internal fun login(account: Account) {
synchronized(this) {
val oldAccount = this.account
val oldAccount = this.account
this.account = account
this.account = account
// 立即保存到数据库
val accountProperties = AccountProperties.getInstance()
accountProperties.id = account.id
accountProperties.server = account.server
accountProperties.email = account.email
accountProperties.teams = ohMyJson.encodeToString(account.teams)
accountProperties.subscriptions = ohMyJson.encodeToString(account.subscriptions)
accountProperties.accessToken = account.accessToken
accountProperties.refreshToken = account.refreshToken
accountProperties.secretKey = ohMyJson.encodeToString(account.secretKey)
// 立即保存到数据库
val accountProperties = AccountProperties.getInstance()
accountProperties.id = account.id
accountProperties.server = account.server
accountProperties.email = account.email
accountProperties.teams = ohMyJson.encodeToString(account.teams)
accountProperties.subscriptions = ohMyJson.encodeToString(account.subscriptions)
accountProperties.accessToken = account.accessToken
accountProperties.refreshToken = account.refreshToken
accountProperties.secretKey = ohMyJson.encodeToString(account.secretKey)
// 如果变更账户了那么同步时间从0开始
if (oldAccount.id != account.id) {
accountProperties.nextSynchronizationSince = 0
// 如果变更账户了那么同步时间从0开始
if (oldAccount.id != account.id) {
accountProperties.nextSynchronizationSince = 0
}
if (isLocally().not()) {
accountProperties.publicKey = Base64.encodeBase64String(account.publicKey.encoded)
accountProperties.privateKey = Base64.encodeBase64String(account.privateKey.encoded)
} else {
accountProperties.publicKey = StringUtils.EMPTY
accountProperties.privateKey = StringUtils.EMPTY
}
// 通知变化
notifyAccountChanged(oldAccount, account)
}
if (isLocally().not()) {
accountProperties.publicKey = Base64.encodeBase64String(account.publicKey.encoded)
accountProperties.privateKey = Base64.encodeBase64String(account.privateKey.encoded)
} else {
accountProperties.publicKey = StringUtils.EMPTY
accountProperties.privateKey = StringUtils.EMPTY
}
// 通知变化
notifyAccountChanged(oldAccount, account)
}
private fun notifyAccountChanged(oldAccount: Account, newAccount: Account) {
@@ -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()
}
@@ -213,7 +222,7 @@ class PullService private constructor() : SyncService(), Disposable, Application
log.debug("拉取数据: {} 成功, 响应码: {}", id, response.code)
}
if(response.isSuccessful.not()){
if (response.isSuccessful.not()) {
IOUtils.closeQuietly(response)
}
@@ -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()
AccountHttp.execute(request = request)
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,19 +465,17 @@ class DatabaseManager private constructor() : Disposable {
return
}
// 如果团队变更,那么删除所有旧的团队数据,静默删除
if (oldAccount.id == newAccount.id) {
return
}
for (team in oldAccount.teams) {
// 如果被踢出团队,那么移除该团队的所有资产
if (newAccount.teams.none { it.id == team.id }) {
lock.withLock {
transaction(database) {
DataEntity.deleteWhere {
DataEntity.ownerId.eq(team.id) and (DataEntity.ownerType.eq(
OwnerType.Team.name
))
if (oldAccount.teams != newAccount.teams) {
for (team in oldAccount.teams) {
lock.withLock {
transaction(database) {
DataEntity.deleteWhere {
DataEntity.ownerId.eq(team.id) and (DataEntity.ownerType.eq(
OwnerType.Team.name
))
}
}
}
}
@@ -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
)
)
)
}
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
}
}
}
tabbed.addTab(
I18n.getString("termora.keymgr.my-keys"),
Icons.user,
KeyManagerPanel(AccountOwner(accountManager.getAccountId(), accountManager.getEmail(), OwnerType.User))
)
if (accountManager.hasTeamFeature()) {
for (team in accountManager.getTeams()) {

View File

@@ -40,7 +40,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

@@ -13,6 +13,7 @@ import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin
import app.termora.plugin.internal.ssh.SSHInternalPlugin
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
@@ -127,6 +128,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

@@ -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,7 @@ internal class LocalProtocolHostPanelExtension private constructor() : ProtocolH
return LocalProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return LocalProtocolHostPanel()
}
}

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,7 @@ internal class RDPProtocolHostPanelExtension private constructor() : ProtocolHos
return RDPProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return RDPProtocolHostPanel()
}
}

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,7 +15,7 @@ internal class SerialProtocolHostPanelExtension private constructor() : Protocol
return SerialProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return SerialProtocolHostPanel()
}

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

@@ -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,8 @@ internal class SSHProtocolHostPanelExtension private constructor() : ProtocolHos
return SSHProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
return SSHProtocolHostPanel()
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return SSHProtocolHostPanel(accountOwner)
}
}

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
@@ -28,7 +27,6 @@ import org.apache.sshd.common.session.Session
import org.apache.sshd.common.session.SessionListener
import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets
import java.util.*
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.SwingUtilities
@@ -47,9 +45,6 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
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)
init {
terminalPanel.dropFiles = false

View File

@@ -1,5 +1,6 @@
package app.termora.plugin.internal.wsl
import app.termora.account.AccountOwner
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
@@ -14,11 +15,11 @@ internal class WSLProtocolHostPanelExtension private constructor() : ProtocolHos
return WSLProtocolProvider.instance
}
override fun canCreateProtocolHostPanel(): Boolean {
override fun canCreateProtocolHostPanel(accountOwner: AccountOwner): Boolean {
return WSLSupport.isSupported
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
override fun createProtocolHostPanel(accountOwner: AccountOwner): ProtocolHostPanel {
return WSLProtocolHostPanel()
}
}

View File

@@ -1,8 +1,10 @@
package app.termora.protocol
import app.termora.account.AccountOwner
import app.termora.plugin.Extension
import app.termora.plugin.ExtensionManager
interface ProtocolHostPanelExtension : Extension {
companion object {
val extensions
@@ -19,11 +21,23 @@ interface ProtocolHostPanelExtension : Extension {
/**
* 是否可以创建协议主机面板
*/
@Deprecated("Old stuff")
fun canCreateProtocolHostPanel(): Boolean = true
/**
* 是否可以创建协议主机面板
*/
fun canCreateProtocolHostPanel(accountOwner: AccountOwner) = canCreateProtocolHostPanel()
/**
* 创建协议主机面板
*/
fun createProtocolHostPanel(): ProtocolHostPanel
@Deprecated("Old stuff")
fun createProtocolHostPanel(): ProtocolHostPanel = throw UnsupportedOperationException()
/**
* 创建协议主机面板
*/
fun createProtocolHostPanel(accountOwner: AccountOwner) = createProtocolHostPanel()
}

View File

@@ -0,0 +1,20 @@
package app.termora.terminal.panel
import app.termora.TerminalTab
import app.termora.actions.AnAction
import app.termora.plugin.Extension
import app.termora.terminal.panel.vw.VisualWindow
import app.termora.terminal.panel.vw.VisualWindowManager
interface FloatingToolbarActionExtension : Extension {
/**
* 抛出 [UnsupportedOperationException] 表示不支持
*/
fun createActionButton(visualWindowManager: VisualWindowManager, tab: TerminalTab): AnAction
/**
* 获取要返回的虚拟窗口
*/
fun getVisualWindowClass(tab: TerminalTab): Class<out VisualWindow>
}

View File

@@ -6,13 +6,10 @@ import app.termora.actions.AnActionEvent
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders
import app.termora.database.DatabaseManager
import app.termora.plugin.ExtensionManager
import app.termora.plugin.internal.ssh.SSHTerminalTab
import app.termora.snippet.SnippetAction
import app.termora.snippet.SnippetTreeDialog
import app.termora.terminal.DataKey
import app.termora.terminal.panel.vw.NvidiaSMIVisualWindow
import app.termora.terminal.panel.vw.SystemInformationVisualWindow
import app.termora.terminal.panel.vw.TransferVisualWindow
import app.termora.terminal.panel.vw.VisualWindowManager
import com.formdev.flatlaf.extras.components.FlatToolBar
import com.formdev.flatlaf.ui.FlatRoundBorder
import org.apache.commons.lang3.StringUtils
@@ -26,7 +23,7 @@ import javax.swing.SwingUtilities
class FloatingToolbarPanel : FlatToolBar(), Disposable {
private val floatingToolbarEnable get() = DatabaseManager.getInstance().terminal.floatingToolbar
private var closed = false
private val anEvent get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
private val event get() = AnActionEvent(this, StringUtils.EMPTY, EventObject(this))
companion object {
@@ -79,7 +76,6 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
}
}
initActions()
initEvents()
}
@@ -116,26 +112,36 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
// Pin
add(initPinActionButton())
// 服务器信息
add(initServerInfoActionButton())
val tab = event.getData(DataProviders.TerminalTab)
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel)
if (terminalPanel != null) {
val extensions = ExtensionManager.getInstance()
.getExtensions(FloatingToolbarActionExtension::class.java)
for (extension in extensions) {
try {
add(createButton(extension.createActionButton(terminalPanel, tab), terminalPanel, tab, extension))
} catch (_: UnsupportedOperationException) {
continue
}
}
// Transfer
add(initTransferActionButton())
initReconnectActionButton(tab)
}
// Snippet
add(initSnippetActionButton())
// Nvidia 显卡信息
add(initNvidiaSMIActionButton())
// 重连
add(initReconnectActionButton())
// 关闭
add(initCloseActionButton())
}
private fun initEvents() {
// 初始化 Action
addPropertyChangeListener("ancestor", object : PropertyChangeListener {
override fun propertyChange(evt: PropertyChangeEvent) {
removePropertyChangeListener("ancestor", this)
initActions()
}
})
// 被添加到组件后
addPropertyChangeListener("ancestor", object : PropertyChangeListener {
override fun propertyChange(evt: PropertyChangeEvent) {
@@ -143,11 +149,12 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
SwingUtilities.invokeLater { resumeVisualWindows() }
}
})
}
@Suppress("UNCHECKED_CAST")
private fun resumeVisualWindows() {
val tab = anEvent.getData(DataProviders.TerminalTab) ?: return
val tab = event.getData(DataProviders.TerminalTab) ?: return
if (tab !is SSHTerminalTab) return
val terminalPanel = tab.getData(DataProviders.TerminalPanel) ?: return
terminalPanel.resumeVisualWindows(tab.host.id, object : DataProvider {
@@ -160,106 +167,30 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
})
}
private fun initServerInfoActionButton(): JButton {
val btn = JButton(Icons.infoOutline)
btn.toolTipText = I18n.getString("termora.visual-window.system-information")
btn.addActionListener(object : AnAction() {
private fun createButton(
action: AnAction,
visualWindowManager: VisualWindowManager,
tab: TerminalTab,
extension: FloatingToolbarActionExtension
): JButton {
val btn = JButton(object : AnAction(action.smallIcon) {
override fun actionPerformed(evt: AnActionEvent) {
val tab = anEvent.getData(DataProviders.TerminalTab) ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
if (tab !is SSHTerminalTab) {
terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported"))
return
}
for (window in terminalPanel.getVisualWindows()) {
if (window is SystemInformationVisualWindow) {
terminalPanel.moveToFront(window)
return
try {
val clazz = extension.getVisualWindowClass(tab)
for (window in visualWindowManager.getVisualWindows()) {
if (clazz.isInstance(window)) {
visualWindowManager.moveToFront(window)
return
}
}
action.actionPerformed(evt)
} catch (_: UnsupportedOperationException) {
action.actionPerformed(evt)
}
val visualWindowPanel = SystemInformationVisualWindow(tab, terminalPanel)
terminalPanel.addVisualWindow(visualWindowPanel)
}
})
return btn
}
private fun initTransferActionButton(): JButton {
val btn = JButton(Icons.folder)
btn.toolTipText = I18n.getString("termora.transport.sftp")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = anEvent.getData(DataProviders.TerminalTab) ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
if (tab !is SSHTerminalTab) {
terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported"))
return
}
for (window in terminalPanel.getVisualWindows()) {
if (window is TransferVisualWindow) {
terminalPanel.moveToFront(window)
return
}
}
val visualWindowPanel = TransferVisualWindow(tab, terminalPanel)
terminalPanel.addVisualWindow(visualWindowPanel)
}
})
return btn
}
private fun initSnippetActionButton(): JButton {
val btn = JButton(Icons.codeSpan)
btn.toolTipText = I18n.getString("termora.snippet.title")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = anEvent.getData(DataProviders.TerminalTab) ?: return
val writer = tab.getData(DataProviders.TerminalWriter) ?: return
val dialog = SnippetTreeDialog(evt.window)
dialog.setLocationRelativeTo(btn)
dialog.setLocation(dialog.x, btn.locationOnScreen.y + height + 2)
dialog.isVisible = true
val node = dialog.getSelectedNode() ?: return
SnippetAction.getInstance().runSnippet(node.data, writer)
}
})
return btn
}
private fun initNvidiaSMIActionButton(): JButton {
val btn = JButton(Icons.nvidia)
btn.toolTipText = I18n.getString("termora.visual-window.nvidia-smi")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = anEvent.getData(DataProviders.TerminalTab) ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
if (tab !is SSHTerminalTab) {
terminalPanel.toast(I18n.getString("termora.floating-toolbar.not-supported"))
return
}
for (window in terminalPanel.getVisualWindows()) {
if (window is NvidiaSMIVisualWindow) {
terminalPanel.moveToFront(window)
return
}
}
val visualWindowPanel = NvidiaSMIVisualWindow(tab, terminalPanel)
terminalPanel.addVisualWindow(visualWindowPanel)
}
})
btn.text = StringUtils.EMPTY
btn.toolTipText = action.shortDescription
return btn
}
@@ -293,19 +224,16 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
return btn
}
private fun initReconnectActionButton(): JButton {
private fun initReconnectActionButton(tab: TerminalTab) {
if (tab.canReconnect().not()) return
val btn = JButton(Icons.refresh)
btn.toolTipText = I18n.getString("termora.tabbed.contextmenu.reconnect")
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = anEvent.getData(DataProviders.TerminalTab) ?: return
if (tab.canReconnect()) {
tab.reconnect()
}
tab.reconnect()
}
})
return btn
add(btn)
}
}

View File

@@ -5,6 +5,7 @@ import app.termora.assertEventDispatchThread
import app.termora.database.DatabaseManager
import app.termora.swingCoroutineScope
import app.termora.terminal.*
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -23,9 +24,15 @@ class TerminalDisplay(
private val terminalBlink: TerminalBlink
) : JComponent() {
enum class RendererFont {
Base,
Monospaced,
Fallback,
}
companion object {
private val lru = object : LinkedHashMap<String, Boolean>() {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Boolean>?): Boolean {
private val lru = object : LinkedHashMap<String, RendererFont>() {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, RendererFont>?): Boolean {
return size > 2048
}
}
@@ -40,6 +47,7 @@ class TerminalDisplay(
private var boldFont = font.deriveFont(Font.BOLD)
private var italicFont = font.deriveFont(Font.ITALIC)
private var boldItalicFont = font.deriveFont(Font.ITALIC or Font.BOLD)
private var fallbackFont = getFallbackTerminalFont()
/**
* 正在输入的内容
@@ -179,15 +187,22 @@ class TerminalDisplay(
}
private fun checkFont() {
// 如果字体已经改变,那么这里刷新字体
if (font.family != DatabaseManager.getInstance().terminal.font
|| font.size != DatabaseManager.getInstance().terminal.fontSize
val terminal = DatabaseManager.getInstance().terminal
if ((terminal.fallbackFont.isNotBlank() && fallbackFont == null) ||
(terminal.fallbackFont.isBlank() && fallbackFont != null) ||
(terminal.fallbackFont != fallbackFont?.family) ||
(font.size != terminal.fontSize)
) {
fallbackFont = getFallbackTerminalFont()
}
if (font.family != terminal.font || font.size != terminal.fontSize) {
font = getTerminalFont()
monospacedFont = Font(Font.MONOSPACED, font.style, font.size)
boldFont = font.deriveFont(Font.BOLD)
italicFont = font.deriveFont(Font.ITALIC)
boldItalicFont = font.deriveFont(Font.ITALIC or Font.BOLD)
monospacedFont = Font(Font.MONOSPACED, font.style, font.size)
}
}
@@ -395,7 +410,7 @@ class TerminalDisplay(
fun getDisplayFont(text: String, style: TextStyle): Font {
assertEventDispatchThread()
var font = if (style.bold && style.italic) {
val displayFont = if (style.bold && style.italic) {
boldItalicFont
} else if (style.italic) {
italicFont
@@ -405,17 +420,38 @@ class TerminalDisplay(
font
}
var font = displayFont
val key = "${font.fontName}:${font.style}:${font.size}:${text}"
if (lru.containsKey(key)) {
if (!lru.getValue(key)) {
font = monospacedFont
val c = lru.getValue(key)
font = when (c) {
RendererFont.Base -> font
RendererFont.Fallback -> fallbackFont ?: monospacedFont
else -> monospacedFont
}
} else {
if ((font.canDisplayUpTo(text) != -1).also { lru[key] = !it }) {
font = monospacedFont
// >=0 表示不支持
if (FontCanDisplay.canDisplayUpTo(font, text) != -1) {
val fallbackTerminalFont = fallbackFont ?: monospacedFont
font = if (fallbackTerminalFont.fontName == monospacedFont.fontName) {
monospacedFont
} else if (FontCanDisplay.canDisplayUpTo(fallbackTerminalFont, text) != -1) {
monospacedFont
} else {
fallbackTerminalFont
}
}
}
// macOS 比较特殊,因为它可以自动选择 PingFang而 PingFang 在 macOS 效果最好(前提是回退字体可用的情况下)
if (SystemInfo.isMacOS) {
if (font == monospacedFont) {
font = displayFont
}
}
return font
}
@@ -438,11 +474,17 @@ class TerminalDisplay(
private fun getTerminalFont(): Font {
return Font(
DatabaseManager.getInstance().terminal.font,
Font.PLAIN,
DatabaseManager.getInstance().terminal.fontSize
)
val terminal = DatabaseManager.getInstance().terminal
return Font(terminal.font, Font.PLAIN, terminal.fontSize)
}
private fun getFallbackTerminalFont(): Font? {
val terminal = DatabaseManager.getInstance().terminal
return if (terminal.fallbackFont.isBlank()) {
null
} else {
Font(terminal.fallbackFont, Font.PLAIN, terminal.fontSize)
}
}
fun toast(text: String, duration: Duration) {
@@ -509,4 +551,39 @@ class TerminalDisplay(
}
private object FontCanDisplay {
fun canDisplayUpTo(font: Font, str: String): Int {
if (SystemInfo.isWindows || SystemInfo.isLinux) {
return font.canDisplayUpTo(str)
}
val getFontMethod = Font::class.java.getDeclaredMethod("getFont2D")
getFontMethod.isAccessible = true
val font2d = getFontMethod.invoke(font)
val getMapperMethod = font2d.javaClass.getDeclaredMethod("getMapper")
getMapperMethod.isAccessible = true
val mapper = getMapperMethod.invoke(font2d)
val charToGlyphMethod = mapper.javaClass.getDeclaredMethod("charToGlyph", Char::class.java)
val len = str.length
var i = 0
while (i < len) {
val c = str[i]
val glyph = charToGlyphMethod.invoke(mapper, c) as Int
if (glyph >= 0) {
i++
continue
}
if (!Character.isHighSurrogate(c)
|| (charToGlyphMethod.invoke(mapper, str.codePointAt(i)) as Int) < 0
) {
return i
}
i += 2
}
return -1
}
}
}

View File

@@ -2,6 +2,7 @@ package app.termora.terminal.panel
import app.termora.Disposable
import app.termora.Disposer
import app.termora.TerminalTab
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders
@@ -35,7 +36,7 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
class TerminalPanel(val terminal: Terminal, private val writer: TerminalWriter) :
class TerminalPanel(val tab: TerminalTab?, val terminal: Terminal, private val writer: TerminalWriter) :
JPanel(BorderLayout()), DataProvider, Disposable, VisualWindowManager {
companion object {
@@ -554,7 +555,13 @@ class TerminalPanel(val terminal: Terminal, private val writer: TerminalWriter)
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == DataProviders.TerminalTab) {
if (tab != null) {
return tab as T
}
}
return dataProviderSupport.getData(dataKey)
}

View File

@@ -0,0 +1,27 @@
package app.termora.terminal.panel.vw
import app.termora.plugin.Extension
import app.termora.plugin.InternalPlugin
import app.termora.terminal.panel.FloatingToolbarActionExtension
import app.termora.terminal.panel.vw.extensions.NvidiaVisualWindowActionExtension
import app.termora.terminal.panel.vw.extensions.ServerInfoVisualWindowActionExtension
import app.termora.terminal.panel.vw.extensions.SnippetVisualWindowActionExtension
import app.termora.terminal.panel.vw.extensions.TransferVisualWindowActionExtension
internal class FloatingToolbarPlugin : InternalPlugin() {
init {
support.addExtension(FloatingToolbarActionExtension::class.java) { TransferVisualWindowActionExtension.instance }
support.addExtension(FloatingToolbarActionExtension::class.java) { ServerInfoVisualWindowActionExtension.instance }
support.addExtension(FloatingToolbarActionExtension::class.java) { SnippetVisualWindowActionExtension.instance }
support.addExtension(FloatingToolbarActionExtension::class.java) { NvidiaVisualWindowActionExtension.instance }
}
override fun getName(): String {
return "FloatingToolbar"
}
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
return support.getExtensions(clazz)
}
}

View File

@@ -10,6 +10,7 @@ import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
@@ -25,6 +26,7 @@ import java.io.StringReader
import javax.swing.*
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathFactory
import kotlin.time.Duration.Companion.milliseconds
class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "NVIDIA-SMI", visualWindowManager) {
@@ -264,7 +266,14 @@ class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWind
override suspend fun refresh(isFirst: Boolean) {
val session = tab.getData(SSHTerminalTab.SSHSession) ?: return
val session = suspend {
var c = tab.getData(SSHTerminalTab.SSHSession)
while (c == null) {
delay(250.milliseconds)
c = tab.getData(SSHTerminalTab.SSHSession)
}
c
}.invoke()
val doc = try {
val (code, text) = SshClients.execChannel(session, "nvidia-smi -x -q")

View File

@@ -344,7 +344,7 @@ open class VisualWindowPanel(protected val id: String, protected val visualWindo
isAlwaysOnTop = isAlwaysTop
if (SystemInfo.isWindows || SystemInfo.isLinux) {
val sizes = listOf(16, 20, 24, 28, 32, 48, 64)
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) }

View File

@@ -0,0 +1,41 @@
package app.termora.terminal.panel.vw.extensions
import app.termora.I18n
import app.termora.Icons
import app.termora.TerminalTab
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.plugin.internal.ssh.SSHTerminalTab
import app.termora.terminal.panel.FloatingToolbarActionExtension
import app.termora.terminal.panel.vw.NvidiaSMIVisualWindow
import app.termora.terminal.panel.vw.VisualWindow
import app.termora.terminal.panel.vw.VisualWindowManager
class NvidiaVisualWindowActionExtension private constructor() : FloatingToolbarActionExtension {
companion object {
val instance = NvidiaVisualWindowActionExtension()
}
override fun createActionButton(visualWindowManager: VisualWindowManager, tab: TerminalTab): AnAction {
if (tab !is SSHTerminalTab) throw UnsupportedOperationException()
return object : AnAction(Icons.nvidia) {
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.visual-window.nvidia-smi"))
}
override fun actionPerformed(evt: AnActionEvent) {
val visualWindowPanel = NvidiaSMIVisualWindow(tab, visualWindowManager)
visualWindowManager.addVisualWindow(visualWindowPanel)
}
}
}
override fun getVisualWindowClass(tab: TerminalTab): Class<out VisualWindow> {
return NvidiaSMIVisualWindow::class.java
}
override fun ordered(): Long {
return 3;
}
}

View File

@@ -0,0 +1,42 @@
package app.termora.terminal.panel.vw.extensions
import app.termora.I18n
import app.termora.Icons
import app.termora.TerminalTab
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.plugin.internal.ssh.SSHTerminalTab
import app.termora.terminal.panel.FloatingToolbarActionExtension
import app.termora.terminal.panel.vw.SystemInformationVisualWindow
import app.termora.terminal.panel.vw.VisualWindow
import app.termora.terminal.panel.vw.VisualWindowManager
class ServerInfoVisualWindowActionExtension private constructor() : FloatingToolbarActionExtension {
companion object {
val instance = ServerInfoVisualWindowActionExtension()
}
override fun createActionButton(visualWindowManager: VisualWindowManager, tab: TerminalTab): AnAction {
if (tab !is SSHTerminalTab) throw UnsupportedOperationException()
return object : AnAction(Icons.infoOutline) {
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.visual-window.system-information"))
}
override fun actionPerformed(evt: AnActionEvent) {
val visualWindowPanel = SystemInformationVisualWindow(tab, visualWindowManager)
visualWindowManager.addVisualWindow(visualWindowPanel)
}
}
}
override fun getVisualWindowClass(tab: TerminalTab): Class<out VisualWindow> {
if (tab !is SSHTerminalTab) throw UnsupportedOperationException()
return SystemInformationVisualWindow::class.java
}
override fun ordered(): Long {
return -1;
}
}

View File

@@ -0,0 +1,50 @@
package app.termora.terminal.panel.vw.extensions
import app.termora.I18n
import app.termora.Icons
import app.termora.PtyHostTerminalTab
import app.termora.TerminalTab
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.snippet.SnippetAction
import app.termora.snippet.SnippetTreeDialog
import app.termora.terminal.panel.FloatingToolbarActionExtension
import app.termora.terminal.panel.vw.VisualWindow
import app.termora.terminal.panel.vw.VisualWindowManager
import javax.swing.JComponent
class SnippetVisualWindowActionExtension private constructor() : FloatingToolbarActionExtension {
companion object {
val instance = SnippetVisualWindowActionExtension()
}
override fun createActionButton(visualWindowManager: VisualWindowManager, tab: TerminalTab): AnAction {
if (tab !is PtyHostTerminalTab) throw UnsupportedOperationException()
return object : AnAction(Icons.codeSpan) {
init {
putValue(SHORT_DESCRIPTION, I18n.getString("termora.snippet.title"))
}
override fun actionPerformed(evt: AnActionEvent) {
val btn = evt.source as? JComponent ?: return
val writer = tab.getData(DataProviders.TerminalWriter) ?: return
val dialog = SnippetTreeDialog(evt.window)
dialog.setLocationRelativeTo(btn)
dialog.setLocation(dialog.x, btn.locationOnScreen.y + btn.height + 2)
dialog.isVisible = true
val node = dialog.getSelectedNode() ?: return
SnippetAction.getInstance().runSnippet(node.data, writer)
}
}
}
override fun getVisualWindowClass(tab: TerminalTab): Class<out VisualWindow> {
throw UnsupportedOperationException()
}
override fun ordered(): Long {
return 2;
}
}

View File

@@ -0,0 +1,37 @@
package app.termora.terminal.panel.vw.extensions
import app.termora.Icons
import app.termora.TerminalTab
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.plugin.internal.ssh.SSHTerminalTab
import app.termora.terminal.panel.FloatingToolbarActionExtension
import app.termora.terminal.panel.vw.TransferVisualWindow
import app.termora.terminal.panel.vw.VisualWindow
import app.termora.terminal.panel.vw.VisualWindowManager
class TransferVisualWindowActionExtension private constructor() : FloatingToolbarActionExtension {
companion object {
val instance = TransferVisualWindowActionExtension()
}
override fun createActionButton(visualWindowManager: VisualWindowManager, tab: TerminalTab): AnAction {
if (tab !is SSHTerminalTab) throw UnsupportedOperationException()
return object : AnAction(Icons.folder) {
override fun actionPerformed(evt: AnActionEvent) {
val visualWindowPanel = TransferVisualWindow(tab, visualWindowManager)
visualWindowManager.addVisualWindow(visualWindowPanel)
}
}
}
override fun getVisualWindowClass(tab: TerminalTab): Class<out VisualWindow> {
if (tab !is SSHTerminalTab) throw UnsupportedOperationException()
return TransferVisualWindow::class.java
}
override fun ordered(): Long {
return 1
}
}

View File

@@ -1,6 +1,7 @@
package app.termora.transfer
import app.termora.*
import app.termora.account.AccountManager
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.database.DatabaseChangedExtension
@@ -164,9 +165,13 @@ class TransportTabbed(
// 编辑
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
edit.addActionListener(object : AnAction() {
private val accountManager get() = AccountManager.getInstance()
override fun actionPerformed(evt: AnActionEvent) {
val window = evt.window
val dialog = NewHostDialogV2(window, panel.host)
val dialog = NewHostDialogV2(
window,
panel.host,
accountOwner = accountManager.getOwners().first { it.id == panel.host.ownerId })
dialog.setLocationRelativeTo(window)
dialog.title = panel.host.name
dialog.isVisible = true

View File

@@ -2,6 +2,7 @@ package app.termora.tree
import app.termora.*
import app.termora.Application.ohMyJson
import app.termora.account.AccountManager
import app.termora.actions.OpenHostAction
import app.termora.database.DatabaseChangedExtension
import app.termora.database.DatabaseManager
@@ -295,8 +296,11 @@ class NewHostTree : SimpleTree(), Disposable {
}
}
newHost.addActionListener(object : ActionListener {
private val accountManager get() = AccountManager.getInstance()
override fun actionPerformed(e: ActionEvent) {
val dialog = NewHostDialogV2(owner)
val dialog = NewHostDialogV2(
owner,
accountOwner = accountManager.getOwners().first { it.id == lastHost.ownerId })
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
val host = (dialog.host ?: return).copy(
@@ -311,8 +315,12 @@ class NewHostTree : SimpleTree(), Disposable {
}
})
property.addActionListener(object : ActionListener {
private val accountManager get() = AccountManager.getInstance()
override fun actionPerformed(e: ActionEvent) {
val dialog = NewHostDialogV2(owner, lastHost)
val dialog = NewHostDialogV2(
owner,
lastHost,
accountOwner = accountManager.getOwners().first { it.id == lastHost.ownerId })
dialog.setLocationRelativeTo(owner)
dialog.title = lastHost.name
dialog.isVisible = true
@@ -639,12 +647,12 @@ class NewHostTree : SimpleTree(), Disposable {
ownerType = folder.host.ownerType,
ownerId = folder.host.ownerId,
),
DatabaseChangedExtension.Source.Sync
DatabaseChangedExtension.Source.User
)
for (host in node.getAllChildren().map { it.host }) {
hostManager.addHost(
host.copy(ownerType = folder.host.ownerType, ownerId = folder.host.ownerId),
DatabaseChangedExtension.Source.Sync
DatabaseChangedExtension.Source.User
)
}
}

View File

@@ -148,6 +148,10 @@ class NewHostTreeModel private constructor() : SimpleTreeModel<Host>(
}
hostManager.removeHost(node.id)
}
removeNodeFromParent0(node)
}
private fun removeNodeFromParent0(node: MutableTreeNode?) {
super.removeNodeFromParent(node)
}
@@ -232,7 +236,13 @@ class NewHostTreeModel private constructor() : SimpleTreeModel<Host>(
private inner class MyAccountAccountExtension : AccountExtension {
override fun onAccountChanged(oldAccount: Account, newAccount: Account) {
if (oldAccount.id != newAccount.id) reload(getRoot())
if (oldAccount.id != newAccount.id) {
reload(getRoot())
} else if (oldAccount.teams != newAccount.teams) {
val nodes = getRoot().children().toList().filterIsInstance<TeamTreeNode>()
nodes.forEach { removeNodeFromParent0(it) }
reload(getRoot())
}
}
}

View File

@@ -51,6 +51,9 @@ termora.setting=Settings
termora.settings.appearance=General
termora.settings.appearance.theme=Theme
termora.settings.appearance.layout=Layout
termora.settings.appearance.layout.screen=Screen
termora.settings.appearance.layout.fence=Split
termora.settings.appearance.language=Language
termora.settings.appearance.i-want-to-translate=I want to translate
termora.settings.appearance.follow-system=Sync with OS
@@ -60,6 +63,7 @@ termora.settings.appearance.confirm-tab-close=Confirm tab close
termora.settings.terminal=Terminal
termora.settings.terminal.font=Font
termora.settings.terminal.fallback-font=Fallback Font
termora.settings.terminal.size=Size
termora.settings.terminal.max-rows=Max rows
termora.settings.terminal.debug=Debug mode

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