Compare commits

...

30 Commits

Author SHA1 Message Date
hstyi
5145cfa8a5 release: 1.0.16 2025-06-10 08:34:03 +08:00
hstyi
87b1a5e315 fix: snippet \ character escape (#625) 2025-06-09 14:17:37 +08:00
hstyi
fa59869f2c fix: authentication username not being saved (#622) 2025-06-09 09:47:00 +08:00
kanoshiou
1ae64fe0db perf: lazy loading OptionsPane and Fonts (#619) 2025-06-07 12:07:55 +08:00
hstyi
f8d363836e chore: improve the host text field (#617) 2025-06-05 23:40:20 +08:00
dependabot[bot]
38dccb1d22 chore(deps): bump org.jetbrains.pty4j:pty4j from 0.13.5 to 0.13.6 (#613)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 11:11:50 +08:00
hstyi
3e31a89b92 chore: SFTP edit command supports manual file selection (#612) 2025-06-03 16:55:39 +08:00
kanoshiou
d8f892cc02 fix: missing remark when importing keys (#611) 2025-06-03 13:42:09 +08:00
hstyi
873deb55aa fix: SSH authentication causing IP and port changes (#610) 2025-06-03 12:55:41 +08:00
hstyi
c08712d79b fix: Xterm Send Device Attributes (Primary DA) (#607) 2025-05-30 10:44:53 +08:00
dependabot[bot]
61bc905727 chore(deps): bump org.testcontainers:testcontainers-bom from 1.21.0 to 1.21.1 (#606)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-30 10:28:44 +08:00
hstyi
17859be3c5 feat: confirm tab close (#605) 2025-05-30 09:48:48 +08:00
hstyi
7a24e34695 fix: delete leftover files before installing Windows (#604) 2025-05-30 09:11:57 +08:00
dependabot[bot]
58638eaad8 chore(deps): bump org.jetbrains.pty4j:pty4j from 0.13.4 to 0.13.5 (#603)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 18:26:50 +08:00
hstyi
09d2f2d193 chore: dialog location (#602) 2025-05-28 13:16:42 +08:00
hstyi
9121eff8d8 feat: support importing RDP protocol from CSV (#600) 2025-05-27 09:57:12 +08:00
dependabot[bot]
8b090b0526 chore(deps): bump org.gradle.toolchains.foojay-resolver-convention from 0.10.0 to 1.0.0 (#595) 2025-05-21 12:45:45 +08:00
hstyi
15a0d642ff feat: support block selection (#594) 2025-05-19 18:31:51 +08:00
hstyi
dc4333da21 release: 1.0.15 2025-05-19 11:34:23 +08:00
hstyi
184f6d46dc fix: snippet scroll (#587) 2025-05-16 13:17:02 +08:00
hstyi
68788905fe chore: improve sftp tab (#583) 2025-05-14 23:24:52 +08:00
dependabot[bot]
fc46216a3f chore(deps): bump kotlin from 2.1.20 to 2.1.21 (#580)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 11:43:24 +08:00
hstyi
563143645e fix: SFTP drag and drop upload (#578) 2025-05-13 13:50:08 +08:00
hstyi
891ccb901b chore: maven-publish 2025-05-12 16:56:01 +08:00
hstyi
928a866fe7 feat: improve SFTP (#572) 2025-05-12 15:37:39 +08:00
hstyi
ea25b5b46f feat: modify permissions to support recursion (#571) 2025-05-12 15:26:29 +08:00
hstyi
1de10e6129 fix: process Device Status Report (DSR) (#570) 2025-05-12 11:33:39 +08:00
hstyi
aaf9c2e8d2 feat: support for disabling hyperlink (#568) 2025-05-12 11:05:39 +08:00
hstyi
b8196b5730 fix: snippet unescape (#567) 2025-05-12 10:50:48 +08:00
hstyi
0a83e8beb4 fix: double-click to open the host (#566) 2025-05-12 10:49:35 +08:00
37 changed files with 781 additions and 290 deletions

View File

@@ -14,13 +14,14 @@ plugins {
java
idea
application
`maven-publish`
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlinx.serialization)
}
group = "app.termora"
version = "1.0.14"
version = "1.0.16"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
@@ -56,67 +57,67 @@ dependencies {
// implementation(platform(libs.koin.bom))
// implementation(libs.koin.core)
implementation(libs.slf4j.api)
implementation(libs.pty4j)
implementation(libs.slf4j.tinylog)
implementation(libs.tinylog.impl)
implementation(libs.commons.codec)
implementation(libs.commons.io)
implementation(libs.commons.lang3)
implementation(libs.commons.csv)
implementation(libs.commons.net)
implementation(libs.commons.text)
implementation(libs.commons.compress)
implementation(libs.commons.vfs2) { exclude(group = "*", module = "*") }
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.kotlinx.coroutines.core)
api(libs.slf4j.api)
api(libs.pty4j)
api(libs.slf4j.tinylog)
api(libs.tinylog.impl)
api(libs.commons.codec)
api(libs.commons.io)
api(libs.commons.lang3)
api(libs.commons.csv)
api(libs.commons.net)
api(libs.commons.text)
api(libs.commons.compress)
api(libs.commons.vfs2) { exclude(group = "*", module = "*") }
api(libs.kotlinx.coroutines.swing)
api(libs.kotlinx.coroutines.core)
implementation(libs.flatlaf) {
api(libs.flatlaf) {
artifact {
if (useNoNativesFlatLaf) {
classifier = "no-natives"
}
}
}
implementation(libs.flatlaf.extras) {
api(libs.flatlaf.extras) {
if (useNoNativesFlatLaf) {
exclude(group = "com.formdev", module = "flatlaf")
}
}
implementation(libs.flatlaf.swingx) {
api(libs.flatlaf.swingx) {
if (useNoNativesFlatLaf) {
exclude(group = "com.formdev", module = "flatlaf")
}
}
implementation(libs.kotlinx.serialization.json)
implementation(libs.swingx)
implementation(libs.jgoodies.forms)
implementation(libs.jna)
implementation(libs.jna.platform)
implementation(libs.versioncompare)
implementation(libs.oshi.core)
implementation(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") }
implementation(libs.jfa) { exclude(group = "*", module = "*") }
implementation(libs.jbr.api)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.sshd.core)
implementation(libs.commonmark)
implementation(libs.jgit)
implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.jgit.agent) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.eddsa)
implementation(libs.jnafilechooser)
implementation(libs.xodus.vfs)
implementation(libs.xodus.openAPI)
implementation(libs.xodus.environment)
implementation(libs.bip39)
implementation(libs.colorpicker)
implementation(libs.mixpanel)
implementation(libs.jSerialComm)
implementation(libs.ini4j)
implementation(libs.restart4j)
api(libs.kotlinx.serialization.json)
api(libs.swingx)
api(libs.jgoodies.forms)
api(libs.jna)
api(libs.jna.platform)
api(libs.versioncompare)
api(libs.oshi.core)
api(libs.jSystemThemeDetector) { exclude(group = "*", module = "*") }
api(libs.jfa) { exclude(group = "*", module = "*") }
api(libs.jbr.api)
api(libs.okhttp)
api(libs.okhttp.logging)
api(libs.sshd.core)
api(libs.commonmark)
api(libs.jgit)
api(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
api(libs.jgit.agent) { exclude(group = "*", module = "sshd-osgi") }
api(libs.eddsa)
api(libs.jnafilechooser)
api(libs.xodus.vfs)
api(libs.xodus.openAPI)
api(libs.xodus.environment)
api(libs.bip39)
api(libs.colorpicker)
api(libs.mixpanel)
api(libs.jSerialComm)
api(libs.ini4j)
api(libs.restart4j)
}
application {
@@ -147,6 +148,37 @@ application {
mainClass = "app.termora.MainKt"
}
publishing {
publications {
create<MavenPublication>("mavenJava") {
from(components["java"])
pom {
name = project.name
description = "Termora is a terminal emulator and SSH client for Windows, macOS and Linux"
url = "https://github.com/TermoraDev/termora"
licenses {
license {
name = "AGPL-3.0"
url = "https://opensource.org/license/agpl-v3"
}
}
developers {
developer {
name = "hstyi"
url = "https://github.com/hstyi"
}
}
scm {
url = "https://github.com/TermoraDev/termora"
}
}
}
}
}
tasks.test {
useJUnitPlatform()
}

View File

@@ -1,7 +1,7 @@
[versions]
kotlin = "2.1.20"
kotlin = "2.1.21"
slf4j = "2.0.17"
pty4j = "0.13.4"
pty4j = "0.13.6"
tinylog = "2.7.0"
kotlinx-coroutines = "1.10.2"
flatlaf = "3.6"
@@ -35,7 +35,7 @@ bip39 = "1.0.9"
colorpicker = "2.0.1"
rhino = "1.8.0"
delight-rhino-sandbox = "0.0.17"
testcontainers = "1.21.0"
testcontainers = "1.21.1"
mixpanel = "1.5.3"
jSerialComm = "2.11.0"
ini4j = "0.5.5-2"

View File

@@ -1,5 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "termora"

View File

@@ -523,6 +523,11 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var beep by BooleanPropertyDelegate(true)
/**
* 超链接
*/
var hyperlink by BooleanPropertyDelegate(true)
/**
* 光标闪烁
*/
@@ -643,6 +648,11 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var backgroundRunning by BooleanPropertyDelegate(false)
/**
* 标签关闭前确认
*/
var confirmTabClose by BooleanPropertyDelegate(false)
/**
* 背景图片的地址
*/

View File

@@ -25,6 +25,7 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
isModal = true
title = I18n.getString("termora.new-host.title")
setLocationRelativeTo(null)
pane.setSelectedIndex(0)
init()
}

View File

@@ -13,11 +13,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.RegExUtils
import org.apache.commons.lang3.StringUtils
import org.eclipse.jgit.internal.transport.sshd.agent.connector.PageantConnector
import org.eclipse.jgit.internal.transport.sshd.agent.connector.UnixDomainSocketConnector
import org.eclipse.jgit.internal.transport.sshd.agent.connector.WinPipeConnector
import java.awt.*
import java.awt.datatransfer.DataFlavor
import java.awt.event.*
import java.nio.charset.Charset
import javax.swing.*
@@ -221,7 +223,24 @@ open class HostOptionsPane : OptionsPane() {
val nameTextField = OutlineTextField(128)
val protocolTypeComboBox = FlatComboBox<Protocol>()
val usernameTextField = OutlineTextField(128)
val hostTextField = OutlineTextField(255)
val hostTextField = object : OutlineTextField(255) {
override fun paste() {
if (!toolkit.systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
return
}
var text = toolkit.systemClipboard.getData(DataFlavor.stringFlavor)?.toString() ?: return
if (text.isBlank()) {
return
}
// 移除所有不可见字符
text = RegExUtils.replaceAll(text, "[\\p{C}\\s]", StringUtils.EMPTY)
// text
replaceSelection(text)
}
}
private val passwordPanel = JPanel(BorderLayout())
private val chooseKeyBtn = JButton(Icons.greyKey)
val passwordTextField = OutlinePasswordField(255)

View File

@@ -135,10 +135,12 @@ class NewHostTree : SimpleTree() {
// double click
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (getPathForLocation(e.x, e.y) == null) return
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val lastNode = lastSelectedPathComponent as? HostTreeNode ?: return
if (lastNode.host.protocol != Protocol.Folder) {
val path = tree.getClosestPathForLocation(e.x, e.y) ?: return
val bounds = tree.getRowBounds(tree.getRowForPath(path)) ?: return
if ((e.y >= bounds.y && e.y < (bounds.y + bounds.height)).not()) return
openHostAction?.actionPerformed(OpenHostActionEvent(e.source, lastNode.host, e))
}
}
@@ -854,7 +856,8 @@ class NewHostTree : SimpleTree() {
val port = map["Port"]?.toIntOrNull() ?: 22
val username = map["Username"] ?: StringUtils.EMPTY
val protocol = map["Protocol"] ?: "SSH"
if (!StringUtils.equalsIgnoreCase(protocol, "SSH")) continue
// 仅支持 SSH、RDP 协议
if (StringUtils.equalsAnyIgnoreCase(protocol, "SSH", "RDP").not()) continue
if (StringUtils.isAllBlank(hostname, label)) continue
var p: HostTreeNode? = null
@@ -889,7 +892,7 @@ class NewHostTree : SimpleTree() {
host = hostname,
port = port,
username = username,
protocol = Protocol.SSH,
protocol = runCatching { Protocol.valueOf(protocol) }.getOrNull() ?: Protocol.SSH,
parentId = p?.host?.id ?: StringUtils.EMPTY,
)
)

View File

@@ -40,7 +40,7 @@ class NewHostTreeDialog(
init()
setLocationRelativeTo(null)
setLocationRelativeTo(owner)
}

View File

@@ -17,6 +17,7 @@ open class OptionsPane : JPanel(BorderLayout()) {
}
private val cardLayout = CardLayout()
private val contentPanel = JPanel(cardLayout)
private val loadedComponents = mutableMapOf<String, JComponent>()
init {
initView()
@@ -103,16 +104,15 @@ open class OptionsPane : JPanel(BorderLayout()) {
throw UnsupportedOperationException("Title already exists")
}
}
contentPanel.add(option.getJComponent(), option.getTitle())
tabListModel.addElement(option)
if (tabList.selectedIndex < 0) {
tabList.selectedIndex = 0
}
}
fun removeOption(option: Option) {
contentPanel.remove(option.getJComponent())
val title = option.getTitle()
loadedComponents[title]?.let {
contentPanel.remove(it)
loadedComponents.remove(title)
}
tabListModel.removeElement(option)
}
@@ -123,7 +123,17 @@ open class OptionsPane : JPanel(BorderLayout()) {
private fun initEvents() {
tabList.addListSelectionListener {
if (tabList.selectedIndex >= 0) {
cardLayout.show(contentPanel, tabListModel.get(tabList.selectedIndex).getTitle())
val option = tabListModel.get(tabList.selectedIndex)
val title = option.getTitle()
if (!loadedComponents.containsKey(title)) {
val component = option.getJComponent()
loadedComponents[title] = component
contentPanel.add(component, title)
SwingUtilities.updateComponentTreeUI(component)
}
cardLayout.show(contentPanel, title)
}
}
}

View File

@@ -223,10 +223,9 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
}
}
override fun willBeClose(): Boolean {
override fun beforeClose() {
// 保存窗口状态
terminalPanel.storeVisualWindows(host.id)
return super.willBeClose()
}
private inner class MySessionListener : SessionListener, Disposable {

View File

@@ -3,8 +3,6 @@ package app.termora
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Window
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.JPanel
@@ -20,8 +18,10 @@ class SettingsDialog(owner: Window) : DialogWrapper(owner) {
title = I18n.getString("termora.setting")
setLocationRelativeTo(null)
init()
val index = properties.getString("Settings-SelectedOption")?.toIntOrNull() ?: 0
optionsPane.setSelectedIndex(index)
init()
initEvents()
}
@@ -31,14 +31,6 @@ class SettingsDialog(owner: Window) : DialogWrapper(owner) {
properties.putString("Settings-SelectedOption", optionsPane.getSelectedIndex().toString())
}
})
addWindowListener(object : WindowAdapter() {
override fun windowActivated(e: WindowEvent) {
removeWindowListener(this)
val index = properties.getString("Settings-SelectedOption")?.toIntOrNull() ?: return
optionsPane.setSelectedIndex(index)
}
})
}
override fun createCenterPanel(): JComponent {

View File

@@ -33,6 +33,10 @@ import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import com.jthemedetecor.OsThemeDetector
import com.sun.jna.LastErrorException
import com.sun.jna.Native
import com.sun.jna.platform.win32.Shell32
import com.sun.jna.platform.win32.ShlObj
import com.sun.jna.platform.win32.WinDef
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
@@ -132,6 +136,7 @@ class SettingsOptionsPane : OptionsPane() {
val themeComboBox = FlatComboBox<String>()
val languageComboBox = FlatComboBox<String>()
val backgroundComBoBox = YesOrNoComboBox()
val confirmTabCloseComBoBox = YesOrNoComboBox()
val followSystemCheckBox = JCheckBox(I18n.getString("termora.settings.appearance.follow-system"))
val preferredThemeBtn = JButton(Icons.settings)
val opacitySpinner = NumberSpinner(100, 0, 100)
@@ -180,6 +185,7 @@ class SettingsOptionsPane : OptionsPane() {
followSystemCheckBox.isSelected = appearance.followSystem
preferredThemeBtn.isEnabled = followSystemCheckBox.isSelected
backgroundComBoBox.selectedItem = appearance.backgroundRunning
confirmTabCloseComBoBox.selectedItem = appearance.confirmTabClose
themeComboBox.isEnabled = !followSystemCheckBox.isSelected
themeManager.themes.keys.forEach { themeComboBox.addItem(it) }
@@ -230,6 +236,13 @@ class SettingsOptionsPane : OptionsPane() {
}
}
confirmTabCloseComBoBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
appearance.confirmTabClose = confirmTabCloseComBoBox.selectedItem as Boolean
}
}
followSystemCheckBox.addActionListener {
appearance.followSystem = followSystemCheckBox.isSelected
themeComboBox.isEnabled = !followSystemCheckBox.isSelected
@@ -368,7 +381,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun getFormPanel(): JPanel {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, default, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val box = FlatToolBar()
box.add(followSystemCheckBox)
@@ -401,7 +414,13 @@ class SettingsOptionsPane : OptionsPane() {
.add(opacitySpinner).xy(3, rows).apply { rows += step }
builder.add("${I18n.getString("termora.settings.appearance.background-running")}:").xy(1, rows)
.add(backgroundComBoBox).xy(3, rows)
.add(backgroundComBoBox).xy(3, rows).apply { rows += step }
val confirmTabCloseBox = Box.createHorizontalBox()
confirmTabCloseBox.add(JLabel("${I18n.getString("termora.settings.appearance.confirm-tab-close")}:"))
confirmTabCloseBox.add(Box.createHorizontalStrut(8))
confirmTabCloseBox.add(confirmTabCloseComBoBox)
builder.add(confirmTabCloseBox).xyw(1, rows, 3).apply { rows += step }
return builder.build()
}
@@ -422,6 +441,7 @@ class SettingsOptionsPane : OptionsPane() {
private val selectCopyComboBox = YesOrNoComboBox()
private val autoCloseTabComboBox = YesOrNoComboBox()
private val floatingToolbarComboBox = YesOrNoComboBox()
private val hyperlinkComboBox = YesOrNoComboBox()
init {
initView()
@@ -499,6 +519,13 @@ class SettingsOptionsPane : OptionsPane() {
}
}
hyperlinkComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.hyperlink = hyperlinkComboBox.selectedItem as Boolean
TerminalPanelFactory.getInstance().repaintAll()
}
}
cursorBlinkComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.cursorBlink = cursorBlinkComboBox.selectedItem as Boolean
@@ -577,20 +604,33 @@ class SettingsOptionsPane : OptionsPane() {
shellComboBox.selectedItem = terminalSetting.localShell
val fonts = linkedSetOf("JetBrains Mono", "Source Code Pro", "Monospaced")
FontUtils.getAllFonts().forEach {
if (!fonts.contains(it.family)) {
fonts.addLast(it.family)
fontComboBox.addItem(terminalSetting.font)
var fontsLoaded = false
fontComboBox.addPopupMenuListener(object : PopupMenuListener {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
if (!fontsLoaded) {
val selectedItem = fontComboBox.selectedItem
fontComboBox.removeAllItems();
fontComboBox.addItem("JetBrains Mono")
fontComboBox.addItem("Source Code Pro")
fontComboBox.addItem("Monospaced")
FontUtils.getAvailableFontFamilyNames().forEach {
fontComboBox.addItem(it)
}
fontComboBox.selectedItem = selectedItem
fontsLoaded = true
}
}
for (font in fonts) {
fontComboBox.addItem(font)
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {}
override fun popupMenuCanceled(e: PopupMenuEvent) {}
})
fontComboBox.selectedItem = terminalSetting.font
debugComboBox.selectedItem = terminalSetting.debug
beepComboBox.selectedItem = terminalSetting.beep
hyperlinkComboBox.selectedItem = terminalSetting.hyperlink
cursorBlinkComboBox.selectedItem = terminalSetting.cursorBlink
cursorStyleComboBox.selectedItem = terminalSetting.cursor
selectCopyComboBox.selectedItem = terminalSetting.selectCopy
@@ -613,7 +653,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin, left:pref, $formMargin, pref, default:grow",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val beepBtn = JButton(Icons.run)
@@ -636,6 +676,8 @@ class SettingsOptionsPane : OptionsPane() {
.add("${I18n.getString("termora.settings.terminal.beep")}:").xy(1, rows)
.add(beepComboBox).xy(3, rows)
.add(beepBtn).xy(5, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.hyperlink")}:").xy(1, rows)
.add(hyperlinkComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.select-copy")}:").xy(1, rows)
.add(selectCopyComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.terminal.cursor-style")}:").xy(1, rows)
@@ -1476,6 +1518,7 @@ class SettingsOptionsPane : OptionsPane() {
private val sftpCommandField = OutlineTextField(255)
private val defaultDirectoryField = OutlineTextField(255)
private val browseDirectoryBtn = JButton(Icons.folder)
private val browseEditCommandBtn = JButton(Icons.folder)
private val pinTabComboBox = YesOrNoComboBox()
private val preserveModificationTimeComboBox = YesOrNoComboBox()
private val sftp get() = database.sftp
@@ -1547,6 +1590,41 @@ class SettingsOptionsPane : OptionsPane() {
}
}
})
browseEditCommandBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val chooser = FileChooser()
chooser.allowsMultiSelection = false
chooser.fileSelectionMode = JFileChooser.FILES_ONLY
if (SystemInfo.isMacOS) {
chooser.defaultDirectory = "/Applications"
} else {
if (SystemInfo.isWindows) {
val pszPath = CharArray(WinDef.MAX_PATH)
Shell32.INSTANCE.SHGetFolderPath(
null,
ShlObj.CSIDL_DESKTOPDIRECTORY, null, ShlObj.SHGFP_TYPE_CURRENT,
pszPath
)
chooser.defaultDirectory = Native.toString(pszPath)
} else {
chooser.defaultDirectory = SystemUtils.USER_HOME
}
}
chooser.showOpenDialog(owner).thenAccept { files ->
if (files.isNotEmpty()) {
val file = files.first()
if (SystemInfo.isMacOS) {
editCommandField.text = "open -a ${file.absolutePath} {0}"
} else {
editCommandField.text = "${file.absolutePath} {0}"
}
}
}
}
})
}
@@ -1563,6 +1641,8 @@ class SettingsOptionsPane : OptionsPane() {
sftpCommandField.placeholderText = "sftp"
}
editCommandField.trailingComponent = browseEditCommandBtn
defaultDirectoryField.placeholderText = SystemUtils.USER_HOME
defaultDirectoryField.trailingComponent = browseDirectoryBtn

View File

@@ -265,9 +265,14 @@ object SshClients {
} catch (e: Exception) {
if (e !is SshException || e.disconnectCode != SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e
val owner = client.properties["owner"] as Window? ?: throw e
val authentication = ask(host, owner) ?: throw e
if (authentication.type == AuthenticationType.No) throw e
return doOpenSession(host.copy(authentication = authentication), client)
val askUserInfo = ask(host, entry, owner) ?: throw e
if (askUserInfo.authentication.type == AuthenticationType.No) throw e
return doOpenSession(
host.copy(
authentication = askUserInfo.authentication,
username = askUserInfo.username
), client
)
}
session.setAttribute(HOST_KEY, host)
@@ -414,21 +419,33 @@ object SshClients {
return sshClient
}
private fun ask(host: Host, owner: Window): Authentication? {
val ref = AtomicReference<Authentication>(null)
private data class AskUserInfo(val username: String, val authentication: Authentication)
private fun ask(host: Host, entry: HostConfigEntry, owner: Window): AskUserInfo? {
val ref = AtomicReference<AskUserInfo>(null)
SwingUtilities.invokeAndWait {
val dialog = RequestAuthenticationDialog(owner, host)
dialog.setLocationRelativeTo(owner)
val authentication = dialog.getAuthentication().apply { ref.set(this) }
val authentication = dialog.getAuthentication()
ref.set(AskUserInfo(dialog.getUsername(), authentication))
// save
if (dialog.isRemembered()) {
// fix https://github.com/TermoraDev/termora/issues/609
val hostId = entry.getProperty("Host", host.id)
val h = hostManager.getHost(hostId)
if (h != null) {
hostManager.addHost(
host.copy(
h.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
}
}
return ref.get()
}

View File

@@ -4,6 +4,7 @@ import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.actions.MultipleAction
import app.termora.highlight.KeywordHighlightPaintListener
import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector
import app.termora.terminal.Terminal
import app.termora.terminal.panel.TerminalHyperlinkPaintListener
@@ -40,6 +41,10 @@ class TerminalPanelFactory : Disposable {
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
val writer = MyTerminalWriter(ptyConnector)
val terminalPanel = TerminalPanel(terminal, writer)
// processDeviceStatusReport
terminal.getTerminalModel().setData(DataKey.TerminalWriter, writer)
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())

View File

@@ -1,5 +1,6 @@
package app.termora
import app.termora.Database.Appearance
import app.termora.actions.DataProvider
import java.beans.PropertyChangeListener
import javax.swing.Icon
@@ -44,10 +45,15 @@ interface TerminalTab : Disposable, DataProvider {
fun canClose(): Boolean = true
/**
* 返回 true 表示可以关闭
* 返回 true 表示可以关闭,只有当 [Appearance.confirmTabClose] 为 false 时才会调用
*/
fun willBeClose(): Boolean = true
/**
* 即将关闭,已经无法挽回
*/
fun beforeClose() {}
/**
* 是否可以克隆
*/

View File

@@ -32,6 +32,7 @@ class TerminalTabbed(
private val actionManager = ActionManager.getInstance()
private val dataProviderSupport = DataProviderSupport()
private val titleProperty = UUID.randomUUID().toSimpleString()
private val appearance get() = Database.getDatabase().appearance
private val iconListener = PropertyChangeListener { e ->
val source = e.source
if (e.propertyName == "icon" && source is TerminalTab) {
@@ -153,8 +154,29 @@ class TerminalTabbed(
if (tabbedPane.isTabClosable(index)) {
val tab = tabs[index]
// 询问是否可以关闭
if (disposable) {
if (!tab.willBeClose()) {
// 如果开启了关闭确认,那么直接询问用户
if (appearance.confirmTabClose) {
if (OptionPane.showConfirmDialog(
windowScope.window,
I18n.getString("termora.tabbed.tab.close-prompt"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) != JOptionPane.OK_OPTION
) {
return
}
} else if (!tab.willBeClose()) { // 如果没有开启则询问用户
return
}
}
// 通知即将关闭
if (disposable) {
try {
tab.beforeClose()
} catch (_: Exception) {
return
}
}
@@ -400,10 +422,12 @@ class TerminalTabbed(
private fun showContextMenu(event: MouseEvent) {
val popupMenu = FlatPopupMenu()
popupMenu.add(I18n.getString("termora.toolbar.customize-toolbar")).addActionListener {
val owner = SwingUtilities.getWindowAncestor(this@TerminalTabbed)
val dialog = CustomizeToolBarDialog(
SwingUtilities.getWindowAncestor(this@TerminalTabbed),
owner,
termoraToolBar
)
dialog.setLocationRelativeTo(owner)
if (dialog.open()) {
termoraToolBar.rebuild()
}

View File

@@ -645,9 +645,13 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
return
}
if (ohKeyPair.remark.isEmpty()) {
ohKeyPair = ohKeyPair.copy(
name = nameTextField.text,
remark = remarkTextField.text,
)
if (ohKeyPair.remark.isEmpty()) {
ohKeyPair = ohKeyPair.copy(
remark = "Import on " + DateFormatUtils.format(Date(), I18n.getString("termora.date-format"))
)
}

View File

@@ -39,7 +39,7 @@ class MacroDialog(owner: Window) : DialogWrapper(owner) {
initEvents()
init()
setLocationRelativeTo(null)
setLocationRelativeTo(owner)
}
private fun initView() {

View File

@@ -4,6 +4,7 @@ import app.termora.*
import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import app.termora.sftp.FileSystemViewTable.AskTransfer.Action
import app.termora.vfs2.VFSWalker
import app.termora.vfs2.sftp.MySftpFileObject
import app.termora.vfs2.sftp.MySftpFileSystem
import com.formdev.flatlaf.FlatClientProperties
@@ -37,7 +38,6 @@ import java.nio.file.FileVisitor
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileTime
import java.text.MessageFormat
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
@@ -45,6 +45,21 @@ import java.util.regex.Pattern
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import kotlin.collections.ArrayDeque
import kotlin.collections.List
import kotlin.collections.all
import kotlin.collections.contains
import kotlin.collections.filter
import kotlin.collections.filterIsInstance
import kotlin.collections.find
import kotlin.collections.forEach
import kotlin.collections.isEmpty
import kotlin.collections.isNotEmpty
import kotlin.collections.last
import kotlin.collections.listOf
import kotlin.collections.map
import kotlin.collections.mapOf
import kotlin.collections.mutableListOf
import kotlin.collections.sortedArray
import kotlin.io.path.absolutePathString
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
@@ -218,7 +233,7 @@ class FileSystemViewTable(
val localTarget = sftpPanel.getLocalTarget()
val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false
// 委托最左侧的本地文件系统传输
table.transfer(paths, true, targetWorkdir)
table.transfer(paths, true, targetWorkdir, fileSystemViewPanel)
return true
}
return false
@@ -360,34 +375,7 @@ class FileSystemViewTable(
override fun actionPerformed(e: ActionEvent) {
val last = files.last()
if (last !is MySftpFileObject) return
val dialog = PosixFilePermissionDialog(
SwingUtilities.getWindowAncestor(table),
model.getFilePermissions(last)
)
val permissions = dialog.open() ?: return
if (fileSystemViewPanel.requestLoading()) {
coroutineScope.launch(Dispatchers.IO) {
val c = runCatching { last.setPosixFilePermissions(permissions) }.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
// stop loading
fileSystemViewPanel.stopLoading()
// reload
if (c.isSuccess) {
fileSystemViewPanel.reload(true)
}
}
}
changePermission(last)
}
})
refresh.addActionListener { fileSystemViewPanel.reload() }
@@ -409,6 +397,80 @@ class FileSystemViewTable(
popupMenu.show(table, e.x, e.y)
}
private fun changePermission(file: MySftpFileObject) {
val dialog = PosixFilePermissionDialog(
SwingUtilities.getWindowAncestor(table),
model.getFilePermissions(file)
)
val permissions = dialog.open() ?: return
val isIncludeSubdirectories = dialog.isIncludeSubdirectories()
if (fileSystemViewPanel.requestLoading()) {
coroutineScope.launch(Dispatchers.IO) {
val c = runCatching {
file.setPosixFilePermissions(permissions)
if (isIncludeSubdirectories && file.isFolder) {
file.refresh()
VFSWalker.walk(file, object : FileVisitor<FileObject> {
override fun preVisitDirectory(
dir: FileObject,
attrs: BasicFileAttributes
): FileVisitResult {
dir.refresh()
if (dir is MySftpFileObject) {
dir.setPosixFilePermissions(permissions)
}
return FileVisitResult.CONTINUE
}
override fun visitFile(
file: FileObject,
attrs: BasicFileAttributes
): FileVisitResult {
if (file is MySftpFileObject) {
file.setPosixFilePermissions(permissions)
}
return FileVisitResult.CONTINUE
}
override fun visitFileFailed(
file: FileObject,
exc: IOException
): FileVisitResult {
return FileVisitResult.TERMINATE
}
override fun postVisitDirectory(
dir: FileObject,
exc: IOException?
): FileVisitResult {
return FileVisitResult.CONTINUE
}
})
}
}.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
// stop loading
fileSystemViewPanel.stopLoading()
// reload
if (c.isSuccess) {
fileSystemViewPanel.reload(true)
}
}
}
}
private fun renameSelection() {
val index = selectedRow
if (index < 0) return
@@ -619,12 +681,13 @@ class FileSystemViewTable(
private fun transfer(
files: List<FileObject>,
fromLocalSystem: Boolean = false,
targetWorkdir: FileObject? = null
targetWorkdir: FileObject? = null,
target: FileSystemViewPanel? = null,
) {
assertEventDispatchThread()
val target = sftpPanel.getTarget(table) ?: return
val target = (target ?: sftpPanel.getTarget(table)) ?: return
val table = target.getData(SFTPDataProviders.FileSystemViewTable) ?: return
var isApplyAll = false
var lastAction = Action.Overwrite
@@ -650,7 +713,7 @@ class FileSystemViewTable(
coroutineScope.launch {
try {
doTransfer(file, lastAction, fromLocalSystem, targetWorkdir)
doTransfer(file, lastAction, fromLocalSystem, targetWorkdir, target)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
@@ -801,10 +864,11 @@ class FileSystemViewTable(
file: FileObject,
action: Action,
fromLocalSystem: Boolean,
targetWorkdir: FileObject?
targetWorkdir: FileObject?,
target: FileSystemViewPanel? = null
) {
val sftpPanel = this.sftpPanel
val target = sftpPanel.getTarget(table) ?: return
val target = (target ?: sftpPanel.getTarget(table)) ?: return
/**
* 定义一个添加器,它可以自动的判断导入/拖拽行为
@@ -886,36 +950,7 @@ class FileSystemViewTable(
dir: FileObject,
visitor: FileVisitor<FileObject>,
): FileVisitResult {
// clear cache
if (visitor.preVisitDirectory(dir, EmptyBasicFileAttributes.INSTANCE) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
for (e in dir.children) {
if (e.name.baseName == ".." || e.name.baseName == ".") continue
if (e.isFolder) {
if (walk(dir.resolveFile(e.name.baseName), visitor) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
} else {
val result = visitor.visitFile(
dir.resolveFile(e.name.baseName),
EmptyBasicFileAttributes.INSTANCE
)
if (result == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
} else if (result == FileVisitResult.SKIP_SUBTREE) {
break
}
}
}
if (visitor.postVisitDirectory(dir, null) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
return FileVisitResult.CONTINUE
return VFSWalker.walk(dir, visitor)
}
private fun addTransport(
@@ -974,47 +1009,5 @@ class FileSystemViewTable(
}
private class EmptyBasicFileAttributes : BasicFileAttributes {
companion object {
val INSTANCE = EmptyBasicFileAttributes()
}
override fun lastModifiedTime(): FileTime {
TODO("Not yet implemented")
}
override fun lastAccessTime(): FileTime {
TODO("Not yet implemented")
}
override fun creationTime(): FileTime {
TODO("Not yet implemented")
}
override fun isRegularFile(): Boolean {
TODO("Not yet implemented")
}
override fun isDirectory(): Boolean {
TODO("Not yet implemented")
}
override fun isSymbolicLink(): Boolean {
TODO("Not yet implemented")
}
override fun isOther(): Boolean {
TODO("Not yet implemented")
}
override fun size(): Long {
TODO("Not yet implemented")
}
override fun fileKey(): Any {
TODO("Not yet implemented")
}
}
}

View File

@@ -157,8 +157,12 @@ class FileSystemViewTableModel : DefaultTableModel() {
fun getPathNames(): Set<String> {
val names = linkedSetOf<String>()
for (i in 0 until rowCount) {
if (hasParent && i == 0) {
names.add("..")
} else {
names.add(getFileObject(i).name.baseName)
}
}
return names
}

View File

@@ -25,6 +25,7 @@ class PosixFilePermissionDialog(
private val otherRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
private val otherWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
private val otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
private val includeSubFolder = JCheckBox(I18n.getString("termora.transport.permissions.include-subfolder"))
private var isCancelled = false
@@ -60,13 +61,14 @@ class PosixFilePermissionDialog(
otherRead.isFocusable = false
otherWrite.isFocusable = false
otherExecute.isFocusable = false
includeSubFolder.isFocusable = false
}
override fun createCenterPanel(): JComponent {
val formMargin = "7dlu"
val layout = FormLayout(
"default:grow, $formMargin, default:grow, $formMargin, default:grow",
"pref, $formMargin, pref, $formMargin, pref"
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin")
@@ -95,6 +97,8 @@ class PosixFilePermissionDialog(
otherBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.others"))
builder.add(otherBox).xy(5, 3)
builder.add(includeSubFolder).xyw(1, 5, 5)
return builder.build()
}
@@ -103,6 +107,10 @@ class PosixFilePermissionDialog(
super.doCancelAction()
}
fun isIncludeSubdirectories(): Boolean {
return includeSubFolder.isSelected
}
/**
* @return 返回空表示取消了
*/

View File

@@ -56,6 +56,16 @@ class SFTPAction : AnAction("SFTP", Icons.folder) {
}
val host = hostManager.getHost(hostId) ?: return
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getComponentAt(i)
if (c is SFTPFileSystemViewPanel) {
if (c.state == SFTPFileSystemViewPanel.State.Initialized) {
c.selectHost(host)
return
}
}
}
tabbed.addSFTPFileSystemViewPanelTab(host)
}

View File

@@ -27,6 +27,8 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.*
import javax.swing.event.TreeExpansionEvent
import javax.swing.event.TreeExpansionListener
class SFTPFileSystemViewPanel(
var host: Host? = null,
@@ -35,17 +37,18 @@ class SFTPFileSystemViewPanel(
companion object {
private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java)
}
private enum class State {
enum class State {
Initialized,
Connecting,
Connected,
ConnectFailed,
}
}
@Volatile
private var state = State.Initialized
var state = State.Initialized
private set
private val cardLayout = CardLayout()
private val cardPanel = JPanel(cardLayout)
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -283,12 +286,20 @@ class SFTPFileSystemViewPanel(
val node = tree.getLastSelectedPathNode() ?: return
if (node.isFolder) return
val host = node.data as Host
that.setTabTitle(host.name)
that.host = host
that.connect()
selectHost(host)
}
}
})
tree.addTreeExpansionListener(object : TreeExpansionListener {
override fun treeExpanded(event: TreeExpansionEvent) {
properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree))
}
override fun treeCollapsed(event: TreeExpansionEvent) {
properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree))
}
})
}
override fun dispose() {
@@ -305,6 +316,12 @@ class SFTPFileSystemViewPanel(
}
}
fun selectHost(host: Host) {
that.setTabTitle(host.name)
that.host = host
that.connect()
}
private fun setTabTitle(title: String) {
val tabbed = SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, that)
if (tabbed is JTabbedPane) {

View File

@@ -5,7 +5,6 @@ import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import java.awt.Point
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.concurrent.atomic.AtomicBoolean
@@ -13,7 +12,6 @@ import javax.swing.JButton
import javax.swing.JToolBar
import javax.swing.SwingUtilities
import javax.swing.UIManager
import kotlin.math.max
@Suppress("DuplicatedCode")
class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPane(), Disposable {
@@ -43,23 +41,20 @@ class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPan
private fun initEvents() {
addBtn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(tabbed))
dialog.location = Point(
max(0, addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2),
addBtn.locationOnScreen.y + max(tabHeight, addBtn.height)
)
dialog.setFilter { it.host.protocol == Protocol.SSH }
dialog.setTreeName("SFTPTabbed.Tree")
dialog.allowMulti = true
dialog.isVisible = true
val hosts = dialog.hosts
if (hosts.isEmpty()) return
for (host in hosts) {
addSFTPFileSystemViewPanelTab(host)
for (i in 0 until tabCount) {
val c = getComponentAt(i)
if (c !is SFTPFileSystemViewPanel) continue
if (c.state != SFTPFileSystemViewPanel.State.Initialized) continue
selectedIndex = i
return
}
// 添加一个新的
addTab(
I18n.getString("termora.transport.sftp.select-host"),
SFTPFileSystemViewPanel(transportManager = transportManager)
)
selectedIndex = tabCount - 1
}
})

View File

@@ -18,6 +18,16 @@ class SnippetAction private constructor() : AnAction(I18n.getString("termora.sni
}
const val SNIPPET = "SnippetAction"
// \r \n \t \a \e \b
private val SpecialChars = mutableMapOf(
'r' to '\r',
'n' to '\n',
't' to '\t',
'a' to ControlCharacters.BEL,
'e' to ControlCharacters.ESC,
'b' to ControlCharacters.BS
)
}
override fun actionPerformed(evt: AnActionEvent) {
@@ -27,31 +37,39 @@ class SnippetAction private constructor() : AnAction(I18n.getString("termora.sni
fun runSnippet(snippet: Snippet, writer: TerminalWriter) {
if (snippet.type != SnippetType.Snippet) return
val map = mapOf(
"\n" to ControlCharacters.LF,
"\r" to ControlCharacters.CR,
"\t" to ControlCharacters.TAB,
"\b" to ControlCharacters.BS,
"\\a" to ControlCharacters.BEL,
"\\e" to ControlCharacters.ESC,
)
val chars = snippet.snippet.toCharArray()
writer.write(TerminalWriter.WriteRequest.fromBytes(unescape(snippet.snippet).toByteArray(writer.getCharset())))
}
private fun unescape(text: String): String {
val chars = text.toCharArray()
val sb = StringBuilder()
for (i in chars.indices) {
val c = chars[i]
if (i == 0) continue
if (c != '\n') continue
if (chars[i - 1] != '\\') continue
// 每一行的最后一个 \ 比较特殊,先转成 null 然后再去 unescapeJava
chars[i - 1] = Char.Null
// 不是特殊字符不处理
if (SpecialChars.containsKey(c).not()) {
sb.append(c)
continue
}
var text = chars.joinToString(StringUtils.EMPTY)
text = StringEscapeUtils.unescapeJava(text)
for (e in map.entries) {
text = text.replace(e.key, e.value.toString())
// 特殊字符前面不是 `\` 不处理
if (chars.getOrNull(i - 1) != '\\') {
sb.append(c)
continue
}
text = snippet.snippet.replace(Char.Null, '\\')
writer.write(TerminalWriter.WriteRequest.fromBytes(text.toByteArray(writer.getCharset())))
// 如果构成的字符串是:\\r 就会生成 \r 字符串并非转译成CR
if (chars.getOrNull(i - 2) == '\\') {
sb.deleteCharAt(sb.length - 1)
sb.append(c)
continue
}
// 命中条件之后,那么 sb 最后一个字符肯定是 \
sb.deleteCharAt(sb.length - 1)
sb.append(SpecialChars.getValue(c))
}
return sb.toString()
}
}

View File

@@ -41,8 +41,10 @@ class SnippetPanel : JPanel(BorderLayout()), Disposable {
private fun initViews() {
val splitPane = JSplitPane()
splitPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
val scrollPane = JScrollPane(snippetTree)
scrollPane.border = BorderFactory.createEmptyBorder()
leftPanel.add(snippetTree, BorderLayout.CENTER)
leftPanel.add(scrollPane, BorderLayout.CENTER)
leftPanel.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(4, 4, 4, 4)

View File

@@ -360,8 +360,9 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
}
}
// TODO Send Device Attributes (Primary DA).
// Send Device Attributes (Primary DA).
'c' -> {
sendDeviceAttributes()
}
// CSI Ps M Delete Ps Line(s) (default = 1) (DL).
@@ -505,6 +506,22 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
}
private fun sendDeviceAttributes() {
assertEventDispatchThread()
if (!terminalModel.hasData(DataKey.TerminalWriter)) {
return
}
val writer = terminalModel.getData(DataKey.TerminalWriter)
// VT102_RESPONSE
val bytes = "${ControlCharacters.ESC}[?6c".toByteArray(writer.getCharset())
writer.write(TerminalWriter.WriteRequest.fromBytes(bytes))
}
/**
* https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Functions-using-CSI-_-ordered-by-the-final-character-lparen-s-rparen:CSI-?-Pm-h.1D0E
*/

View File

@@ -21,6 +21,16 @@ interface SelectionModel {
*/
fun setSelection(startPosition: Position, endPosition: Position)
/**
* 设置块选中模式
*/
fun setBlockSelection(block: Boolean)
/**
* 是否是块选中模式
*/
fun isBlockSelection(): Boolean
/**
* 获取开始选中的位置
*/

View File

@@ -7,6 +7,7 @@ import kotlin.math.min
open class SelectionModelImpl(private val terminal: Terminal) : SelectionModel {
private var startPosition = Position.unknown
private var endPosition = Position.unknown
private var block = false
private val document = terminal.getDocument()
internal companion object {
@@ -67,7 +68,37 @@ open class SelectionModelImpl(private val terminal: Terminal) : SelectionModel {
return sb.toString()
}
val iterator = getChars(getSelectionStartPosition(), getSelectionEndPosition())
val start = getSelectionStartPosition()
val end = getSelectionEndPosition()
if (isBlockSelection()) {
val left = min(start.x, end.x)
val right = max(start.x, end.x)
val top = min(start.y, end.y)
val bottom = max(start.y, end.y)
for (lineNum in top..bottom) {
val line = document.getLine(lineNum)
val chars = line.chars()
// 块选中要处理超出边界
val from = (left - 1).coerceAtLeast(0)
val to = right.coerceAtMost(chars.size)
if (from < to) {
val selected = chars.subList(from, to)
.filter { !it.first.isNull && !it.first.isSoftHyphen }
.joinToString("") { it.first.toString() }
sb.append(selected)
}
if (lineNum != bottom) {
sb.appendLine()
}
}
} else {
val iterator = getChars(start, end)
while (iterator.hasNext()) {
val line = iterator.next()
val chars = line.chars()
@@ -92,6 +123,7 @@ open class SelectionModelImpl(private val terminal: Terminal) : SelectionModel {
sb.appendLine()
}
}
}
if (sb.isNotEmpty() && sb.last() == ControlCharacters.LF) {
sb.deleteCharAt(sb.length - 1)
@@ -171,6 +203,12 @@ open class SelectionModelImpl(private val terminal: Terminal) : SelectionModel {
fireSelectionChanged()
}
override fun setBlockSelection(block: Boolean) {
this.block = block
}
override fun isBlockSelection() = block
override fun getSelectionStartPosition(): Position {
return startPosition
}
@@ -202,13 +240,20 @@ open class SelectionModelImpl(private val terminal: Terminal) : SelectionModel {
}
override fun hasSelection(x: Int, y: Int): Boolean {
return hasSelection() && isPointInsideArea(
startPosition,
endPosition,
x,
y,
terminal.getTerminalModel().getCols()
)
if (hasSelection().not()) return false
// 如果是块选中
if (isBlockSelection()) {
val left = min(startPosition.x, endPosition.x)
val right = max(startPosition.x, endPosition.x)
val top = min(startPosition.y, endPosition.y)
val bottom = max(startPosition.y, endPosition.y)
return x in left..right && y in top..bottom
}
return isPointInsideArea(startPosition, endPosition, x, y, terminal.getTerminalModel().getCols())
}

View File

@@ -2,6 +2,7 @@ package app.termora.terminal.panel
import app.termora.Application
import app.termora.ApplicationScope
import app.termora.Database
import app.termora.terminal.*
import java.awt.Graphics
import java.net.URI
@@ -16,6 +17,7 @@ class TerminalHyperlinkPaintListener private constructor() : TerminalPaintListen
}
private val regex = Regex("https?://\\S*[^.\\s'\",()<>\\[\\]]")
private val isEnableHyperlink get() = Database.getDatabase().terminal.hyperlink
override fun before(
offset: Int,
@@ -25,6 +27,9 @@ class TerminalHyperlinkPaintListener private constructor() : TerminalPaintListen
terminalDisplay: TerminalDisplay,
terminal: Terminal
) {
if (isEnableHyperlink.not()) return
val document = terminal.getDocument()
var startOffset = offset
var endOffset = startOffset + count
@@ -91,4 +96,18 @@ class TerminalHyperlinkPaintListener private constructor() : TerminalPaintListen
}
}
override fun after(
offset: Int,
count: Int,
g: Graphics,
terminalPanel: TerminalPanel,
terminalDisplay: TerminalDisplay,
terminal: Terminal
) {
if (isEnableHyperlink.not()) {
// 删除之前的
terminal.getMarkupModel().removeAllHighlighters(Highlighter.HYPERLINK)
}
}
}

View File

@@ -134,6 +134,8 @@ class TerminalPanelMouseSelectionAdapter(private val terminalPanel: TerminalPane
// 如果不判断的话可能会导致移动了一点点就就进入选择状态了
val diff = terminalPanel.getAverageCharWidth() / 5.0
if (abs(mousePressedPoint.y - e.y) >= diff || abs(mousePressedPoint.x - e.x) >= diff) {
// 设置选中模式
terminal.getSelectionModel().setBlockSelection(isOnlyAltDown(e))
beginSelect(
Position(x = mousePressedPoint.x, y = mousePressedPoint.y),
)
@@ -141,6 +143,13 @@ class TerminalPanelMouseSelectionAdapter(private val terminalPanel: TerminalPane
}
}
private fun isOnlyAltDown(e: MouseEvent): Boolean {
return e.isAltDown &&
e.isMetaDown.not() &&
e.isControlDown.not() &&
e.isShiftDown.not() &&
e.isAltGraphDown.not()
}
private fun beginSelect(position: Position) {

View File

@@ -0,0 +1,88 @@
package app.termora.vfs2
import org.apache.commons.vfs2.FileObject
import java.nio.file.FileVisitResult
import java.nio.file.FileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileTime
object VFSWalker {
fun walk(
dir: FileObject,
visitor: FileVisitor<FileObject>,
): FileVisitResult {
// clear cache
if (visitor.preVisitDirectory(dir, EmptyBasicFileAttributes.INSTANCE) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
for (e in dir.children) {
if (e.name.baseName == ".." || e.name.baseName == ".") continue
if (e.isFolder) {
if (walk(dir.resolveFile(e.name.baseName), visitor) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
} else {
val result = visitor.visitFile(
dir.resolveFile(e.name.baseName),
EmptyBasicFileAttributes.INSTANCE
)
if (result == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
} else if (result == FileVisitResult.SKIP_SUBTREE) {
break
}
}
}
if (visitor.postVisitDirectory(dir, null) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
return FileVisitResult.CONTINUE
}
private class EmptyBasicFileAttributes : BasicFileAttributes {
companion object {
val INSTANCE = EmptyBasicFileAttributes()
}
override fun lastModifiedTime(): FileTime {
TODO("Not yet implemented")
}
override fun lastAccessTime(): FileTime {
TODO("Not yet implemented")
}
override fun creationTime(): FileTime {
TODO("Not yet implemented")
}
override fun isRegularFile(): Boolean {
TODO("Not yet implemented")
}
override fun isDirectory(): Boolean {
TODO("Not yet implemented")
}
override fun isSymbolicLink(): Boolean {
TODO("Not yet implemented")
}
override fun isOther(): Boolean {
TODO("Not yet implemented")
}
override fun size(): Long {
TODO("Not yet implemented")
}
override fun fileKey(): Any {
TODO("Not yet implemented")
}
}
}

View File

@@ -57,6 +57,7 @@ termora.settings.appearance.follow-system=Sync with OS
termora.settings.appearance.opacity=Opacity
termora.settings.appearance.background-image=BG Image
termora.settings.appearance.background-running=Backgrounding
termora.settings.appearance.confirm-tab-close=Confirm tab close
termora.setting.security=Security
termora.setting.security.enter-password=Enter password
@@ -73,6 +74,7 @@ termora.settings.terminal.size=Size
termora.settings.terminal.max-rows=Max rows
termora.settings.terminal.debug=Debug mode
termora.settings.terminal.beep=Beep
termora.settings.terminal.hyperlink=Hyperlink
termora.settings.terminal.select-copy=Select copy
termora.settings.terminal.cursor-style=Cursor type
termora.settings.terminal.cursor-blink=Cursor blink
@@ -232,6 +234,7 @@ termora.tabbed.contextmenu.close-other-tabs=Close Other Tabs
termora.tabbed.contextmenu.close-all-tabs=Close All Tabs
termora.tabbed.contextmenu.reconnect=Reconnect
termora.tabbed.local-tab.close-prompt=Do you want to terminal a running process in this terminal?
termora.tabbed.tab.close-prompt=Are you sure you want to close this tab?
# Terminal logger
termora.terminal-logger=Terminal Logger
@@ -309,6 +312,7 @@ termora.transport.permissions.execute=Execute
termora.transport.permissions.owner=Owner
termora.transport.permissions.group=Group
termora.transport.permissions.others=Others
termora.transport.permissions.include-subfolder=Include subdirectories
termora.transport.sftp.retry=Retry
termora.transport.sftp.select-another-host=Select another host

View File

@@ -54,6 +54,7 @@ termora.settings.appearance.follow-system=跟随系统
termora.settings.appearance.opacity=透明度
termora.settings.appearance.background-image=背景图
termora.settings.appearance.background-running=后台运行
termora.settings.appearance.confirm-tab-close=标签关闭前确认
termora.setting.security=安全
termora.setting.security.enter-password=请输入密码
@@ -77,6 +78,7 @@ termora.settings.terminal.size=大小
termora.settings.terminal.max-rows=最大行数
termora.settings.terminal.debug=调试模式
termora.settings.terminal.beep=蜂鸣声
termora.settings.terminal.hyperlink=超链接
termora.settings.terminal.select-copy=选中复制
termora.settings.terminal.cursor-style=光标样式
termora.settings.terminal.cursor-blink=光标闪烁
@@ -221,6 +223,7 @@ termora.tabbed.contextmenu.close-other-tabs=关闭其他标签页
termora.tabbed.contextmenu.close-all-tabs=关闭所有标签页
termora.tabbed.contextmenu.reconnect=重新连接
termora.tabbed.local-tab.close-prompt=你想要终止这个终端中正在运行的进程吗?
termora.tabbed.tab.close-prompt=你确定要关闭这个标签页吗?
# Terminal logger
@@ -322,6 +325,7 @@ termora.transport.permissions.execute=执行
termora.transport.permissions.owner=所有者
termora.transport.permissions.group=
termora.transport.permissions.others=其他
termora.transport.permissions.include-subfolder=包含子目录
# transport job
termora.transport.jobs.table.name=名称

View File

@@ -55,6 +55,7 @@ termora.settings.appearance.follow-system=跟隨系統
termora.settings.appearance.opacity=透明度
termora.settings.appearance.background-image=背景圖
termora.settings.appearance.background-running=後台運行
termora.settings.appearance.confirm-tab-close=關閉分頁確認
termora.setting.security=安全
termora.setting.security.enter-password=請輸入密碼
@@ -88,7 +89,8 @@ termora.settings.terminal.font=字體
termora.settings.terminal.size=大小
termora.settings.terminal.max-rows=最大行數
termora.settings.terminal.debug=偵錯模式
termora.settings.terminal.beep=蜂鳴聲
termora.settings.terminal.beep=超連結
termora.settings.terminal.hyperlink=Hyperlink
termora.settings.terminal.select-copy=選取複製
termora.settings.terminal.cursor-style=遊標風格
termora.settings.terminal.cursor-blink=遊標閃爍
@@ -217,7 +219,7 @@ termora.tabbed.contextmenu.close-other-tabs=關閉其他標籤頁
termora.tabbed.contextmenu.close-all-tabs=關閉所有標籤
termora.tabbed.contextmenu.reconnect=重新連接
termora.tabbed.local-tab.close-prompt=你想要終止這個終端機中正在運作的進程嗎?
termora.tabbed.tab.close-prompt=你確定要關閉這個分頁嗎?
# Terminal logger
@@ -305,6 +307,17 @@ termora.transport.sftp.already-exists.destination=目標文件
termora.transport.sftp.already-exists.source=原始檔
termora.transport.sftp.already-exists.actions=操作
# permissions
termora.transport.permissions=更改權限
termora.transport.permissions.file-folder-permissions=檔案/資料夾權限
termora.transport.permissions.read=讀取
termora.transport.permissions.write=寫入
termora.transport.permissions.execute=執行
termora.transport.permissions.owner=所有者
termora.transport.permissions.group=群組
termora.transport.permissions.others=其他
termora.transport.permissions.include-subfolder=包含子目錄
# transport job
termora.transport.jobs.table.name=名稱
termora.transport.jobs.table.status=狀態

View File

@@ -55,6 +55,10 @@ Source: "{#MySourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdir
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[InstallDelete]
Type: files; Name: "{app}\app\*.jar"
Type: filesandordirs; Name: "{app}\runtime\*"
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall; Check: ShouldPromptStart
Filename: "{app}\{#MyAppExeName}"; Flags: nowait runhidden; Check: ShouldAutoStart

View File

@@ -0,0 +1,29 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
# 安装基础包 + sshd + nvim 依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-server curl ca-certificates tzdata git unzip \
libfuse2 locales && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# 安装 nvim 最新版AppImage 提取)
RUN curl -LO https://github.com/neovim/neovim/releases/download/v0.11.1/nvim-linux-arm64.appimage && \
mv nvim-linux-arm64.appimage nvim.appimage && chmod u+x nvim.appimage && ./nvim.appimage --appimage-extract && \
mv squashfs-root/usr/bin/nvim /usr/local/bin/nvim && \
rm -rf squashfs-root nvim.appimage
# 配置 SSH
RUN mkdir /var/run/sshd && \
echo 'root:root' | chpasswd && \
echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config && \
echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config
# 设置语言环境(可选)
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \
apt-get update && apt-get install -y locales && \
locale-gen en_US.UTF-8 && \
update-locale LANG=en_US.UTF-8
ENV LANG=en_US.UTF-8 \
LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8
# 启动 SSHD
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]