Compare commits

...

29 Commits

Author SHA1 Message Date
hstyi
7c26e3d08a release: 1.0.11 2025-03-27 12:03:11 +08:00
hstyi
9b84fb4ec8 chore: ignore verify server key (#398) 2025-03-27 11:52:36 +08:00
hstyi
d8ec7b6d4a chore: automatically jump to the bottom (#397) 2025-03-27 11:44:10 +08:00
hstyi
769c0d990b fix: max row selection 2025-03-17 08:48:46 +08:00
hstyi
3f1ae38b61 chore: improve tick 2025-03-17 08:48:34 +08:00
hstyi
e10fce21a2 fix: flat inspector key shortcut 2025-03-16 17:04:12 +08:00
hstyi
a00557bb9d feat: process lock (#380) 2025-03-16 17:02:40 +08:00
hstyi
e478535ae5 chore: visual window stick 2025-03-16 10:21:33 +08:00
hstyi
7756758738 fix: SFTP path not working 2025-03-16 10:05:32 +08:00
hstyi
e0ea42faee feat: floating window supports stick (#374) 2025-03-16 08:42:25 +08:00
hstyi
e72c6b77b5 chore: Dockerfile x11 2025-03-15 23:15:09 +08:00
hstyi
bcd3aacd6f fix: emacs alt x 2025-03-15 20:49:55 +08:00
hstyi
570b0e08ad fix: AWTEventListener memory leaks 2025-03-15 15:11:50 +08:00
hstyi
d703850e87 chore: sftp failed message 2025-03-15 14:57:25 +08:00
hstyi
4bb1a411e8 feat: without jbr 2025-03-15 13:20:08 +08:00
hstyi
9884ed19fa chore: macOS dispatch_async 2025-03-15 08:29:42 +08:00
hstyi
1ffaed3f36 fix: sftp ui 2025-03-14 12:25:25 +08:00
hstyi
4cb42953ad feat: sftp contextmenu (#366) 2025-03-14 11:47:41 +08:00
hstyi
0248992dc3 chore: Command + Q will not trigger a popup 2025-03-14 09:36:17 +08:00
hstyi
9bab9db875 chore: hide copied toast 2025-03-14 09:28:45 +08:00
hstyi
b283a3ea38 feat: supports importing hosts from SSH config (#359) 2025-03-14 00:03:02 +08:00
hstyi
98ac2928b4 fix: xterm-256 foreground & background color (#358) 2025-03-13 23:39:18 +08:00
hstyi
a0a6f43c10 fix: arrow keys 2025-03-13 23:39:01 +08:00
hstyi
0c158acbe0 fix: sftp symbolic link 2025-03-13 22:21:39 +08:00
hstyi
9a97b3a304 feat: send command to the current window sessions 2025-03-13 22:17:01 +08:00
hstyi
aef44bd0da chore: improve factories 2025-03-13 20:45:49 +08:00
hstyi
75c65d9ba8 feat: support edit host (#352) 2025-03-13 20:45:31 +08:00
hstyi
93755db77f fix: nano bg color 2025-03-13 17:10:17 +08:00
hstyi
79d0a9a348 refactor: SFTP (#351) 2025-03-13 16:33:57 +08:00
133 changed files with 5413 additions and 3658 deletions

View File

@@ -45,5 +45,4 @@ jobs:
name: termora-windows-x86-64
path: |
build/distributions/*.zip
build/distributions/*.msi
build/distributions/*.exe

View File

@@ -20,7 +20,7 @@ plugins {
group = "app.termora"
version = "1.0.10"
version = "1.0.11"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
@@ -118,7 +118,6 @@ dependencies {
application {
val args = mutableListOf(
"--add-exports java.base/sun.nio.ch=ALL-UNNAMED",
"-Xmx2g",
"-XX:+UseZGC",
"-XX:+ZUncommit",
@@ -127,7 +126,10 @@ application {
)
if (os.isMacOsX) {
args.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
// macOS NSWindow
args.add("--add-opens java.desktop/java.awt=ALL-UNNAMED")
args.add("--add-opens java.desktop/sun.lwawt=ALL-UNNAMED")
args.add("--add-opens java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
args.add("-Dsun.java2d.metal=true")
args.add("-Dapple.awt.application.appearance=system")
}
@@ -345,6 +347,10 @@ tasks.register<Exec>("jpackage") {
options.add("-Dsun.java2d.metal=true")
if (os.isMacOsX) {
// NSWindow
options.add("--add-opens java.desktop/java.awt=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("-Dapple.awt.application.appearance=system")
options.add("--add-opens java.desktop/sun.lwawt.macosx.concurrent=ALL-UNNAMED")
}
@@ -421,15 +427,9 @@ tasks.register<Exec>("jpackage") {
tasks.register("dist") {
doLast {
val vendor = Jvm.current().vendor ?: StringUtils.EMPTY
@Suppress("UnstableApiUsage")
if (!JvmVendorSpec.JETBRAINS.matches(vendor)) {
throw GradleException("JVM: $vendor is not supported")
}
val gradlew = File(projectDir, if (os.isWindows) "gradlew.bat" else "gradlew").absolutePath
// 清空目录
exec { commandLine(gradlew, "clean") }
@@ -736,8 +736,6 @@ fun stapleMacOSLocalFile(file: File) {
kotlin {
jvmToolchain {
languageVersion = JavaLanguageVersion.of(21)
@Suppress("UnstableApiUsage")
vendor = JvmVendorSpec.JETBRAINS
}
}

View File

@@ -0,0 +1,38 @@
package app.termora;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.WString;
import com.sun.jna.win32.StdCallLibrary;
interface MyKernel32 extends StdCallLibrary {
MyKernel32 INSTANCE = Native.load("Kernel32", MyKernel32.class);
WString INVARIANT_LOCALE = new WString("");
int CompareStringEx(WString lpLocaleName,
int dwCmpFlags,
WString lpString1,
int cchCount1,
WString lpString2,
int cchCount2,
Pointer lpVersionInformation,
Pointer lpReserved,
int lParam);
default int CompareStringEx(int dwCmpFlags,
String str1,
String str2) {
return MyKernel32.INSTANCE
.CompareStringEx(
INVARIANT_LOCALE,
dwCmpFlags,
new WString(str1),
str1.length(),
new WString(str2),
str2.length(),
Pointer.NULL,
Pointer.NULL,
0);
}
}

View File

@@ -2,12 +2,6 @@ package app.termora
object Actions {
/**
* 将命令发送到多个会话
*/
const val MULTIPLE = "MultipleAction"
/**
* 关键词高亮
*/

View File

@@ -150,6 +150,16 @@ object Application {
ProcessBuilder("xdg-open", uri.toString()).start()
}
}
fun browseInFolder(file: File) {
if (SystemInfo.isWindows) {
ProcessBuilder("explorer", "/select," + file.absolutePath).start()
} else if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-R", file.absolutePath).start()
} else if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
Desktop.getDesktop().browseFileDirectory(file)
}
}
}
fun formatBytes(bytes: Long): String {
@@ -159,7 +169,7 @@ fun formatBytes(bytes: Long): String {
val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt()
val value = bytes / 1024.0.pow(exp.toDouble())
return String.format("%.2f %s", value, units[exp])
return String.format("%.2f%s", value, units[exp])
}
fun formatSeconds(seconds: Long): String {
@@ -168,11 +178,33 @@ fun formatSeconds(seconds: Long): String {
val minutes = (seconds % 3600) / 60
val remainingSeconds = seconds % 60
return when {
days > 0 -> "${days}${hours}小时${minutes}${remainingSeconds}"
hours > 0 -> "${hours}小时${minutes}${remainingSeconds}"
minutes > 0 -> "${minutes}${remainingSeconds}"
else -> "${remainingSeconds}"
days > 0 -> I18n.getString(
"termora.transport.jobs.table.estimated-time-days-format",
days,
hours,
minutes,
remainingSeconds
)
hours > 0 -> I18n.getString(
"termora.transport.jobs.table.estimated-time-hours-format",
hours,
minutes,
remainingSeconds
)
minutes > 0 -> I18n.getString(
"termora.transport.jobs.table.estimated-time-minutes-format",
minutes,
remainingSeconds
)
else -> I18n.getString(
"termora.transport.jobs.table.estimated-time-seconds-format",
remainingSeconds
)
}
}

View File

@@ -0,0 +1,91 @@
package app.termora
import com.formdev.flatlaf.util.SystemInfo
import com.pty4j.util.PtyUtil
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.tinylog.configuration.Configuration
import java.io.File
import kotlin.system.exitProcess
class ApplicationInitializr {
fun run() {
// 由于 macOS 签名和公证问题,依赖二进制依赖会单独在一个文件夹
if (SystemUtils.IS_OS_MAC_OSX) {
setupNativeLibraries()
}
if (SystemUtils.IS_OS_MAC_OSX) {
System.setProperty("apple.awt.application.name", Application.getName())
}
// 设置 tinylog
setupTinylog()
// 检查是否单例
checkSingleton()
// 启动
ApplicationRunner().run()
}
private fun setupNativeLibraries() {
if (!SystemUtils.IS_OS_MAC_OSX) {
return
}
val appPath = Application.getAppPath()
if (StringUtils.isBlank(appPath)) {
return
}
val contents = File(appPath).parentFile?.parentFile ?: return
val dylib = FileUtils.getFile(contents, "app", "dylib")
if (!dylib.exists()) {
return
}
val jna = FileUtils.getFile(dylib, "jna")
if (jna.exists()) {
System.setProperty("jna.boot.library.path", jna.absolutePath)
}
val pty4j = FileUtils.getFile(dylib, "pty4j")
if (pty4j.exists()) {
System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath)
}
val jSerialComm = FileUtils.getFile(dylib, "jSerialComm")
if (jSerialComm.exists()) {
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
}
val restart4j = FileUtils.getFile(dylib, "restart4j", "restarter")
if (restart4j.exists()) {
System.setProperty("restarter.path", restart4j.absolutePath)
}
}
/**
* Windows 情况覆盖
*/
private fun setupTinylog() {
if (SystemInfo.isWindows) {
val dir = File(Application.getBaseDataDir(), "logs")
FileUtils.forceMkdir(dir)
Configuration.set("writer_file.latest", "${dir.absolutePath}/${Application.getName().lowercase()}.log")
Configuration.set("writer_file.file", "${dir.absolutePath}/{date:yyyy}-{date:MM}-{date:dd}.log")
}
}
private fun checkSingleton() {
if (ApplicationSingleton.getInstance().isSingleton()) return
System.err.println("Program is already running")
exitProcess(1)
}
}

View File

@@ -5,8 +5,8 @@ import app.termora.keymap.KeymapManager
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatSystemProperties
import com.formdev.flatlaf.extras.FlatDesktop
import com.formdev.flatlaf.extras.FlatDesktop.QuitResponse
import com.formdev.flatlaf.extras.FlatInspector
import com.formdev.flatlaf.ui.FlatTableCellBorder
import com.formdev.flatlaf.util.SystemInfo
import com.jthemedetecor.OsThemeDetector
import com.mixpanel.mixpanelapi.ClientDelivery
@@ -21,36 +21,16 @@ import org.apache.commons.lang3.LocaleUtils
import org.apache.commons.lang3.SystemUtils
import org.json.JSONObject
import org.slf4j.LoggerFactory
import org.tinylog.configuration.Configuration
import java.awt.KeyboardFocusManager
import java.io.File
import java.nio.channels.FileChannel
import java.nio.channels.FileLock
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.util.*
import java.util.function.Consumer
import javax.swing.*
import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis
class ApplicationRunner {
private lateinit var singletonChannel: FileChannel
private lateinit var singletonLock: FileLock
private val log by lazy {
if (!::singletonLock.isInitialized) {
throw UnsupportedOperationException("Singleton lock is not initialized")
}
LoggerFactory.getLogger(ApplicationRunner::class.java)
}
private val log by lazy { LoggerFactory.getLogger(ApplicationRunner::class.java) }
fun run() {
measureTimeMillis {
// 覆盖 tinylog 配置
val setupTinylog = measureTimeMillis { setupTinylog() }
// 是否单例
val checkSingleton = measureTimeMillis { checkSingleton() }
// 打印系统信息
val printSystemInfo = measureTimeMillis { printSystemInfo() }
@@ -84,8 +64,6 @@ class ApplicationRunner {
val startMainFrame = measureTimeMillis { startMainFrame() }
if (log.isDebugEnabled) {
log.debug("setupTinylog: {}ms", setupTinylog)
log.debug("checkSingleton: {}ms", checkSingleton)
log.debug("printSystemInfo: {}ms", printSystemInfo)
log.debug("openDatabase: {}ms", openDatabase)
log.debug("loadSettings: {}ms", loadSettings)
@@ -120,36 +98,18 @@ class ApplicationRunner {
private fun startMainFrame() {
TermoraFrameManager.getInstance().createWindow().isVisible = true
if (SystemUtils.IS_OS_MAC_OSX) {
SwingUtilities.invokeLater {
FlatDesktop.setQuitHandler(object : Consumer<QuitResponse> {
override fun accept(response: QuitResponse) {
quitHandler(response)
}
})
}
SwingUtilities.invokeLater { FlatDesktop.setQuitHandler { quitHandler() } }
}
}
private fun quitHandler(response: QuitResponse) {
val keyboardFocusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
if (OptionPane.showConfirmDialog(
keyboardFocusManager.focusedWindow,
I18n.getString("termora.quit-confirm", Application.getName()),
optionType = JOptionPane.YES_NO_OPTION,
) != JOptionPane.YES_OPTION
) {
response.cancelQuit()
return
}
private fun quitHandler() {
for (frame in TermoraFrameManager.getInstance().getWindows()) {
frame.dispose()
}
}
private fun loadSettings() {
@@ -164,7 +124,7 @@ class ApplicationRunner {
private fun setupLaf() {
System.setProperty(FlatSystemProperties.USE_WINDOW_DECORATIONS, "${SystemInfo.isLinux}")
System.setProperty(FlatSystemProperties.USE_WINDOW_DECORATIONS, "${SystemInfo.isLinux || SystemInfo.isWindows}")
System.setProperty(FlatSystemProperties.USE_ROUNDED_POPUP_BORDER, "false")
if (SystemInfo.isLinux) {
@@ -186,6 +146,7 @@ class ApplicationRunner {
themeManager.change(theme, true)
if (Application.isUnknownVersion())
FlatInspector.install("ctrl shift alt X")
@@ -218,9 +179,8 @@ class ApplicationRunner {
}
UIManager.put("Table.rowHeight", 24)
UIManager.put("Table.cellNoFocusBorder", BorderFactory.createEmptyBorder())
UIManager.put("Table.focusCellHighlightBorder", BorderFactory.createEmptyBorder())
UIManager.put("Table.focusSelectedCellHighlightBorder", BorderFactory.createEmptyBorder())
UIManager.put("Table.focusCellHighlightBorder", FlatTableCellBorder.Default())
UIManager.put("Table.focusSelectedCellHighlightBorder", FlatTableCellBorder.Default())
UIManager.put("Table.selectionArc", UIManager.getInt("Component.arc"))
UIManager.put("Tree.rowHeight", 24)
@@ -254,36 +214,6 @@ class ApplicationRunner {
}
/**
* Windows 情况覆盖
*/
private fun setupTinylog() {
if (SystemInfo.isWindows) {
val dir = File(Application.getBaseDataDir(), "logs")
FileUtils.forceMkdir(dir)
Configuration.set("writer_file.latest", "${dir.absolutePath}/${Application.getName().lowercase()}.log")
Configuration.set("writer_file.file", "${dir.absolutePath}/{date:yyyy}-{date:MM}-{date:dd}.log")
}
}
private fun checkSingleton() {
singletonChannel = FileChannel.open(
Paths.get(Application.getBaseDataDir().absolutePath, "lock"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
)
val lock = singletonChannel.tryLock()
if (lock == null) {
System.err.println("Program is already running")
exitProcess(1)
}
singletonLock = lock
}
private fun openDatabase() {
try {
Database.getDatabase()

View File

@@ -0,0 +1,201 @@
package app.termora
import com.formdev.flatlaf.util.SystemInfo
import com.sun.jna.platform.win32.Kernel32
import com.sun.jna.platform.win32.User32
import com.sun.jna.platform.win32.WinDef.*
import com.sun.jna.platform.win32.WinError
import com.sun.jna.platform.win32.WinUser.*
import com.sun.jna.platform.win32.Wtsapi32
import org.slf4j.LoggerFactory
import java.nio.channels.FileChannel
import java.nio.channels.FileLock
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.util.concurrent.atomic.AtomicBoolean
class ApplicationSingleton private constructor() : Disposable {
@Volatile
private var isSingleton = null as Boolean?
companion object {
fun getInstance(): ApplicationSingleton {
return ApplicationScope.forApplicationScope()
.getOrCreate(ApplicationSingleton::class) { ApplicationSingleton() }
}
}
fun isSingleton(): Boolean {
var singleton = this.isSingleton
if (singleton != null) return singleton
try {
synchronized(this) {
singleton = this.isSingleton
if (singleton != null) return singleton as Boolean
if (SystemInfo.isWindows) {
val handle = Kernel32.INSTANCE.CreateMutex(null, false, Application.getName())
singleton = handle != null && Kernel32.INSTANCE.GetLastError() != WinError.ERROR_ALREADY_EXISTS
if (singleton == true) {
// 启动监听器,方便激活窗口
Thread.ofVirtual().start(Win32HelperWindow.getInstance())
} else {
// 尝试激活窗口
Win32HelperWindow.tick()
}
} else {
singleton = FileLocker.getInstance().tryLock()
}
this.isSingleton = singleton == true
}
} catch (e: Exception) {
e.printStackTrace(System.err)
return false
}
return this.isSingleton == true
}
private class FileLocker private constructor() {
companion object {
fun getInstance(): FileLocker {
return ApplicationScope.forApplicationScope()
.getOrCreate(FileLocker::class) { FileLocker() }
}
}
private lateinit var singletonChannel: FileChannel
private lateinit var singletonLock: FileLock
fun tryLock(): Boolean {
singletonChannel = FileChannel.open(
Paths.get(Application.getBaseDataDir().absolutePath, "lock"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
)
val lock = singletonChannel.tryLock() ?: return false
this.singletonLock = lock
return true
}
}
private class Win32HelperWindow private constructor() : Runnable {
companion object {
private val log = LoggerFactory.getLogger(Win32HelperWindow::class.java)
private val WindowClass = "${Application.getName()}HelperWindowClass"
private val WindowName =
"${Application.getName()} hidden helper window, used only to catch the windows events"
private const val TICK: Int = WM_USER + 1
fun getInstance(): Win32HelperWindow {
return ApplicationScope.forApplicationScope()
.getOrCreate(Win32HelperWindow::class) { Win32HelperWindow() }
}
fun tick() {
val hWnd = User32.INSTANCE.FindWindow(WindowClass, WindowName) ?: return
User32.INSTANCE.SendMessage(hWnd, TICK, WPARAM(), LPARAM())
}
}
private val isRunning = AtomicBoolean(false)
override fun run() {
if (SystemInfo.isWindows) {
if (isRunning.compareAndSet(false, true)) {
Win32Window()
}
}
}
private class Win32Window : WindowProc {
/**
* Instantiates a new win32 window test.
*/
init {
// define new window class
val hInst = Kernel32.INSTANCE.GetModuleHandle(null)
val wClass = WNDCLASSEX()
wClass.hInstance = hInst
wClass.lpfnWndProc = this
wClass.lpszClassName = WindowClass
// register window class
User32.INSTANCE.RegisterClassEx(wClass)
// create new window
val hWnd = User32.INSTANCE.CreateWindowEx(
User32.WS_EX_TOPMOST,
WindowClass,
WindowName,
0, 0, 0, 0, 0,
null, // WM_DEVICECHANGE contradicts parent=WinUser.HWND_MESSAGE
null, hInst, null
)
val msg = MSG()
while (User32.INSTANCE.GetMessage(msg, hWnd, 0, 0) > 0) {
User32.INSTANCE.TranslateMessage(msg)
User32.INSTANCE.DispatchMessage(msg)
}
Wtsapi32.INSTANCE.WTSUnRegisterSessionNotification(hWnd)
User32.INSTANCE.UnregisterClass(WindowClass, hInst)
User32.INSTANCE.DestroyWindow(hWnd)
}
override fun callback(hwnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT {
when (uMsg) {
WM_CREATE -> {
if (log.isDebugEnabled) {
log.debug("win32 helper window created")
}
return LRESULT()
}
TICK -> {
if (log.isDebugEnabled) {
log.debug("win32 helper window tick")
}
onTick()
return LRESULT()
}
WM_DESTROY -> {
if (log.isDebugEnabled) {
log.debug("win32 helper window destroyed")
}
User32.INSTANCE.PostQuitMessage(0)
return LRESULT()
}
else -> return User32.INSTANCE.DefWindowProc(hwnd, uMsg, wParam, lParam)
}
}
private fun onTick() {
TermoraFrameManager.getInstance().tick()
}
}
}
}

View File

@@ -606,12 +606,22 @@ class Database private constructor(private val env: Environment) : Disposable {
*/
var sftpCommand by StringPropertyDelegate(StringUtils.EMPTY)
/**
* defaultDirectory
*/
var defaultDirectory by StringPropertyDelegate(StringUtils.EMPTY)
/**
* 是否固定在标签栏
*/
var pinTab by BooleanPropertyDelegate(false)
/**
* 是否保留原始文件时间
*/
var preserveModificationTime by BooleanPropertyDelegate(false)
}
/**

View File

@@ -2,8 +2,8 @@ package app.termora
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.native.osx.NativeMacLibrary
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import java.awt.*
@@ -12,29 +12,60 @@ import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.*
abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
private val rootPanel = JPanel(BorderLayout())
private val titleLabel = JLabel()
private val titleBar by lazy { LogicCustomTitleBar.createCustomTitleBar(this) }
val disposable = Disposer.newDisposable()
private val customTitleBar = if (SystemInfo.isMacOS && JBR.isWindowDecorationsSupported())
JBR.getWindowDecorations().createCustomTitleBar() else null
companion object {
const val DEFAULT_ACTION = "DEFAULT_ACTION"
private const val PROCESS_GLOBAL_KEYMAP = "PROCESS_GLOBAL_KEYMAP"
}
protected var controlsVisible = true
set(value) {
field = value
titleBar.putProperty("controls.visible", value)
if (SystemInfo.isMacOS) {
if (customTitleBar != null) {
customTitleBar.putProperty("controls.visible", value)
} else {
NativeMacLibrary.setControlsVisible(this, value)
}
} else {
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICONIFFY, value)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_MAXIMIZE, value)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_CLOSE, value)
}
}
protected var titleBarHeight = UIManager.getInt("TabbedPane.tabHeight").toFloat()
protected var fullWindowContent = false
set(value) {
titleBar.height = value
field = value
rootPane.putClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT, value)
}
protected var titleVisible = true
set(value) {
field = value
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE, value)
}
protected var titleIconVisible = false
set(value) {
field = value
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICON, value)
}
protected var titleBarHeight = UIManager.getInt("TabbedPane.tabHeight")
set(value) {
field = value
if (SystemInfo.isMacOS) {
customTitleBar?.height = height.toFloat()
} else {
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_HEIGHT, value)
}
}
protected var lostFocusDispose = false
@@ -51,25 +82,43 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
super.rootPane.putClientProperty(PROCESS_GLOBAL_KEYMAP, value)
}
init {
super.setDefaultCloseOperation(DISPOSE_ON_CLOSE)
// 使用 FlatLaf 的 TitlePane
if (SystemInfo.isWindows || SystemInfo.isLinux) {
rootPane.windowDecorationStyle = JRootPane.PLAIN_DIALOG
}
}
protected fun init() {
defaultCloseOperation = DISPOSE_ON_CLOSE
initTitleBar()
initEvents()
if (JBR.isWindowDecorationsSupported()) {
if (rootPane.getClientProperty(FlatClientProperties.TITLE_BAR_SHOW_TITLE) != false) {
val titlePanel = createTitlePanel()
if (titlePanel != null) {
rootPanel.add(titlePanel, BorderLayout.NORTH)
}
val rootPanel = JPanel(BorderLayout())
rootPanel.add(createCenterPanel(), BorderLayout.CENTER)
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
)
val titlePanel = createTitlePanel()
if (titlePanel != null) {
rootPanel.add(titlePanel, BorderLayout.NORTH)
}
val customTitleBar = this.customTitleBar
if (customTitleBar != null) {
customTitleBar.putProperty("controls.visible", controlsVisible)
customTitleBar.height = titleBarHeight.toFloat()
JBR.getWindowDecorations().setCustomTitleBar(this, customTitleBar)
}
}
rootPanel.add(createCenterPanel(), BorderLayout.CENTER)
val southPanel = createSouthPanel()
if (southPanel != null) {
rootPanel.add(southPanel, BorderLayout.SOUTH)
@@ -122,7 +171,7 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
val panel = JPanel(BorderLayout())
panel.add(titleLabel, BorderLayout.CENTER)
panel.preferredSize = Dimension(-1, titleBar.height.toInt())
panel.preferredSize = Dimension(-1, titleBarHeight)
return panel
@@ -191,30 +240,20 @@ abstract class DialogWrapper(owner: Window?) : JDialog(owner) {
}
})
if (SystemInfo.isWindows) {
addWindowListener(object : WindowAdapter(), ThemeChangeListener {
override fun windowClosed(e: WindowEvent) {
ThemeManager.getInstance().removeThemeChangeListener(this)
}
override fun windowOpened(e: WindowEvent) {
onChanged()
ThemeManager.getInstance().addThemeChangeListener(this)
}
override fun onChanged() {
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
}
})
}
}
private fun initTitleBar() {
titleBar.height = titleBarHeight
titleBar.putProperty("controls.visible", controlsVisible)
if (JBR.isWindowDecorationsSupported()) {
JBR.getWindowDecorations().setCustomTitleBar(this, titleBar)
override fun addNotify() {
super.addNotify()
// 显示后触发一次重绘制
if (SystemInfo.isWindows || SystemInfo.isLinux) {
this.controlsVisible = controlsVisible
this.titleBarHeight = titleBarHeight
this.titleIconVisible = titleIconVisible
this.titleVisible = titleVisible
this.fullWindowContent = fullWindowContent
}
}
protected open fun doOKAction() {

View File

@@ -47,6 +47,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
serialCommOption.parityComboBox.selectedItem = serialComm.parity
serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits
serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
}
override fun getHost(): Host {

View File

@@ -132,6 +132,11 @@ data class Options(
* 串口配置
*/
val serialComm: SerialComm = SerialComm(),
/**
* SFTP 默认目录
*/
val sftpDefaultDirectory: String = StringUtils.EMPTY,
) {
companion object {
val Default = Options()

View File

@@ -29,6 +29,7 @@ open class HostOptionsPane : OptionsPane() {
protected val terminalOption = TerminalOption()
protected val jumpHostsOption = JumpHostsOption()
protected val serialCommOption = SerialCommOption()
protected val sftpOption = SFTPOption()
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init {
@@ -38,6 +39,7 @@ open class HostOptionsPane : OptionsPane() {
addOption(jumpHostsOption)
addOption(terminalOption)
addOption(serialCommOption)
addOption(sftpOption)
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
}
@@ -91,7 +93,8 @@ open class HostOptionsPane : OptionsPane() {
startupCommand = terminalOption.startupCommandTextField.text,
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
serialComm = serialComm
serialComm = serialComm,
sftpDefaultDirectory = sftpOption.defaultDirectoryField.text
)
return Host(
@@ -669,6 +672,54 @@ open class HostOptionsPane : OptionsPane() {
}
}
protected inner class SFTPOption : JPanel(BorderLayout()), Option {
val defaultDirectoryField = OutlineTextField(255)
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.folder
}
override fun getTitle(): String {
return "SFTP"
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
.add(defaultDirectoryField).xy(3, rows).apply { rows += step }
.build()
return panel
}
}
protected inner class TunnelingOption : JPanel(BorderLayout()), Option {
val tunnelings = mutableListOf<Tunneling>()

View File

@@ -13,7 +13,7 @@ import javax.swing.Icon
abstract class HostTerminalTab(
val windowScope: WindowScope,
val host: Host,
protected val terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal()
protected val terminal: Terminal = TerminalFactory.getInstance().createTerminal()
) : PropertyTerminalTab(), DataProvider {
companion object {
val Host = DataKey(app.termora.Host::class)

View File

@@ -13,6 +13,7 @@ object Icons {
val openInNewWindow by lazy { DynamicIcon("icons/openInNewWindow.svg", "icons/openInNewWindow_dark.svg") }
val openInToolWindow by lazy { DynamicIcon("icons/openInToolWindow.svg", "icons/openInToolWindow_dark.svg") }
val searchHistory by lazy { DynamicIcon("icons/searchHistory.svg", "icons/searchHistory_dark.svg") }
val inspectionsEye by lazy { DynamicIcon("icons/inspectionsEye.svg", "icons/inspectionsEye_dark.svg") }
val eye by lazy { DynamicIcon("icons/eye.svg", "icons/eye_dark.svg") }
val eyeClose by lazy { DynamicIcon("icons/eyeClose.svg", "icons/eyeClose_dark.svg") }
val matchCase by lazy { DynamicIcon("icons/matchCase.svg", "icons/matchCase_dark.svg") }
@@ -32,6 +33,7 @@ object Icons {
val percentage by lazy { DynamicIcon("icons/percentage.svg", "icons/percentage_dark.svg") }
val text by lazy { DynamicIcon("icons/text.svg", "icons/text_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
val warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_dark.svg") }
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") }
@@ -50,6 +52,7 @@ object Icons {
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") }
val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") }
val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_dark.svg") }
val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") }
val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") }
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }

View File

@@ -1,74 +0,0 @@
package app.termora
import com.formdev.flatlaf.extras.components.FlatTextField
import org.apache.commons.lang3.StringUtils
import java.awt.Window
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.UIManager
class InputDialog(
owner: Window,
title: String,
text: String = StringUtils.EMPTY,
placeholderText: String = StringUtils.EMPTY
) : DialogWrapper(owner) {
private val textField = FlatTextField()
private var text: String? = null
init {
setSize(340, 60)
setLocationRelativeTo(owner)
super.setTitle(title)
isResizable = false
isModal = true
controlsVisible = false
titleBarHeight = UIManager.getInt("TabbedPane.tabHeight") * 0.8f
textField.placeholderText = placeholderText
textField.text = text
textField.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if (e.keyCode == KeyEvent.VK_ENTER) {
if (textField.text.isBlank()) {
return
}
doOKAction()
}
}
})
init()
}
override fun createCenterPanel(): JComponent {
textField.background = UIManager.getColor("window")
textField.border = BorderFactory.createEmptyBorder(0, 13, 0, 13)
return textField
}
fun getText(): String? {
isVisible = true
return text
}
override fun doCancelAction() {
text = null
super.doCancelAction()
}
override fun doOKAction() {
text = textField.text
super.doOKAction()
}
override fun createSouthPanel(): JComponent? {
return null
}
}

View File

@@ -9,7 +9,7 @@ class LocalTerminalTab(windowScope: WindowScope, host: Host) :
override suspend fun openPtyConnector(): PtyConnector {
val winSize = terminalPanel.winSize()
val ptyConnector = PtyConnectorFactory.getInstance(windowScope).createPtyConnector(
val ptyConnector = PtyConnectorFactory.getInstance().createPtyConnector(
winSize.rows, winSize.cols,
host.options.envs(),
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),

View File

@@ -1,109 +0,0 @@
package app.termora
import com.formdev.flatlaf.FlatClientProperties
import com.jetbrains.JBR
import com.jetbrains.WindowDecorations.CustomTitleBar
import java.awt.Rectangle
import java.awt.Window
import javax.swing.RootPaneContainer
class LogicCustomTitleBar(private val titleBar: CustomTitleBar) : CustomTitleBar {
companion object {
fun createCustomTitleBar(rootPaneContainer: RootPaneContainer): CustomTitleBar {
if (!JBR.isWindowDecorationsSupported()) {
return LogicCustomTitleBar(object : CustomTitleBar {
override fun getHeight(): Float {
val bounds = rootPaneContainer.rootPane
.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
if (bounds is Rectangle) {
return bounds.height.toFloat()
}
return 0f
}
override fun setHeight(height: Float) {
rootPaneContainer.rootPane.putClientProperty(
FlatClientProperties.TITLE_BAR_HEIGHT,
height.toInt()
)
}
override fun getProperties(): MutableMap<String, Any> {
return mutableMapOf()
}
override fun putProperties(m: MutableMap<String, *>?) {
}
override fun putProperty(key: String?, value: Any?) {
if (key == "controls.visible" && value is Boolean) {
rootPaneContainer.rootPane.putClientProperty(
FlatClientProperties.TITLE_BAR_SHOW_CLOSE,
value
)
}
}
override fun getLeftInset(): Float {
return 0f
}
override fun getRightInset(): Float {
val bounds = rootPaneContainer.rootPane
.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
if (bounds is Rectangle) {
return bounds.width.toFloat()
}
return 0f
}
override fun forceHitTest(client: Boolean) {
}
override fun getContainingWindow(): Window {
return rootPaneContainer as Window
}
})
}
return JBR.getWindowDecorations().createCustomTitleBar()
}
}
override fun getHeight(): Float {
return titleBar.height
}
override fun setHeight(height: Float) {
titleBar.height = height
}
override fun getProperties(): MutableMap<String, Any> {
return titleBar.properties
}
override fun putProperties(m: MutableMap<String, *>?) {
titleBar.putProperties(m)
}
override fun putProperty(key: String?, value: Any?) {
titleBar.putProperty(key, value)
}
override fun getLeftInset(): Float {
return titleBar.leftInset
}
override fun getRightInset(): Float {
return titleBar.rightInset
}
override fun forceHitTest(client: Boolean) {
titleBar.forceHitTest(client)
}
override fun getContainingWindow(): Window {
return titleBar.containingWindow
}
}

View File

@@ -1,58 +1,6 @@
package app.termora
import com.pty4j.util.PtyUtil
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import java.io.File
fun main() {
// 由于 macOS 签名和公证问题,依赖二进制依赖会单独在一个文件夹
if (SystemUtils.IS_OS_MAC_OSX) {
setupNativeLibraries()
}
if (SystemUtils.IS_OS_MAC_OSX) {
System.setProperty("apple.awt.application.name", Application.getName())
}
ApplicationRunner().run()
ApplicationInitializr().run()
}
private fun setupNativeLibraries() {
if (!SystemUtils.IS_OS_MAC_OSX) {
return
}
val appPath = Application.getAppPath()
if (StringUtils.isBlank(appPath)) {
return
}
val contents = File(appPath).parentFile?.parentFile ?: return
val dylib = FileUtils.getFile(contents, "app", "dylib")
if (!dylib.exists()) {
return
}
val jna = FileUtils.getFile(dylib, "jna")
if (jna.exists()) {
System.setProperty("jna.boot.library.path", jna.absolutePath)
}
val pty4j = FileUtils.getFile(dylib, "pty4j")
if (pty4j.exists()) {
System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath)
}
val jSerialComm = FileUtils.getFile(dylib, "jSerialComm")
if (jSerialComm.exists()) {
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
}
val restart4j = FileUtils.getFile(dylib, "restart4j", "restarter")
if (restart4j.exists()) {
System.setProperty("restarter.path", restart4j.absolutePath)
}
}

View File

@@ -1,51 +0,0 @@
package app.termora
import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate
import org.jdesktop.swingx.action.ActionManager
/**
* 当开启转发时,会获取到所有的 [PtyConnector] 然后跳过中间层,直接找到最近的一个 [MultiplePtyConnector],如果找不到那就以最后一个匹配不到的为准 [getMultiplePtyConnector]。
*/
class MultiplePtyConnector(
private val myConnector: PtyConnector
) : PtyConnectorDelegate(myConnector) {
private val isMultiple get() = ActionManager.getInstance().isSelected(Actions.MULTIPLE)
private val ptyConnectors
get() = ApplicationScope.forApplicationScope()
.windowScopes().map { PtyConnectorFactory.getInstance(it).getPtyConnectors() }
.flatten()
override fun write(buffer: ByteArray, offset: Int, len: Int) {
if (isMultiple) {
for (connector in ptyConnectors) {
getMultiplePtyConnector(connector).write(buffer, offset, len)
}
} else {
myConnector.write(buffer, offset, len)
}
}
private fun getMultiplePtyConnector(connector: PtyConnector): PtyConnector {
if (connector is MultiplePtyConnector) {
val c = connector.myConnector
if (c is MultiplePtyConnector) {
return getMultiplePtyConnector(c)
}
return c
}
if (connector is PtyConnectorDelegate) {
val c = connector.ptyConnector
if (c != null) {
return getMultiplePtyConnector(c)
}
}
return connector
}
}

View File

@@ -1,7 +1,9 @@
package app.termora
import app.termora.actions.ActionManager
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.actions.MultipleAction
import app.termora.terminal.Terminal
import app.termora.terminal.TerminalColor
import app.termora.terminal.TextStyle
@@ -9,8 +11,10 @@ import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalDisplay
import app.termora.terminal.panel.TerminalPaintListener
import app.termora.terminal.panel.TerminalPanel
import org.apache.commons.lang3.StringUtils
import java.awt.Color
import java.awt.Graphics
import java.util.*
class MultipleTerminalListener : TerminalPaintListener {
override fun after(
@@ -21,9 +25,9 @@ class MultipleTerminalListener : TerminalPaintListener {
terminalDisplay: TerminalDisplay,
terminal: Terminal
) {
if (!ActionManager.getInstance().isSelected(Actions.MULTIPLE)) {
return
}
val windowScope = AnActionEvent(terminalPanel, StringUtils.EMPTY, EventObject(terminalPanel))
.getData(DataProviders.WindowScope) ?: return
if (!MultipleAction.getInstance(windowScope).isSelected) return
val oldFont = g.font
val colorPalette = terminal.getTerminalModel().getColorPalette()

View File

@@ -0,0 +1,11 @@
package app.termora
import com.formdev.flatlaf.ui.FlatRootPaneUI
import com.formdev.flatlaf.ui.FlatTitlePane
class MyFlatRootPaneUI : FlatRootPaneUI() {
fun getTitlePane(): FlatTitlePane? {
return super.titlePane
}
}

View File

@@ -1,7 +1,6 @@
package app.termora
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProvider
import app.termora.actions.DataProviders
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import org.apache.commons.lang3.StringUtils
@@ -237,11 +236,8 @@ class MyTabbedPane : FlatTabbedPane() {
private fun dragToAnotherWindow(oldFrame: TermoraFrame, frame: TermoraFrame) {
val tab = this.terminalTab ?: return
val terminalPanel = (tab as DataProvider?)?.getData(DataProviders.TerminalPanel) ?: return
val tabbedManager = frame.getData(DataProviders.TerminalTabbed) ?: return
val tabbedPane = frame.getData(DataProviders.TabbedPane) ?: return
val windowScope = frame.getData(DataProviders.WindowScope) ?: return
val oldWindowScope = oldFrame.getData(DataProviders.WindowScope) ?: return
val location = Point(MouseInfo.getPointerInfo().location)
SwingUtilities.convertPointFromScreen(location, tabbedPane)
val index = tabbedPane.indexAtLocation(location.x, location.y)
@@ -253,11 +249,6 @@ class MyTabbedPane : FlatTabbedPane() {
index
)
TerminalPanelFactory.getInstance(oldWindowScope).removeTerminalPanel(terminalPanel)
TerminalPanelFactory.getInstance(windowScope).addTerminalPanel(terminalPanel)
if (frame.hasFocus()) {
return
}

View File

@@ -0,0 +1,41 @@
package app.termora
import com.formdev.flatlaf.util.SystemInfo
import de.jangassen.jfa.foundation.Foundation
import de.jangassen.jfa.foundation.Foundation.NSAutoreleasePool
import java.text.Collator
import java.util.*
class NativeStringComparator private constructor() : Comparator<String> {
private val collator by lazy { Collator.getInstance(Locale.getDefault()).apply { strength = Collator.PRIMARY } }
companion object {
fun getInstance(): NativeStringComparator {
return ApplicationScope.forApplicationScope()
.getOrCreate(NativeStringComparator::class) { NativeStringComparator() }
}
private const val SORT_DIGITSASNUMBERS: Int = 0x00000008
}
override fun compare(o1: String, o2: String): Int {
if (SystemInfo.isWindows) {
// CompareStringEx returns 1, 2, 3 respectively instead of -1, 0, 1
return MyKernel32.INSTANCE.CompareStringEx(SORT_DIGITSASNUMBERS, o1, o2) - 2
} else if (SystemInfo.isMacOS) {
val pool = NSAutoreleasePool()
try {
val a = Foundation.nsString(o1)
val b = Foundation.nsString(o2)
return Foundation.invoke(a, "localizedStandardCompare:", b).toInt()
} finally {
pool.drain()
}
}
return collator.compare(o1, o2)
}
}

View File

@@ -1,9 +1,8 @@
package app.termora
import app.termora.Application.ohMyJson
import app.termora.actions.AnActionEvent
import app.termora.actions.OpenHostAction
import app.termora.transport.SFTPAction
import app.termora.sftp.SFTPActionEvent
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
@@ -15,6 +14,7 @@ import org.apache.commons.io.FilenameUtils
import org.apache.commons.io.filefilter.FileFilterUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.client.config.hosts.HostConfigEntry
import org.ini4j.Ini
import org.ini4j.Reg
import org.jdesktop.swingx.action.ActionManager
@@ -46,6 +46,7 @@ class NewHostTree : SimpleTree() {
private val properties get() = Database.getDatabase().properties
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)
private val sftpAction get() = ActionManager.getInstance().getAction(app.termora.Actions.SFTP)
private var isShowMoreInfo
get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean()
set(value) = properties.putString("HostTree.showMoreInfo", value.toString())
@@ -185,8 +186,10 @@ class NewHostTree : SimpleTree() {
val finalShellMenu = importMenu.add("FinalShell")
val windTermMenu = importMenu.add("WindTerm")
val secureCRTMenu = importMenu.add("SecureCRT")
val sshMenu = importMenu.add(".ssh/config")
val mobaXtermMenu = importMenu.add("MobaXterm")
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect"))
val openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu
val openWithSFTP = openWith.add("SFTP")
@@ -218,6 +221,7 @@ class NewHostTree : SimpleTree() {
secureCRTMenu.addActionListener { importHosts(lastNode, ImportType.SecureCRT) }
electermMenu.addActionListener { importHosts(lastNode, ImportType.electerm) }
mobaXtermMenu.addActionListener { importHosts(lastNode, ImportType.MobaXterm) }
sshMenu.addActionListener { importHosts(lastNode, ImportType.SSH) }
finalShellMenu.addActionListener { importHosts(lastNode, ImportType.FinalShell) }
csvMenu.addActionListener { importHosts(lastNode, ImportType.CSV) }
windTermMenu.addActionListener { importHosts(lastNode, ImportType.WindTerm) }
@@ -396,10 +400,8 @@ class NewHostTree : SimpleTree() {
val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return
val sftpAction = ActionManager.getInstance().getAction(app.termora.Actions.SFTP) as SFTPAction? ?: return
val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return
for (node in nodes) {
sftpAction.connectHost(node, tab)
sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt))
}
}
@@ -430,6 +432,7 @@ class NewHostTree : SimpleTree() {
when (type) {
ImportType.WindTerm -> chooser.fileFilter = FileNameExtensionFilter("WindTerm (*.sessions)", "sessions")
ImportType.SSH -> chooser.fileFilter = FileNameExtensionFilter("SSH (config)", "config")
ImportType.CSV -> chooser.fileFilter = FileNameExtensionFilter("CSV (*.csv)", "csv")
ImportType.SecureCRT -> chooser.fileFilter = FileNameExtensionFilter("SecureCRT (*.xml)", "xml")
ImportType.electerm -> chooser.fileFilter = FileNameExtensionFilter("electerm (*.json)", "json")
@@ -495,19 +498,23 @@ class NewHostTree : SimpleTree() {
}
// 选择文件
val code = chooser.showOpenDialog(owner)
if (code != JFileChooser.APPROVE_OPTION) {
return
if (type != ImportType.SSH) {
val code = chooser.showOpenDialog(owner)
if (code != JFileChooser.APPROVE_OPTION) {
return
}
}
val file = chooser.selectedFile
properties.putString(
"NewHostTree.ImportHosts.defaultDir",
(if (FileUtils.isDirectory(file)) file else file.parentFile).absolutePath
)
if (file != null && file.parentFile != null) {
properties.putString(
"NewHostTree.ImportHosts.defaultDir",
(if (FileUtils.isDirectory(file)) file else file.parentFile).absolutePath
)
}
val nodes = when (type) {
ImportType.SSH -> parseFromSSH(folder)
ImportType.WindTerm -> parseFromWindTerm(folder, file)
ImportType.SecureCRT -> parseFromSecureCRT(folder, file)
ImportType.MobaXterm -> parseFromMobaXterm(folder, file)
@@ -539,6 +546,9 @@ class NewHostTree : SimpleTree() {
// 重新加载
model.reload(folder)
// expand root
expandPath(TreePath(model.getPathToRoot(folder)))
}
private fun parseFromWindTerm(folder: HostTreeNode, file: File): List<HostTreeNode> {
@@ -564,6 +574,26 @@ class NewHostTree : SimpleTree() {
return parseFromCSV(folder, StringReader(sw.toString()))
}
private fun parseFromSSH(folder: HostTreeNode): List<HostTreeNode> {
val entries = HostConfigEntry.readHostConfigEntries(HostConfigEntry.getDefaultHostConfigFile())
val sw = StringWriter()
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
for (entry in entries) {
printer.printRecord(
StringUtils.EMPTY,
StringUtils.defaultString(entry.host),
StringUtils.defaultString(entry.hostName),
if (entry.port == 0) 22 else entry.port,
StringUtils.defaultString(entry.username),
"SSH"
)
}
}
return parseFromCSV(folder, StringReader(sw.toString()))
}
private fun parseFromSecureCRT(folder: HostTreeNode, file: File): List<HostTreeNode> {
val xPath = XPathFactory.newInstance().newXPath()
val db = DocumentBuilderFactory.newInstance().newDocumentBuilder()
@@ -863,6 +893,7 @@ class NewHostTree : SimpleTree() {
PuTTY,
SecureCRT,
MobaXterm,
SSH,
FinalShell,
electerm,
}

View File

@@ -3,11 +3,10 @@ package app.termora
import org.apache.commons.lang3.StringUtils
import java.awt.Dimension
import java.awt.Window
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.function.Function
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.JScrollPane
import javax.swing.UIManager
import javax.swing.*
class NewHostTreeDialog(
owner: Window,
@@ -19,7 +18,7 @@ class NewHostTreeDialog(
private val tree = NewHostTree()
init {
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
size = Dimension(UIManager.getInt("Dialog.width") - 250, UIManager.getInt("Dialog.height") - 150)
isModal = true
isResizable = false
controlsVisible = false
@@ -29,6 +28,15 @@ class NewHostTreeDialog(
tree.doubleClickConnection = false
tree.dragEnabled = false
tree.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val node = tree.getLastSelectedPathNode() ?: return
if (node.isFolder) return
doOKAction()
}
}
})
init()

View File

@@ -1,11 +1,13 @@
package app.termora
import app.termora.native.osx.NativeMacLibrary
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextPane
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Desktop
@@ -113,6 +115,36 @@ object OptionPane {
dialog.dispose()
}
fun showInputDialog(
parentComponent: Component?,
title: String = UIManager.getString("OptionPane.messageDialogTitle"),
value: String = StringUtils.EMPTY,
placeholder: String = StringUtils.EMPTY,
): String? {
val pane = JOptionPane(StringUtils.EMPTY, JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION)
val dialog = initDialog(pane.createDialog(parentComponent, title))
pane.wantsInput = true
pane.initialSelectionValue = value
val textField = SwingUtils.getDescendantsOfType(JTextField::class.java, pane, true).firstOrNull()
if (textField?.name == "OptionPane.textField") {
textField.border = BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor),
BorderFactory.createEmptyBorder(0, 0, 2, 0)
)
textField.background = UIManager.getColor("window")
textField.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, placeholder)
}
dialog.isVisible = true
dialog.dispose()
val inputValue = pane.inputValue
if (inputValue == JOptionPane.UNINITIALIZED_VALUE) return null
return inputValue as? String
}
fun openFileInFolder(
parentComponent: Component,
file: File,
@@ -140,14 +172,31 @@ object OptionPane {
}
private fun initDialog(dialog: JDialog): JDialog {
if (SystemInfo.isWindows || SystemInfo.isLinux) {
dialog.rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_CLOSE, false)
dialog.rootPane.putClientProperty(
FlatClientProperties.TITLE_BAR_HEIGHT,
UIManager.getInt("TabbedPane.tabHeight")
)
} else if (SystemInfo.isMacOS) {
dialog.rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
dialog.rootPane.putClientProperty("apple.awt.fullWindowContent", true)
dialog.rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
dialog.rootPane.putClientProperty(
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING,
FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING_MEDIUM
)
if (JBR.isWindowDecorationsSupported()) {
val windowDecorations = JBR.getWindowDecorations()
val titleBar = windowDecorations.createCustomTitleBar()
titleBar.putProperty("controls.visible", false)
titleBar.height = UIManager.getInt("TabbedPane.tabHeight") - if (SystemInfo.isMacOS) 10f else 6f
windowDecorations.setCustomTitleBar(dialog, titleBar)
val height = UIManager.getInt("TabbedPane.tabHeight") - 10
if (JBR.isWindowDecorationsSupported()) {
val customTitleBar = JBR.getWindowDecorations().createCustomTitleBar()
customTitleBar.putProperty("controls.visible", false)
customTitleBar.height = height.toFloat()
JBR.getWindowDecorations().setCustomTitleBar(dialog, customTitleBar)
} else {
NativeMacLibrary.setControlsVisible(dialog, false)
}
val label = JLabel(dialog.title)
label.putClientProperty(FlatClientProperties.STYLE, "font: bold")
@@ -155,11 +204,9 @@ object OptionPane {
box.add(Box.createHorizontalGlue())
box.add(label)
box.add(Box.createHorizontalGlue())
box.preferredSize = Dimension(-1, titleBar.height.toInt())
box.preferredSize = Dimension(-1, height)
dialog.contentPane.add(box, BorderLayout.NORTH)
}
return dialog
}
}

View File

@@ -18,8 +18,9 @@ class PtyConnectorFactory : Disposable {
companion object {
private val log = LoggerFactory.getLogger(PtyConnectorFactory::class.java)
fun getInstance(scope: Scope): PtyConnectorFactory {
return scope.getOrCreate(PtyConnectorFactory::class) { PtyConnectorFactory() }
fun getInstance(): PtyConnectorFactory {
return ApplicationScope.forApplicationScope()
.getOrCreate(PtyConnectorFactory::class) { PtyConnectorFactory() }
}
}
@@ -33,14 +34,21 @@ class PtyConnectorFactory : Disposable {
if (SystemUtils.IS_OS_UNIX) {
commands.add("-l")
}
return createPtyConnector(commands.toTypedArray(), rows, cols, env, charset)
return createPtyConnector(
commands = commands.toTypedArray(),
rows = rows,
cols = cols,
env = env,
charset = charset
)
}
fun createPtyConnector(
commands: Array<String>,
rows: Int = 24, cols: Int = 80,
env: Map<String, String> = emptyMap(),
charset: Charset = StandardCharsets.UTF_8
directory: String = SystemUtils.USER_HOME,
charset: Charset = StandardCharsets.UTF_8,
): PtyConnector {
val envs = mutableMapOf<String, String>()
envs.putAll(System.getenv())
@@ -67,7 +75,7 @@ class PtyConnectorFactory : Disposable {
.setInitialRows(rows)
.setInitialColumns(cols)
.setConsole(false)
.setDirectory(SystemUtils.USER_HOME)
.setDirectory(StringUtils.defaultIfBlank(directory, SystemUtils.USER_HOME))
.setCygwin(false)
.setUseWinConPty(SystemUtils.IS_OS_WINDOWS)
.setRedirectErrorStream(false)
@@ -79,20 +87,14 @@ class PtyConnectorFactory : Disposable {
}
fun decorate(ptyConnector: PtyConnector): PtyConnector {
// 集成转发如果PtyConnector支持转发那么应该在当前注释行前面代理
val multiplePtyConnector = MultiplePtyConnector(ptyConnector)
// 宏应该在转发前面执行,不然会导致重复录制
val macroPtyConnector = MacroPtyConnector(multiplePtyConnector)
//
val macroPtyConnector = MacroPtyConnector(ptyConnector)
// 集成自动删除
val autoRemovePtyConnector = AutoRemovePtyConnector(macroPtyConnector)
ptyConnectors.add(autoRemovePtyConnector)
return autoRemovePtyConnector
}
fun getPtyConnectors(): List<PtyConnector> {
return ptyConnectors
}
private inner class AutoRemovePtyConnector(connector: PtyConnector) : PtyConnectorDelegate(connector) {
override fun close() {
ptyConnectors.remove(this)

View File

@@ -13,7 +13,7 @@ import kotlin.time.Duration.Companion.milliseconds
abstract class PtyHostTerminalTab(
windowScope: WindowScope,
host: Host,
terminal: Terminal = TerminalFactory.getInstance(windowScope).createTerminal()
terminal: Terminal = TerminalFactory.getInstance().createTerminal()
) : HostTerminalTab(windowScope, host, terminal) {
companion object {
@@ -23,15 +23,8 @@ abstract class PtyHostTerminalTab(
private var readerJob: Job? = null
private val ptyConnectorDelegate = PtyConnectorDelegate()
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
protected val terminalPanel = terminalPanelFactory.createTerminalPanel(terminal, ptyConnectorDelegate)
.apply { Disposer.register(this@PtyHostTerminalTab, this) }
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance(windowScope)
init {
terminal.getTerminalModel().setData(DataKey.PtyConnector, ptyConnectorDelegate)
}
protected val terminalPanel = TerminalPanelFactory.getInstance().createTerminalPanel(terminal, ptyConnectorDelegate)
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance()
override fun start() {
coroutineScope.launch(Dispatchers.IO) {
@@ -122,10 +115,9 @@ abstract class PtyHostTerminalTab(
override fun dispose() {
stop()
terminalPanel
Disposer.dispose(terminalPanel)
super.dispose()
if (log.isInfoEnabled) {
log.info("Host: {} disposed", host.name)
}
@@ -141,6 +133,8 @@ abstract class PtyHostTerminalTab(
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == DataProviders.TerminalPanel) {
return terminalPanel as T?
} else if (dataKey == DataProviders.TerminalWriter) {
return terminalPanel.getData(DataKey.TerminalWriter) as T?
}
return super.getData(dataKey)
}

View File

@@ -0,0 +1,16 @@
package app.termora
import java.awt.Component
import java.awt.KeyboardFocusManager
abstract class RememberFocusTerminalTab : TerminalTab {
private var lastFocusedComponent: Component? = null
override fun onLostFocus() {
lastFocusedComponent = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner
}
override fun onGrabFocus() {
lastFocusedComponent?.requestFocusInWindow()
}
}

View File

@@ -8,6 +8,7 @@ import org.apache.commons.io.Charsets
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.session.ClientSession
@@ -28,6 +29,11 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
private var sshSession: ClientSession? = null
private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null
private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand
private val defaultDirectory get() = Database.getDatabase().sftp.defaultDirectory
init {
terminalPanel.dropFiles = true
}
companion object {
val canSupports by lazy {
@@ -115,16 +121,21 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
if (envs.containsKey("CurrentDir")) {
val currentDir = envs.getValue("CurrentDir")
commands.add("${host.username}@${host.host}:${currentDir}")
} else if (host.options.sftpDefaultDirectory.isNotBlank()) {
commands.add("${host.username}@${host.host}:${host.options.sftpDefaultDirectory.trim()}")
} else {
commands.add("${host.username}@${host.host}")
}
val directory = FileUtils.getFile(StringUtils.defaultIfBlank(defaultDirectory, SystemUtils.USER_HOME))
val winSize = terminalPanel.winSize()
val ptyConnector = ptyConnectorFactory.createPtyConnector(
commands.toTypedArray(),
winSize.rows, winSize.cols,
host.options.envs(),
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
commands = commands.toTypedArray(),
rows = winSize.rows, cols = winSize.cols,
env = host.options.envs(),
charset = Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8),
directory = if (directory.exists()) directory.absolutePath else SystemUtils.USER_HOME
)
return ptyConnector

View File

@@ -1,73 +0,0 @@
package app.termora
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import app.termora.transport.TransportDataProviders
import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
class SFTPTerminalTab : Disposable, TerminalTab, DataProvider {
private val sftp get() = Database.getDatabase().sftp
private val transportPanel = TransportPanel()
init {
Disposer.register(this, transportPanel)
}
override fun getTitle(): String {
return "SFTP"
}
override fun getIcon(): Icon {
return Icons.folder
}
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
}
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
}
override fun getJComponent(): JComponent {
return transportPanel
}
override fun canClone(): Boolean {
return false
}
override fun canClose(): Boolean {
assertEventDispatchThread()
if (sftp.pinTab) {
return false
}
val transportManager = transportPanel.getData(TransportDataProviders.TransportManager) ?: return true
if (transportManager.getTransports().isEmpty()) {
return true
}
return OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(getJComponent()),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == TransportDataProviders.TransportPanel) {
return transportPanel as T
}
return null
}
}

View File

@@ -15,6 +15,7 @@ import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager
import app.termora.native.FileChooser
import app.termora.sftp.SFTPTab
import app.termora.snippet.Snippet
import app.termora.snippet.SnippetManager
import app.termora.sync.SyncConfig
@@ -25,7 +26,6 @@ import app.termora.terminal.CursorStyle
import app.termora.terminal.DataKey
import app.termora.terminal.panel.FloatingToolbarPanel
import app.termora.terminal.panel.TerminalPanel
import app.termora.transport.SFTPAction
import cash.z.ecc.android.bip39.Mnemonics
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.FlatSVGIcon
@@ -46,14 +46,15 @@ import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.jdesktop.swingx.JXEditorPane
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.awt.event.ActionEvent
import java.awt.event.ItemEvent
import java.awt.event.ItemListener
import java.io.File
import java.net.URI
import java.nio.charset.StandardCharsets
@@ -72,7 +73,6 @@ class SettingsOptionsPane : OptionsPane() {
private val snippetManager get() = SnippetManager.getInstance()
private val keymapManager get() = KeymapManager.getInstance()
private val macroManager get() = MacroManager.getInstance()
private val actionManager get() = ActionManager.getInstance()
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
private val keyManager get() = KeyManager.getInstance()
@@ -340,7 +340,7 @@ class SettingsOptionsPane : OptionsPane() {
floatingToolbarComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.floatingToolbar = floatingToolbarComboBox.selectedItem as Boolean
TerminalPanelFactory.getAllTerminalPanel().forEach { tp ->
TerminalPanelFactory.getInstance().getTerminalPanels().forEach { tp ->
if (terminalSetting.floatingToolbar && FloatingToolbarPanel.isPined) {
tp.getData(FloatingToolbarPanel.FloatingToolbar)?.triggerShow()
} else {
@@ -369,7 +369,7 @@ class SettingsOptionsPane : OptionsPane() {
if (it.stateChange == ItemEvent.SELECTED) {
val style = cursorStyleComboBox.selectedItem as CursorStyle
terminalSetting.cursor = style
TerminalFactory.getInstance(ApplicationScope.forWindowScope(owner)).getTerminals().forEach { e ->
TerminalFactory.getInstance().getTerminals().forEach { e ->
e.getTerminalModel().setData(DataKey.CursorStyle, style)
}
}
@@ -379,7 +379,7 @@ class SettingsOptionsPane : OptionsPane() {
debugComboBox.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
terminalSetting.debug = debugComboBox.selectedItem as Boolean
TerminalFactory.getInstance(ApplicationScope.forWindowScope(owner)).getTerminals().forEach {
TerminalFactory.getInstance().getTerminals().forEach {
it.getTerminalModel().setData(TerminalPanel.Debug, terminalSetting.debug)
}
}
@@ -408,10 +408,8 @@ class SettingsOptionsPane : OptionsPane() {
}
private fun fireFontChanged() {
ApplicationScope.windowScopes().forEach {
TerminalPanelFactory.getInstance(it)
TerminalPanelFactory.getInstance()
.fireResize()
}
}
private fun initView() {
@@ -1334,9 +1332,11 @@ class SettingsOptionsPane : OptionsPane() {
private val editCommandField = OutlineTextField(255)
private val sftpCommandField = OutlineTextField(255)
private val defaultDirectoryField = OutlineTextField(255)
private val browseDirectoryBtn = JButton(Icons.folder)
private val pinTabComboBox = YesOrNoComboBox()
private val preserveModificationTimeComboBox = YesOrNoComboBox()
private val sftp get() = database.sftp
private val sftpAction get() = actionManager.getAction(Actions.SFTP) as SFTPAction
init {
initView()
@@ -1358,25 +1358,53 @@ class SettingsOptionsPane : OptionsPane() {
}
})
pinTabComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
defaultDirectoryField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
sftp.defaultDirectory = defaultDirectoryField.text
}
})
pinTabComboBox.addItemListener(object : ItemListener {
override fun itemStateChanged(e: ItemEvent) {
if (e.stateChange != ItemEvent.SELECTED) return
sftp.pinTab = pinTabComboBox.selectedItem as Boolean
for (window in TermoraFrameManager.getInstance().getWindows()) {
val evt = AnActionEvent(window, StringUtils.EMPTY, EventObject(window))
if (pinTabComboBox.selectedItem == true) {
sftpAction.openOrCreateSFTPTerminalTab(evt)
}
val tabbed = evt.getData(DataProviders.TabbedPane) ?: continue
val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue
for ((index, tab) in manager.getTerminalTabs().withIndex()) {
if (tab is SFTPTerminalTab) {
tabbed.setTabClosable(index, pinTabComboBox.selectedItem != true)
break
if (sftp.pinTab) {
if (manager.getTerminalTabs().none { it is SFTPTab }) {
manager.addTerminalTab(1, SFTPTab(), false)
}
}
// 刷新状态
manager.refreshTerminalTabs()
}
}
})
preserveModificationTimeComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
sftp.preserveModificationTime = preserveModificationTimeComboBox.selectedItem as Boolean
}
}
browseDirectoryBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val chooser = FileChooser()
chooser.allowsMultiSelection = false
chooser.defaultDirectory = StringUtils.defaultIfBlank(
defaultDirectoryField.text,
SystemUtils.USER_HOME
)
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.showOpenDialog(owner).thenAccept { files ->
if (files.isNotEmpty()) defaultDirectoryField.text = files.first().absolutePath
}
}
})
}
@@ -1393,9 +1421,14 @@ class SettingsOptionsPane : OptionsPane() {
sftpCommandField.placeholderText = "sftp"
}
defaultDirectoryField.placeholderText = SystemUtils.USER_HOME
defaultDirectoryField.trailingComponent = browseDirectoryBtn
defaultDirectoryField.text = sftp.defaultDirectory
editCommandField.text = sftp.editCommand
sftpCommandField.text = sftp.sftpCommand
pinTabComboBox.selectedItem = sftp.pinTab
preserveModificationTimeComboBox.selectedItem = sftp.preserveModificationTime
}
override fun getIcon(isSelected: Boolean): Icon {
@@ -1416,13 +1449,23 @@ class SettingsOptionsPane : OptionsPane() {
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val box = Box.createHorizontalBox()
box.add(JLabel("${I18n.getString("termora.settings.sftp.preserve-time")}:"))
box.add(Box.createHorizontalStrut(8))
box.add(preserveModificationTimeComboBox)
var rows = 1
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, 1)
builder.add(pinTabComboBox).xy(3, 1)
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, 3)
builder.add(editCommandField).xy(3, 3)
builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, 5)
builder.add(sftpCommandField).xy(3, 5)
builder.add("${I18n.getString("termora.settings.sftp.fixed-tab")}:").xy(1, rows)
builder.add(pinTabComboBox).xy(3, rows).apply { rows += 2 }
builder.add("${I18n.getString("termora.settings.sftp.edit-command")}:").xy(1, rows)
builder.add(editCommandField).xy(3, rows).apply { rows += 2 }
builder.add("${I18n.getString("termora.tabbed.contextmenu.sftp-command")}:").xy(1, rows)
builder.add(sftpCommandField).xy(3, rows).apply { rows += 2 }
builder.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
builder.add(defaultDirectoryField).xy(3, rows).apply { rows += 2 }
builder.add(box).xyw(1, rows, 3).apply { rows += 2 }
return builder.build()

View File

@@ -1,7 +1,11 @@
package app.termora
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.ClientBuilder
@@ -16,6 +20,7 @@ import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier
import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
import org.apache.sshd.client.keyverifier.ServerKeyVerifier
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.AttributeRepository
import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.PtyChannelConfiguration
import org.apache.sshd.common.config.keys.KeyUtils
@@ -48,6 +53,9 @@ import javax.swing.SwingUtilities
import kotlin.math.max
object SshClients {
val HOST_KEY = AttributeRepository.AttributeKey<Host>()
private val timeout = Duration.ofSeconds(30)
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
@@ -151,7 +159,8 @@ object SshClients {
log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
}
// 映射完毕之后修改Host和端口
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
jumpHosts[i + 1] =
nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
}
}
@@ -191,6 +200,8 @@ object SshClients {
throw SshException("Authentication failed")
}
session.setAttribute(HOST_KEY, host)
return session
}
@@ -230,6 +241,29 @@ object SshClients {
return sshdSocketAddress
}
suspend fun openClient(host: Host, owner: Window): Pair<SshClient, Host> {
val client = openClient(host)
var myHost = host
withContext(Dispatchers.Swing) {
client.userInteraction = TerminalUserInteraction(owner)
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// 弹出授权框
if (host.authentication.type == AuthenticationType.No) {
val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication()
myHost = myHost.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(myHost)
}
}
}
return client to myHost
}
/**
* 打开一个客户端
*/
@@ -302,30 +336,7 @@ private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVe
remoteAddress: SocketAddress,
serverKey: PublicKey
): Boolean {
if (SshClients.isMiddleware(clientSession)) {
return true
}
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait {
result.set(
OptionPane.showConfirmDialog(
parentComponent = owner,
message = I18n.getString(
"termora.host.verify-server-key",
remoteAddress.toString().replace("/", StringUtils.EMPTY),
KeyUtils.getKeyType(serverKey),
KeyUtils.getFingerPrint(serverKey)
),
optionType = JOptionPane.OK_CANCEL_OPTION,
messageType = JOptionPane.WARNING_MESSAGE,
) == JOptionPane.OK_OPTION
)
}
return result.get()
return true
}
override fun acceptModifiedServerKey(

View File

@@ -10,8 +10,8 @@ class TerminalFactory private constructor() : Disposable {
private val terminals = mutableListOf<Terminal>()
companion object {
fun getInstance(scope: WindowScope): TerminalFactory {
return scope.getOrCreate(TerminalFactory::class) { TerminalFactory() }
fun getInstance(): TerminalFactory {
return ApplicationScope.forApplicationScope().getOrCreate(TerminalFactory::class) { TerminalFactory() }
}
}

View File

@@ -1,14 +1,22 @@
package app.termora
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
import app.termora.terminal.panel.TerminalPanel
import app.termora.terminal.panel.TerminalWriter
import kotlinx.coroutines.*
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener
import java.nio.charset.Charset
import java.util.*
import javax.swing.JComponent
import javax.swing.SwingUtilities
import kotlin.time.Duration.Companion.milliseconds
@@ -17,16 +25,9 @@ class TerminalPanelFactory : Disposable {
companion object {
private val Factory = DataKey(TerminalPanelFactory::class)
fun getInstance(scope: Scope): TerminalPanelFactory {
return scope.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
}
fun getAllTerminalPanel(): Array<TerminalPanel> {
return ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }
.flatMap { it.terminalPanels }.toTypedArray()
fun getInstance(): TerminalPanelFactory {
return ApplicationScope.forApplicationScope()
.getOrCreate(TerminalPanelFactory::class) { TerminalPanelFactory() }
}
}
@@ -37,17 +38,15 @@ class TerminalPanelFactory : Disposable {
fun createTerminalPanel(terminal: Terminal, ptyConnector: PtyConnector): TerminalPanel {
val terminalPanel = TerminalPanel(terminal, ptyConnector)
val writer = MyTerminalWriter(ptyConnector)
val terminalPanel = TerminalPanel(terminal, writer)
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
terminal.getTerminalModel().setData(Factory, this)
Disposer.register(terminalPanel, object : Disposable {
override fun dispose() {
if (terminal.getTerminalModel().hasData(Factory)) {
terminal.getTerminalModel().getData(Factory).removeTerminalPanel(terminalPanel)
}
removeTerminalPanel(terminalPanel)
}
})
@@ -75,13 +74,12 @@ class TerminalPanelFactory : Disposable {
}
}
fun removeTerminalPanel(terminalPanel: TerminalPanel) {
private fun removeTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.remove(terminalPanel)
}
fun addTerminalPanel(terminalPanel: TerminalPanel) {
private fun addTerminalPanel(terminalPanel: TerminalPanel) {
terminalPanels.add(terminalPanel)
terminalPanel.terminal.getTerminalModel().setData(Factory, this)
}
private class Painter : Disposable {
@@ -97,10 +95,7 @@ class TerminalPanelFactory : Disposable {
coroutineScope.launch {
while (coroutineScope.isActive) {
delay(500.milliseconds)
SwingUtilities.invokeLater {
ApplicationScope.forApplicationScope().windowScopes()
.map { getInstance(it) }.forEach { it.repaintAll() }
}
SwingUtilities.invokeLater { TerminalPanelFactory.getInstance().repaintAll() }
}
}
}
@@ -110,4 +105,58 @@ class TerminalPanelFactory : Disposable {
}
}
private class MyTerminalWriter(private val ptyConnector: PtyConnector) : TerminalWriter {
companion object {
private val log = LoggerFactory.getLogger(MyTerminalWriter::class.java)
}
private lateinit var evt: AnActionEvent
override fun onMounted(c: JComponent) {
evt = AnActionEvent(c, StringUtils.EMPTY, EventObject(c))
}
override fun write(request: TerminalWriter.WriteRequest) {
if (log.isDebugEnabled) {
log.debug("write: ${String(request.buffer, getCharset())}")
}
val windowScope = evt.getData(DataProviders.WindowScope)
if (windowScope == null) {
ptyConnector.write(request.buffer)
return
}
val multipleAction = MultipleAction.getInstance(windowScope)
if (!multipleAction.isSelected) {
ptyConnector.write(request.buffer)
return
}
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager)
if (terminalTabbedManager == null) {
ptyConnector.write(request.buffer)
return
}
for (tab in terminalTabbedManager.getTerminalTabs()) {
val writer = tab.getData(DataProviders.TerminalWriter) ?: continue
if (writer is MyTerminalWriter) {
writer.ptyConnector.write(request.buffer)
}
}
}
override fun resize(rows: Int, cols: Int) {
ptyConnector.resize(rows, cols)
}
override fun getCharset(): Charset {
return ptyConnector.getCharset()
}
}
}

View File

@@ -43,6 +43,8 @@ interface TerminalTab : Disposable, DataProvider {
*/
fun canClose(): Boolean = true
fun willBeClose(): Boolean = true
/**
* 是否可以克隆
*/

View File

@@ -6,7 +6,6 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.terminal.DataKey
import app.termora.transport.TransportPanel
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.extras.components.FlatTabbedPane
@@ -121,7 +120,7 @@ class TerminalTabbed(
val results = mutableListOf<FindEverywhereResult>()
for (i in 0 until tabbedPane.tabCount) {
val c = tabbedPane.getComponentAt(i)
if (c is WelcomePanel || c is TransportPanel) {
if (c is JComponent && c.getClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE) != null) {
continue
}
results.add(
@@ -155,7 +154,7 @@ class TerminalTabbed(
val tab = tabs[index]
if (disposable) {
if (!tab.canClose()) {
if (!tab.willBeClose()) {
return
}
}
@@ -191,12 +190,11 @@ class TerminalTabbed(
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
rename.addActionListener {
if (tabIndex > 0) {
val dialog = InputDialog(
val text = OptionPane.showInputDialog(
SwingUtilities.getWindowAncestor(this),
title = rename.text,
text = tabbedPane.getTitleAt(tabIndex),
value = tabbedPane.getTitleAt(tabIndex)
)
val text = dialog.getText()
if (!text.isNullOrBlank()) {
tabbedPane.setTitleAt(tabIndex, text)
c.putClientProperty(titleProperty, text)
@@ -215,6 +213,21 @@ class TerminalTabbed(
}
}
// 编辑
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
edit.addActionListener(object : AnAction() {
private val hostManager get() = HostManager.getInstance()
override fun actionPerformed(evt: AnActionEvent) {
if (tab is HostTerminalTab) {
val host = hostManager.getHost(tab.host.id) ?: return
val dialog = HostDialog(evt.window, host)
dialog.setLocationRelativeTo(evt.window)
dialog.isVisible = true
hostManager.addHost(dialog.host ?: return)
}
}
})
// 在新窗口中打开
val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window"))
openInNewWindow.addActionListener(object : AnAction() {
@@ -276,6 +289,7 @@ class TerminalTabbed(
close.isEnabled = tab.canClose()
rename.isEnabled = close.isEnabled
clone.isEnabled = close.isEnabled
edit.isEnabled = tab is HostTerminalTab && tab.host.id != "local"
openInNewWindow.isEnabled = close.isEnabled
// 如果不允许克隆
@@ -327,6 +341,13 @@ class TerminalTabbed(
Disposer.register(this, tab)
}
override fun refreshTerminalTabs() {
for (i in 0 until tabbedPane.tabCount) {
tabbedPane.setTabClosable(i, tabs[i].canClose())
}
}
private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) {
if (!SFTPPtyTerminalTab.canSupports) {
OptionPane.showMessageDialog(

View File

@@ -7,4 +7,5 @@ interface TerminalTabbedManager {
fun getTerminalTabs(): List<TerminalTab>
fun setSelectedTerminalTab(tab: TerminalTab)
fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true)
fun refreshTerminalTabs()
}

View File

@@ -4,23 +4,25 @@ package app.termora
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.actions.DataProviders
import app.termora.sftp.SFTPTab
import app.termora.terminal.DataKey
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.JBR
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Insets
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseListener
import java.awt.event.MouseMotionListener
import java.util.*
import javax.imageio.ImageIO
import javax.swing.Box
import javax.swing.JComponent
import javax.swing.JFrame
import javax.swing.SwingUtilities
import javax.swing.SwingUtilities.isEventDispatchThread
import javax.swing.UIManager
import kotlin.math.max
fun assertEventDispatchThread() {
if (!isEventDispatchThread()) throw WrongThreadException("AWT EventQueue")
@@ -32,14 +34,13 @@ class TermoraFrame : JFrame(), DataProvider {
private val id = UUID.randomUUID().toString()
private val windowScope = ApplicationScope.forWindowScope(this)
private val titleBar = LogicCustomTitleBar.createCustomTitleBar(this)
private val tabbedPane = MyTabbedPane()
private val toolbar = TermoraToolBar(titleBar, tabbedPane)
private val toolbar = TermoraToolBar(windowScope, this, tabbedPane)
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
private val isWindowDecorationsSupported by lazy { JBR.isWindowDecorationsSupported() }
private val dataProviderSupport = DataProviderSupport()
private val welcomePanel = WelcomePanel(windowScope)
private val sftp get() = Database.getDatabase().sftp
private val myUI = MyFlatRootPaneUI()
init {
@@ -48,44 +49,146 @@ class TermoraFrame : JFrame(), DataProvider {
}
private fun initEvents() {
if (SystemInfo.isLinux) {
val mouseAdapter = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
getMouseHandler()?.mouseClicked(e)
}
forceHitTest()
override fun mousePressed(e: MouseEvent) {
getMouseHandler()?.mousePressed(e)
}
// macos 需要判断是否全部删除
// 当 Tab 为 0 的时候,需要加一个边距,避开控制栏
if (SystemInfo.isMacOS && isWindowDecorationsSupported) {
tabbedPane.addChangeListener {
tabbedPane.leadingComponent = if (tabbedPane.tabCount == 0) {
Box.createHorizontalStrut(titleBar.leftInset.toInt())
} else {
null
override fun mouseDragged(e: MouseEvent) {
val mouseLayer = getMouseLayer() ?: return
getMouseMotionListener()?.mouseDragged(
MouseEvent(
mouseLayer,
e.id,
e.`when`,
e.modifiersEx,
e.x,
e.y,
e.clickCount,
e.isPopupTrigger,
e.button
)
)
}
private fun getMouseHandler(): MouseListener? {
return getHandler() as? MouseListener
}
private fun getMouseMotionListener(): MouseMotionListener? {
return getHandler() as? MouseMotionListener
}
private fun getMouseLayer(): JComponent? {
val titlePane = myUI.getTitlePane() ?: return null
val handlerField = titlePane.javaClass.getDeclaredField("mouseLayer") ?: return null
handlerField.isAccessible = true
return handlerField.get(titlePane) as? JComponent
}
private fun getHandler(): Any? {
val titlePane = myUI.getTitlePane() ?: return null
val handlerField = titlePane.javaClass.getDeclaredField("handler") ?: return null
handlerField.isAccessible = true
return handlerField.get(titlePane)
}
}
toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
}
/// force hit
if (SystemInfo.isMacOS) {
if (JBR.isWindowDecorationsSupported()) {
val height = UIManager.getInt("TabbedPane.tabHeight") + tabbedPane.tabAreaInsets.top
val customTitleBar = JBR.getWindowDecorations().createCustomTitleBar()
customTitleBar.height = height.toFloat()
// 监听主题变化 需要动态修改控制栏颜色
if (SystemInfo.isWindows && isWindowDecorationsSupported) {
ThemeManager.getInstance().addThemeChangeListener(object : ThemeChangeListener {
override fun onChanged() {
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
val mouseAdapter = object : MouseAdapter() {
private fun hit(e: MouseEvent) {
if (e.source == tabbedPane) {
val index = tabbedPane.indexAtLocation(e.x, e.y)
if (index >= 0) {
return
}
}
customTitleBar.forceHitTest(false)
}
override fun mouseClicked(e: MouseEvent) {
hit(e)
}
override fun mousePressed(e: MouseEvent) {
hit(e)
}
override fun mouseReleased(e: MouseEvent) {
hit(e)
}
override fun mouseEntered(e: MouseEvent) {
hit(e)
}
override fun mouseDragged(e: MouseEvent) {
hit(e)
}
override fun mouseMoved(e: MouseEvent) {
hit(e)
}
}
})
}
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)
}
}
}
private fun initView() {
if (isWindowDecorationsSupported) {
titleBar.height = UIManager.getInt("TabbedPane.tabHeight").toFloat()
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
JBR.getWindowDecorations().setCustomTitleBar(this, titleBar)
// 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) {
rootPane.setUI(myUI)
tabbedPane.tabAreaInsets = Insets(1, 2, 0, 0)
}
if (SystemInfo.isLinux) {
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_HEIGHT, UIManager.getInt("TabbedPane.tabHeight"))
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) {
@@ -101,88 +204,21 @@ class TermoraFrame : JFrame(), DataProvider {
terminalTabbed.addTerminalTab(welcomePanel)
// 下一次事件循环检测是否固定 SFTP
SwingUtilities.invokeLater {
if (sftp.pinTab) {
terminalTabbed.addTerminalTab(SFTPTerminalTab(), false)
if (sftp.pinTab) {
SwingUtilities.invokeLater {
terminalTabbed.addTerminalTab(SFTPTab(), false)
}
}
// macOS 要避开左边的控制栏
if (SystemInfo.isMacOS) {
val left = max(titleBar.leftInset.toInt(), 76)
if (tabbedPane.tabCount == 0) {
tabbedPane.leadingComponent = Box.createHorizontalStrut(left)
} else {
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0)
}
}
Disposer.register(windowScope, terminalTabbed)
add(terminalTabbed)
add(terminalTabbed, BorderLayout.CENTER)
dataProviderSupport.addData(DataProviders.TabbedPane, tabbedPane)
dataProviderSupport.addData(DataProviders.TermoraFrame, this)
dataProviderSupport.addData(DataProviders.WindowScope, windowScope)
}
private fun forceHitTest() {
val mouseAdapter = object : MouseAdapter() {
private fun hit(e: MouseEvent) {
if (e.source == tabbedPane) {
val index = tabbedPane.indexAtLocation(e.x, e.y)
if (index >= 0) {
return
}
}
titleBar.forceHitTest(false)
}
override fun mouseClicked(e: MouseEvent) {
hit(e)
}
override fun mousePressed(e: MouseEvent) {
if (e.source == toolbar.getJToolBar()) {
if (!isWindowDecorationsSupported && SwingUtilities.isLeftMouseButton(e)) {
if (JBR.isWindowMoveSupported()) {
JBR.getWindowMove().startMovingTogetherWithMouse(this@TermoraFrame, e.button)
}
}
}
hit(e)
}
override fun mouseReleased(e: MouseEvent) {
hit(e)
}
override fun mouseEntered(e: MouseEvent) {
hit(e)
}
override fun mouseDragged(e: MouseEvent) {
hit(e)
}
override fun mouseMoved(e: MouseEvent) {
hit(e)
}
}
terminalTabbed.addMouseListener(mouseAdapter)
terminalTabbed.addMouseMotionListener(mouseAdapter)
tabbedPane.addMouseListener(mouseAdapter)
tabbedPane.addMouseMotionListener(mouseAdapter)
toolbar.getJToolBar().addMouseListener(mouseAdapter)
toolbar.getJToolBar().addMouseMotionListener(mouseAdapter)
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey)
?: terminalTabbed.getData(dataKey)
@@ -203,5 +239,4 @@ class TermoraFrame : JFrame(), DataProvider {
return id.hashCode()
}
}

View File

@@ -5,7 +5,9 @@ import org.slf4j.LoggerFactory
import java.awt.Frame
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.swing.JFrame
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import javax.swing.UIManager
import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE
import kotlin.math.max
@@ -96,6 +98,21 @@ class TermoraFrameManager {
})
}
fun tick() {
if (SwingUtilities.isEventDispatchThread()) {
val windows = getWindows()
if (windows.isEmpty()) return
for (window in windows) {
if (window.extendedState and JFrame.ICONIFIED == JFrame.ICONIFIED) {
window.extendedState = window.extendedState and JFrame.ICONIFIED.inv()
}
}
windows.last().toFront()
} else {
SwingUtilities.invokeLater { tick() }
}
}
private fun dispose() {
Disposer.dispose(ApplicationScope.forApplicationScope())

View File

@@ -1,19 +1,16 @@
package app.termora
import app.termora.Application.ohMyJson
import app.termora.actions.ActionManager
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import app.termora.actions.*
import app.termora.findeverywhere.FindEverywhereAction
import app.termora.snippet.SnippetAction
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTabbedPane
import com.formdev.flatlaf.util.SystemInfo
import com.jetbrains.WindowDecorations
import kotlinx.serialization.Serializable
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionContainerFactory
import java.awt.Insets
import java.awt.Rectangle
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import javax.swing.Box
@@ -27,7 +24,8 @@ data class ToolBarAction(
)
class TermoraToolBar(
private val titleBar: WindowDecorations.CustomTitleBar,
private val windowScope: WindowScope,
private val frame: TermoraFrame,
private val tabbedPane: FlatTabbedPane
) {
private val properties by lazy { Database.getDatabase().properties }
@@ -49,7 +47,7 @@ class TermoraToolBar(
ToolBarAction(Actions.MACRO, true),
ToolBarAction(Actions.KEYWORD_HIGHLIGHT, true),
ToolBarAction(Actions.KEY_MANAGER, true),
ToolBarAction(Actions.MULTIPLE, true),
ToolBarAction(MultipleAction.MULTIPLE, true),
ToolBarAction(FindEverywhereAction.FIND_EVERYWHERE, true),
ToolBarAction(SettingsAction.SETTING, true),
)
@@ -126,8 +124,13 @@ class TermoraToolBar(
// 获取显示的Action如果不是 false 那么就是显示出来
for (action in getActions()) {
if (action.visible) {
actionManager.getAction(action.id)?.let {
toolbar.add(actionContainerFactory.createButton(it))
val ac = actionManager.getAction(action.id)
if (ac == null) {
if (action.id == MultipleAction.MULTIPLE) {
toolbar.add(actionContainerFactory.createButton(MultipleAction.getInstance(windowScope)))
}
} else {
toolbar.add(actionContainerFactory.createButton(ac))
}
}
}
@@ -152,14 +155,11 @@ class TermoraToolBar(
}
fun adjust() {
if (SystemInfo.isMacOS) {
val left = titleBar.leftInset.toInt()
if (tabbedPane.tabAreaInsets.left != left) {
tabbedPane.tabAreaInsets = Insets(0, left, 0, 0)
}
} else if (SystemInfo.isWindows || SystemInfo.isLinux) {
val right = titleBar.rightInset.toInt()
if (SystemInfo.isWindows || SystemInfo.isLinux) {
val rectangle =
frame.rootPane.getClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS)
as? Rectangle ?: return
val right = rectangle.width
val toolbar = this@MyToolBar
for (i in 0 until toolbar.componentCount) {
val c = toolbar.getComponent(i)

View File

@@ -47,6 +47,7 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
private fun initView() {
putClientProperty(FlatClientProperties.TABBED_PANE_TAB_CLOSABLE, false)
putClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE, true)
val panel = JPanel(BorderLayout())
panel.add(createSearchPanel(), BorderLayout.NORTH)

View File

@@ -6,9 +6,9 @@ import app.termora.findeverywhere.FindEverywhereAction
import app.termora.highlight.KeywordHighlightAction
import app.termora.keymgr.KeyManagerAction
import app.termora.macro.MacroAction
import app.termora.sftp.SFTPAction
import app.termora.snippet.SnippetAction
import app.termora.tlog.TerminalLoggerAction
import app.termora.transport.SFTPAction
import javax.swing.Action
class ActionManager : org.jdesktop.swingx.action.ActionManager() {
@@ -29,7 +29,6 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() {
addAction(NewWindowAction.NEW_WINDOW, NewWindowAction())
addAction(FindEverywhereAction.FIND_EVERYWHERE, FindEverywhereAction())
addAction(Actions.MULTIPLE, MultipleAction())
addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance())
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())

View File

@@ -73,6 +73,9 @@ class AppUpdateAction private constructor() : AnAction(
}
private suspend fun checkUpdate() {
if (Application.isUnknownVersion()) {
return
}
val latestVersion = updaterManager.fetchLatestVersion()
if (latestVersion.isSelf) {
@@ -220,7 +223,10 @@ class AppUpdateAction private constructor() : AnAction(
// 没有安装过 则打开安装向导
else listOf(file.absolutePath)
println(commands)
if (log.isInfoEnabled) {
log.info("restart {}", commands.joinToString(StringUtils.SPACE))
}
TermoraRestarter.getInstance().scheduleRestart(owner, commands)
}

View File

@@ -5,7 +5,7 @@ import app.termora.terminal.DataKey
object DataProviders {
val TerminalPanel = DataKey(app.termora.terminal.panel.TerminalPanel::class)
val Terminal = DataKey(app.termora.terminal.Terminal::class)
val PtyConnector get() = DataKey.PtyConnector
val TerminalWriter get() = DataKey.TerminalWriter
val TabbedPane = DataKey(app.termora.MyTabbedPane::class)
val TerminalTabbed = DataKey(app.termora.TerminalTabbed::class)

View File

@@ -1,17 +1,32 @@
package app.termora.actions
import app.termora.*
import app.termora.I18n
import app.termora.Icons
import app.termora.TerminalPanelFactory
import app.termora.WindowScope
class MultipleAction : AnAction(
class MultipleAction private constructor() : AnAction(
I18n.getString("termora.tools.multiple"),
Icons.vcs
) {
companion object {
/**
* 将命令发送到多个会话
*/
const val MULTIPLE = "MultipleAction"
fun getInstance(windowScope: WindowScope): MultipleAction {
return windowScope.getOrCreate(MultipleAction::class) { MultipleAction() }
}
}
init {
setStateAction()
}
override fun actionPerformed(evt: AnActionEvent) {
ApplicationScope.windowScopes().map { TerminalPanelFactory.getInstance(it) }
.forEach { it.repaintAll() }
TerminalPanelFactory.getInstance().repaintAll()
}
}

View File

@@ -22,6 +22,7 @@ class OpenLocalTerminalAction : AnAction(
OpenHostActionEvent(
evt.source,
Host(
id = "local",
name = name,
protocol = Protocol.Local
),

View File

@@ -32,7 +32,6 @@ class TerminalCopyAction : AnAction() {
}
systemClipboard.setContents(StringSelection(text), null)
terminalPanel.toast(I18n.getString("termora.terminal.copied"))
if (log.isTraceEnabled) {
log.trace("Copy to clipboard. {}", text)
}

View File

@@ -1,6 +1,5 @@
package app.termora.actions
import app.termora.ApplicationScope
import app.termora.Database
import app.termora.TerminalPanelFactory
@@ -13,10 +12,8 @@ abstract class TerminalZoomAction : AnAction() {
evt.getData(DataProviders.TerminalPanel) ?: return
if (zoom()) {
ApplicationScope.windowScopes().forEach {
TerminalPanelFactory.getInstance(it)
TerminalPanelFactory.getInstance()
.fireResize()
}
evt.consume()
}
}

View File

@@ -3,12 +3,11 @@ package app.termora.findeverywhere
import app.termora.DialogWrapper
import app.termora.DynamicColor
import app.termora.I18n
import app.termora.WindowScope
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.macro.MacroFindEverywhereProvider
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextField
import com.jetbrains.JBR
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Insets
@@ -18,7 +17,7 @@ import javax.swing.*
import javax.swing.event.DocumentEvent
import javax.swing.event.DocumentListener
class FindEverywhere(owner: Window) : DialogWrapper(owner) {
class FindEverywhere(owner: Window, windowScope: WindowScope) : DialogWrapper(owner) {
private val searchTextField = FlatTextField()
private val model = DefaultListModel<FindEverywhereResult>()
private val resultList = FindEverywhereXList(model)
@@ -26,7 +25,7 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
private val providers = mutableListOf<FindEverywhereProvider>(
BasicFilterFindEverywhereProvider(QuickCommandFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(SettingsFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(QuickActionsFindEverywhereProvider()),
BasicFilterFindEverywhereProvider(QuickActionsFindEverywhereProvider(windowScope)),
BasicFilterFindEverywhereProvider(MacroFindEverywhereProvider()),
)
@@ -44,17 +43,10 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
minimumSize = Dimension(size.width / 2, size.height / 2)
isModal = false
lostFocusDispose = true
controlsVisible = false
defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
setLocationRelativeTo(null)
// 不支持装饰,铺满
if (!JBR.isWindowDecorationsSupported()) {
rootPane.putClientProperty(FlatClientProperties.FULL_WINDOW_CONTENT, true)
rootPane.putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_CLOSE, false)
}
rootPane.background = DynamicColor("desktop")
val desktopBackground = DynamicColor("desktop")
centerPanel.background = DynamicColor("desktop")
centerPanel.border = BorderFactory.createEmptyBorder(12, 12, 12, 12)
@@ -69,7 +61,7 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
resultList.isRolloverEnabled = false
resultList.selectionMode = ListSelectionModel.SINGLE_SELECTION
resultList.border = BorderFactory.createEmptyBorder(5, 0, 0, 0)
resultList.background = rootPane.background
resultList.background = desktopBackground
val scrollPane = JScrollPane(resultList)
@@ -225,5 +217,11 @@ class FindEverywhere(owner: Window) : DialogWrapper(owner) {
super.setVisible(visible)
}
override fun addNotify() {
super.addNotify()
controlsVisible = false
fullWindowContent = true
}
}

View File

@@ -46,7 +46,7 @@ class FindEverywhereAction : AnAction(StringUtils.EMPTY, Icons.find) {
return
}
val dialog = FindEverywhere(owner)
val dialog = FindEverywhere(owner, scope)
for (provider in FindEverywhereProvider.getFindEverywhereProviders(scope)) {
dialog.registerProvider(provider)
}

View File

@@ -5,6 +5,9 @@ import app.termora.Scope
interface FindEverywhereProvider {
companion object {
const val SKIP_FIND_EVERYWHERE = "SKIP_FIND_EVERYWHERE"
@Suppress("UNCHECKED_CAST")
fun getFindEverywhereProviders(scope: Scope): MutableList<FindEverywhereProvider> {
var list = scope.getAnyOrNull("FindEverywhereProviders")

View File

@@ -2,21 +2,32 @@ package app.termora.findeverywhere
import app.termora.Actions
import app.termora.I18n
import app.termora.WindowScope
import app.termora.actions.MultipleAction
import org.jdesktop.swingx.action.ActionManager
class QuickActionsFindEverywhereProvider : FindEverywhereProvider {
class QuickActionsFindEverywhereProvider(private val windowScope: WindowScope) : FindEverywhereProvider {
private val actions = listOf(
Actions.KEY_MANAGER,
Actions.KEYWORD_HIGHLIGHT,
Actions.MULTIPLE,
MultipleAction.MULTIPLE,
)
override fun find(pattern: String): List<FindEverywhereResult> {
val actionManager = ActionManager.getInstance()
return actions
.mapNotNull { actionManager.getAction(it) }
.map { ActionFindEverywhereResult(it) }
val results = ArrayList<FindEverywhereResult>()
for (action in actions) {
val ac = actionManager.getAction(action)
if (ac == null) {
if (action == MultipleAction.MULTIPLE) {
results.add(ActionFindEverywhereResult(MultipleAction.getInstance(windowScope)))
}
} else {
results.add(ActionFindEverywhereResult(ac))
}
}
return results
}

View File

@@ -1,6 +1,5 @@
package app.termora.highlight
import app.termora.ApplicationScope
import app.termora.DialogWrapper
import app.termora.TerminalFactory
import com.formdev.flatlaf.util.SystemInfo
@@ -31,7 +30,7 @@ class ChooseColorTemplateDialog(owner: Window, title: String) : DialogWrapper(ow
override fun createCenterPanel(): JComponent {
val panel = JPanel(GridLayout(2, 8, 4, 4))
val colorPalette = TerminalFactory.getInstance(ApplicationScope.forWindowScope(this))
val colorPalette = TerminalFactory.getInstance()
.createTerminal().getTerminalModel().getColorPalette()
for (i in 1..16) {
val c = JPanel()

View File

@@ -21,7 +21,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
private val table = FlatTable()
private val keywordHighlightManager by lazy { KeywordHighlightManager.getInstance() }
private val colorPalette by lazy {
TerminalFactory.getInstance(ApplicationScope.forWindowScope(this)).createTerminal().getTerminalModel()
TerminalFactory.getInstance().createTerminal().getTerminalModel()
.getColorPalette()
}

View File

@@ -1,8 +1,8 @@
package app.termora.highlight
import app.termora.ApplicationScope
import app.termora.TerminalPanelFactory
import app.termora.Database
import app.termora.TerminalPanelFactory
import org.slf4j.LoggerFactory
class KeywordHighlightManager private constructor() {
@@ -27,7 +27,7 @@ class KeywordHighlightManager private constructor() {
fun addKeywordHighlight(keywordHighlight: KeywordHighlight) {
database.addKeywordHighlight(keywordHighlight)
keywordHighlights[keywordHighlight.id] = keywordHighlight
ApplicationScope.windowScopes().forEach { TerminalPanelFactory.getInstance(it).repaintAll() }
TerminalPanelFactory.getInstance().repaintAll()
if (log.isDebugEnabled) {
log.debug("Keyword highlighter added. {}", keywordHighlight)
@@ -37,7 +37,7 @@ class KeywordHighlightManager private constructor() {
fun removeKeywordHighlight(id: String) {
database.removeKeywordHighlight(id)
keywordHighlights.remove(id)
ApplicationScope.windowScopes().forEach { TerminalPanelFactory.getInstance(it).repaintAll() }
TerminalPanelFactory.getInstance().repaintAll()
if (log.isDebugEnabled) {
log.debug("Keyword highlighter removed. {}", id)

View File

@@ -119,10 +119,11 @@ class KeymapPanel : JPanel(BorderLayout()) {
val keymap = getCurrentKeymap()
val index = keymapComboBox.selectedIndex
if (keymap != null && !keymap.isReadonly && index >= 0) {
val text = InputDialog(
val text = OptionPane.showInputDialog(
SwingUtilities.getWindowAncestor(this@KeymapPanel),
title = renameBtn.toolTipText, text = keymap.name
).getText()
title = renameBtn.toolTipText,
value = keymap.name
)
if (!text.isNullOrBlank()) {
if (text != keymap.name) {
keymapManager.removeKeymap(keymap.name)

View File

@@ -4,7 +4,6 @@ import app.termora.*
import app.termora.AES.decodeBase64
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import app.termora.native.FileChooser
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.extras.components.FlatTable
@@ -104,7 +103,9 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
private fun initEvents() {
generateBtn.addActionListener {
val dialog = GenerateKeyDialog(SwingUtilities.getWindowAncestor(this))
val owner = SwingUtilities.getWindowAncestor(this)
val dialog = GenerateKeyDialog(owner)
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true
if (dialog.ohKeyPair != OhKeyPair.empty) {
val keyPair = dialog.ohKeyPair
@@ -143,12 +144,14 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
editBtn.addActionListener {
val row = keyPairTable.selectedRow
if (row >= 0) {
val owner = SwingUtilities.getWindowAncestor(this)
var ohKeyPair = keyPairTableModel.getOhKeyPair(row)
val dialog = GenerateKeyDialog(
SwingUtilities.getWindowAncestor(this),
owner,
ohKeyPair,
true
)
dialog.setLocationRelativeTo(owner)
dialog.title = ohKeyPair.name
dialog.isVisible = true
ohKeyPair = dialog.ohKeyPair
@@ -196,7 +199,6 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
}
private fun sshCopyId(evt: AnActionEvent) {
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
val keyPairs = keyPairTable.selectedRows.map { keyPairTableModel.getOhKeyPair(it) }
val publicKeys = mutableListOf<Pair<String, String>>()
for (keyPair in keyPairs) {
@@ -220,7 +222,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
return
}
SSHCopyIdDialog(owner, windowScope, hosts, publicKeys).start()
SSHCopyIdDialog(owner, hosts, publicKeys).start()
}
private fun exportKeyPairs(file: File, keyPairs: List<OhKeyPair>) {
@@ -344,7 +346,6 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
pack()
size = Dimension(UIManager.getInt("Dialog.width") - 300, size.height)
setLocationRelativeTo(null)
}
@@ -356,7 +357,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
var rows = 1
val step = 2
return FormBuilder.create().layout(layout).padding("0dlu, $formMargin, $formMargin, $formMargin")
return FormBuilder.create().layout(layout).padding("2dlu, $formMargin, $formMargin, $formMargin")
.add("${I18n.getString("termora.keymgr.table.type")}:").xy(1, rows)
.add(typeComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.keymgr.table.length")}:").xy(1, rows)
@@ -514,7 +515,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
var rows = 1
val step = 2
return FormBuilder.create().layout(layout).padding("0dlu, $formMargin, $formMargin, $formMargin")
return FormBuilder.create().layout(layout).padding("2dlu, $formMargin, $formMargin, $formMargin")
.add("File:").xy(1, rows)
.add(fileTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.keymgr.table.type")}:").xy(1, rows)
@@ -589,8 +590,10 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
try {
val provider = FileKeyPairProvider(file.toPath())
provider.passwordFinder = FilePasswordProvider { _, _, _ ->
val dialog = InputDialog(owner = this@ImportKeyDialog, title = "Password")
dialog.getText() ?: String()
OptionPane.showInputDialog(
SwingUtilities.getWindowAncestor(this),
title = I18n.getString("termora.new-host.general.password"),
) ?: String()
}
val keyPair = provider.loadKeys(null).firstOrNull()
?: throw IllegalStateException("Failed to load the key file")

View File

@@ -23,7 +23,6 @@ import javax.swing.UIManager
class SSHCopyIdDialog(
owner: Window,
private val windowScope: WindowScope,
private val hosts: List<Host>,
// key: name , value: public key
private val publicKeys: List<Pair<String, String>>,
@@ -33,9 +32,9 @@ class SSHCopyIdDialog(
private val log = LoggerFactory.getLogger(SSHCopyIdDialog::class.java)
}
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
private val terminalPanelFactory = TerminalPanelFactory.getInstance()
private val terminal by lazy {
TerminalFactory.getInstance(windowScope).createTerminal().apply {
TerminalFactory.getInstance().createTerminal().apply {
getTerminalModel().setData(DataKey.ShowCursor, false)
getTerminalModel().setData(DataKey.AutoNewline, true)
}

View File

@@ -83,8 +83,11 @@ class MacroDialog(owner: Window) : DialogWrapper(owner) {
val index = list.selectedIndex
if (index >= 0) {
val macro = model.getElementAt(index)
val dialog = InputDialog(owner = this, title = macro.name, text = macro.name)
val text = dialog.getText() ?: String()
val text = OptionPane.showInputDialog(
this,
title = macro.name,
value = macro.name
) ?: String()
if (text.isNotBlank()) {
val newMacro = macro.copy(name = text)
macroManager.addMacro(newMacro)

View File

@@ -1,17 +1,15 @@
package app.termora.macro
import app.termora.Actions
import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate
import org.jdesktop.swingx.action.ActionManager
import java.util.*
class MacroPtyConnector(private val connector: PtyConnector) : PtyConnectorDelegate(connector) {
private val isRecording get() = ActionManager.getInstance().isSelected(Actions.MACRO)
companion object {
private val bytes = LinkedList<Byte>()
private val bytes = ArrayDeque<Byte>()
fun getRecodingByteArray(): ByteArray {
val array = bytes.toByteArray()

View File

@@ -1,7 +1,7 @@
package app.termora.native
import app.termora.native.osx.DispatchNative
import com.formdev.flatlaf.util.SystemInfo
import de.jangassen.jfa.ThreadUtils
import de.jangassen.jfa.foundation.Foundation
import jnafilechooser.api.JnaFileChooser
import org.apache.commons.lang3.StringUtils
@@ -35,6 +35,11 @@ class FileChooser {
} else {
val fileChooser = JnaFileChooser()
fileChooser.isMultiSelectionEnabled = allowsMultiSelection
when (fileSelectionMode) {
JFileChooser.DIRECTORIES_ONLY -> fileChooser.mode = JnaFileChooser.Mode.Directories
JFileChooser.FILES_ONLY -> fileChooser.mode = JnaFileChooser.Mode.Files
JFileChooser.FILES_AND_DIRECTORIES -> fileChooser.mode = JnaFileChooser.Mode.FilesAndDirectories
}
fileChooser.setTitle(title)
if (defaultDirectory.isNotBlank()) {
@@ -89,7 +94,7 @@ class FileChooser {
private fun showMacOSOpenDialog(future: CompletableFuture<List<File>>) {
DispatchNative.getInstance().dispatch_async(object : Runnable {
ThreadUtils.dispatch_async(object : Runnable {
override fun run() {
val pool = Foundation.NSAutoreleasePool()
try {
@@ -165,7 +170,7 @@ class FileChooser {
}
private fun showMacOSSaveDialog(filename: String, future: CompletableFuture<File?>) {
DispatchNative.getInstance().dispatch_async(object : Runnable {
ThreadUtils.dispatch_async(object : Runnable {
override fun run() {
val pool = Foundation.NSAutoreleasePool()
try {

View File

@@ -1,36 +0,0 @@
package app.termora.native.osx
import app.termora.ApplicationScope
import java.lang.reflect.Method
class DispatchNative private constructor() {
companion object {
fun getInstance(): DispatchNative {
return ApplicationScope.forApplicationScope().getOrCreate(DispatchNative::class) { DispatchNative() }
}
}
val dispatch_main_queue: Long
private val nativeExecuteAsync: Method
init {
val clazz = Class.forName("sun.lwawt.macosx.concurrent.LibDispatchNative")
val nativeGetMainQueue = clazz.getDeclaredMethod("nativeGetMainQueue")
nativeGetMainQueue.isAccessible = true
dispatch_main_queue = nativeGetMainQueue.invoke(null) as Long
nativeExecuteAsync = clazz.getDeclaredMethod(
"nativeExecuteAsync",
*arrayOf(Long::class.java, Runnable::class.java)
)
nativeExecuteAsync.isAccessible = true
}
fun dispatch_async(runnable: Runnable) {
nativeExecuteAsync.invoke(null, dispatch_main_queue, runnable)
}
}

View File

@@ -0,0 +1,51 @@
package app.termora.native.osx
import de.jangassen.jfa.foundation.Foundation
import de.jangassen.jfa.foundation.ID
import org.slf4j.LoggerFactory
import java.awt.Component
import java.awt.Window
object NativeMacLibrary {
private val log = LoggerFactory.getLogger(NativeMacLibrary::class.java)
fun getNSWindow(window: Window): Long? {
try {
val peerField = Component::class.java.getDeclaredField("peer") ?: return null
peerField.isAccessible = true
val peer = peerField.get(window) ?: return null
val platformWindowField = peer.javaClass.getDeclaredField("platformWindow") ?: return null
platformWindowField.isAccessible = true
val platformWindow = platformWindowField.get(peer)
val ptrField = Class.forName("sun.lwawt.macosx.CFRetainedResource")
.getDeclaredField("ptr") ?: return null
ptrField.isAccessible = true
return ptrField.get(platformWindow) as Long
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
return null
}
}
fun setControlsVisible(window: Window, visible: Boolean) {
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)
}
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}

View File

@@ -1,15 +1,10 @@
package app.termora.transport
package app.termora.sftp
import app.termora.*
import app.termora.Application.ohMyJson
import app.termora.DynamicColor
import app.termora.I18n
import app.termora.Icons
import app.termora.assertEventDispatchThread
import app.termora.Database
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.ui.FlatUIUtils
import kotlinx.serialization.encodeToString
import org.apache.commons.lang3.StringUtils
import java.awt.*
import java.awt.event.ActionEvent
@@ -23,6 +18,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
private val properties by lazy { Database.getDatabase().properties }
private val arrowWidth = 16
private val arrowSize = 6
private val button = this
/**
* true 表示在书签内
@@ -49,13 +45,15 @@ class BookmarkButton : JButton(Icons.bookmarks) {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e)) {
if (e.x < oldWidth) {
super@BookmarkButton.fireActionPerformed(
ActionEvent(
this@BookmarkButton,
ActionEvent.ACTION_PERFORMED,
StringUtils.EMPTY
for (listener in actionListeners) {
listener.actionPerformed(
ActionEvent(
button,
ActionEvent.ACTION_PERFORMED,
StringUtils.EMPTY
)
)
)
}
} else {
showBookmarks(e)
}
@@ -80,13 +78,15 @@ class BookmarkButton : JButton(Icons.bookmarks) {
popupMenu.addSeparator()
for (bookmark in bookmarks) {
popupMenu.add(bookmark).addActionListener {
super@BookmarkButton.fireActionPerformed(
ActionEvent(
this@BookmarkButton,
ActionEvent.ACTION_PERFORMED,
bookmark
for (listener in actionListeners) {
listener.actionPerformed(
ActionEvent(
button,
ActionEvent.ACTION_PERFORMED,
bookmark
)
)
)
}
}
}
}
@@ -140,7 +140,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
g.color = if (FlatLaf.isLafDark()) Color(206, 208, 214) else Color(108, 112, 126)
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
FlatUIUtils.paintArrow(
g2d, x, preferredSize.height / 2 - arrowSize, arrowWidth, arrowWidth, SwingConstants.SOUTH,
false, arrowSize, 0f, 0f, 0f

View File

@@ -1,4 +1,4 @@
package app.termora.transport
package app.termora.sftp
import app.termora.DialogWrapper
import app.termora.DynamicColor

View File

@@ -0,0 +1,266 @@
package app.termora.sftp
import app.termora.Icons
import app.termora.assertEventDispatchThread
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatTextField
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Graphics
import java.awt.Point
import java.awt.event.ActionEvent
import java.awt.event.ActionListener
import java.awt.event.ItemEvent
import java.awt.event.ItemListener
import java.nio.file.FileSystem
import java.nio.file.Path
import javax.swing.*
import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener
import javax.swing.filechooser.FileSystemView
import kotlin.io.path.absolutePathString
class FileSystemViewNav(
private val fileSystem: FileSystem,
private val homeDirectory: Path
) : JPanel(BorderLayout()) {
companion object {
private const val PATH = "path"
private val log = LoggerFactory.getLogger(FileSystemViewNav::class.java)
}
private val fileSystemView = FileSystemView.getFileSystemView()
private val textField = MyFlatTextField()
private var popupLastTime = 0L
private val history = linkedSetOf<String>()
private val layeredPane = LayeredPane()
private val downBtn = JButton(Icons.chevronDown)
private val comboBox = object : JComboBox<Path>() {
override fun getLocationOnScreen(): Point {
val point = super.getLocationOnScreen()
point.y -= 1
return point
}
}
init {
initViews()
initEvents()
}
private fun initViews() {
comboBox.isEnabled = false
comboBox.putClientProperty("JComboBox.isTableCellEditor", true)
textField.leadingIcon = NativeFileIcons.getFolderIcon()
textField.trailingComponent = downBtn
textField.text = homeDirectory.absolutePathString()
textField.putClientProperty(PATH, homeDirectory)
downBtn.putClientProperty(
FlatClientProperties.STYLE,
mapOf(
"toolbar.hoverBackground" to UIManager.getColor("Button.background"),
"toolbar.pressedBackground" to UIManager.getColor("Button.background"),
)
)
comboBox.renderer = object : DefaultListCellRenderer() {
private val indentIcon = IndentIcon()
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
val c = super.getListCellRendererComponent(
list,
value,
index,
isSelected,
cellHasFocus
)
indentIcon.depth = 0
indentIcon.icon = NativeFileIcons.getFolderIcon()
icon = indentIcon
return c
}
}
layeredPane.add(comboBox, JLayeredPane.DEFAULT_LAYER as Any)
layeredPane.add(textField, JLayeredPane.PALETTE_LAYER as Any)
add(layeredPane, BorderLayout.CENTER)
if (fileSystem.isWindows()) {
try {
for (root in fileSystemView.roots) {
history.add(root.absolutePath)
}
for (rootDirectory in fileSystem.rootDirectories) {
history.add(rootDirectory.absolutePathString())
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
private fun initEvents() {
val itemListener = ItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
val item = comboBox.selectedItem
if (item is Path) {
changeSelectedPath(item)
}
}
}
comboBox.addPopupMenuListener(object : PopupMenuListener {
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
comboBox.addItemListener(itemListener)
}
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
popupLastTime = System.currentTimeMillis()
comboBox.removeItemListener(itemListener)
comboBox.isEnabled = false
textField.requestFocusInWindow()
}
override fun popupMenuCanceled(e: PopupMenuEvent?) {
}
})
// 监听 Action
addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val text = textField.text.trim()
if (text.isBlank()) return
if (history.contains(text)) return
history.add(text)
}
})
downBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (System.currentTimeMillis() - popupLastTime < 250) return
comboBox.isEnabled = true
comboBox.requestFocusInWindow()
showComboBoxPopup()
}
})
textField.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val name = textField.text.trim()
if (name.isBlank()) return
try {
changeSelectedPath(fileSystem.getPath(name))
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
})
}
private fun showComboBoxPopup() {
comboBox.removeAllItems()
for (text in history) {
val path = fileSystem.getPath(text)
comboBox.addItem(path)
if (text == textField.text) {
comboBox.selectedItem = path
}
}
comboBox.showPopup()
}
fun addActionListener(l: ActionListener) {
listenerList.add(ActionListener::class.java, l)
}
class IndentIcon : Icon {
val space = 10
var depth: Int = 0
var icon = NativeFileIcons.getFolderIcon()
override fun paintIcon(c: Component, g: Graphics, x: Int, y: Int) {
if (c.componentOrientation.isLeftToRight) {
icon.paintIcon(c, g, x + depth * space, y)
} else {
icon.paintIcon(c, g, x, y)
}
}
override fun getIconWidth(): Int {
return icon.iconWidth + depth * space
}
override fun getIconHeight(): Int {
return icon.iconHeight
}
}
fun getSelectedPath(): Path {
return textField.getClientProperty(PATH) as Path
}
fun changeSelectedPath(path: Path) {
assertEventDispatchThread()
textField.text = path.absolutePathString()
textField.putClientProperty(PATH, path)
for (listener in listenerList.getListeners(ActionListener::class.java)) {
listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))
}
}
@Suppress("UNNECESSARY_SAFE_CALL")
override fun updateUI() {
super.updateUI()
downBtn?.putClientProperty(
FlatClientProperties.STYLE,
mapOf(
"toolbar.hoverBackground" to UIManager.getColor("Button.background"),
"toolbar.pressedBackground" to UIManager.getColor("Button.background"),
)
)
}
class MyFlatTextField : FlatTextField() {
public override fun fireActionPerformed() {
super.fireActionPerformed()
}
}
private class LayeredPane : JLayeredPane() {
override fun doLayout() {
synchronized(treeLock) {
for (c in components) {
c.setBounds(0, 0, width, height)
}
}
}
}
}

View File

@@ -0,0 +1,468 @@
package app.termora.sftp
import app.termora.*
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import com.formdev.flatlaf.extras.components.FlatToolBar
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.jdesktop.swingx.JXBusyLabel
import java.awt.BorderLayout
import java.awt.event.*
import java.nio.file.FileSystem
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.concurrent.atomic.AtomicBoolean
import java.util.function.Consumer
import javax.swing.*
import kotlin.io.path.absolutePathString
import kotlin.io.path.name
class FileSystemViewPanel(
val host: Host,
val fileSystem: FileSystem,
private val transportManager: TransportManager,
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) : JPanel(BorderLayout()), Disposable, DataProvider {
private val properties get() = Database.getDatabase().properties
private val sftp get() = Database.getDatabase().sftp
private val table = FileSystemViewTable(fileSystem, transportManager, coroutineScope)
private val disposed = AtomicBoolean(false)
private var nextReloadTicks = emptyArray<Consumer<Unit>>()
private val isLoading = AtomicBoolean(false)
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val loadingPanel = LoadingPanel()
private val layeredPane = LayeredPane()
private val homeDirectory = getHomeDirectory()
private val nav = FileSystemViewNav(fileSystem, homeDirectory)
private var workdir = homeDirectory
private val model get() = table.model as FileSystemViewTableModel
private val showHiddenFilesKey = "termora.transport.host.${host.id}.show-hidden-files"
private var useFileHiding: Boolean
get() = properties.getString(showHiddenFilesKey, "true").toBoolean()
set(value) = properties.putString(showHiddenFilesKey, value.toString())
val isDisposed get() = disposed.get()
init {
initViews()
initEvents()
}
private fun initViews() {
val toolbar = FlatToolBar()
toolbar.add(createHomeFolderButton())
toolbar.add(Box.createHorizontalStrut(2))
toolbar.add(nav)
toolbar.add(createBookmarkButton())
toolbar.add(createParentFolderButton())
toolbar.add(createHiddenFilesButton())
toolbar.add(createRefreshButton())
add(toolbar, BorderLayout.NORTH)
add(layeredPane, BorderLayout.CENTER)
val scrollPane = JScrollPane(table)
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
layeredPane.add(scrollPane, JLayeredPane.DEFAULT_LAYER as Any)
layeredPane.add(loadingPanel, JLayeredPane.PALETTE_LAYER as Any)
}
private fun initEvents() {
Disposer.register(this, table)
nav.addActionListener { reload() }
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
enterTableSelectionFolder()
}
}
})
table.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if (e.keyCode == KeyEvent.VK_ENTER) {
enterTableSelectionFolder()
}
}
})
val listener = object : TransportListener, Disposable {
override fun onTransportChanged(transport: Transport) {
val path = transport.target.parent ?: return
if (path.fileSystem != fileSystem) return
if (path.absolutePathString() != workdir.absolutePathString()) return
// 立即刷新
reload(true)
}
override fun dispose() {
transportManager.removeTransportListener(this)
}
}
transportManager.addTransportListener(listener)
Disposer.register(this, listener)
// 变更工作目录
if (SwingUtilities.isEventDispatchThread()) {
changeWorkdir(homeDirectory)
} else {
SwingUtilities.invokeLater { changeWorkdir(homeDirectory) }
}
}
private fun enterTableSelectionFolder(row: Int = table.selectedRow) {
if (row < 0 || isLoading.get()) return
val attr = model.getAttr(row)
if (attr.isFile) return
// 当前工作目录
val workdir = getWorkdir()
// 返回上级之后,选中上级目录
if (attr.name == "..") {
val workdirName = workdir.name
nextReloadTickSelection(workdirName)
}
changeWorkdir(attr.path)
}
private fun createRefreshButton(): JButton {
val button = JButton(Icons.refresh)
button.addActionListener { reload(true) }
return button
}
private fun createHiddenFilesButton(): JButton {
val button = JButton(if (useFileHiding) Icons.eyeClose else Icons.eye)
button.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
useFileHiding = !useFileHiding
button.icon = if (useFileHiding) Icons.eyeClose else Icons.eye
reload(true)
}
})
return button
}
private fun createHomeFolderButton(): JButton {
val button = JButton(Icons.homeFolder)
button.addActionListener { nav.changeSelectedPath(homeDirectory) }
return button
}
private fun createBookmarkButton(): JButton {
val bookmarkBtn = BookmarkButton()
bookmarkBtn.name = "Host.${host.id}.Bookmarks"
bookmarkBtn.addActionListener { e ->
if (e.actionCommand.isNullOrBlank()) {
if (bookmarkBtn.isBookmark) {
bookmarkBtn.deleteBookmark(workdir.toString())
} else {
bookmarkBtn.addBookmark(workdir.toString())
}
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
} else {
changeWorkdir(fileSystem.getPath(e.actionCommand))
}
}
nav.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(nav.getSelectedPath().absolutePathString())
}
})
return bookmarkBtn
}
private fun createParentFolderButton(): AbstractButton {
val button = JButton(Icons.up)
button.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (model.rowCount < 1) return
val attr = model.getAttr(0)
if (attr !is FileSystemViewTableModel.ParentAttr) return
enterTableSelectionFolder(0)
}
})
addPropertyChangeListener("workdir") {
button.isEnabled = model.rowCount > 0 && model.getAttr(0) is FileSystemViewTableModel.ParentAttr
}
return button
}
private fun nextReloadTickSelection(name: String, consumer: Consumer<Int> = Consumer { }) {
// 创建成功之后需要修改和选中
registerNextReloadTick {
for (i in 0 until table.rowCount) {
if (model.getAttr(i).name == name) {
table.addRowSelectionInterval(i, i)
table.scrollRectToVisible(table.getCellRect(i, 0, true))
consumer.accept(i)
break
}
}
}
}
private fun changeWorkdir(workdir: Path) {
assertEventDispatchThread()
nav.changeSelectedPath(workdir)
}
fun renameTo(oldPath: Path, newPath: Path) {
// 新建文件夹
coroutineScope.launch {
if (requestLoading()) {
try {
Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE)
} catch (e: Exception) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
SwingUtilities.getWindowAncestor(owner),
ExceptionUtils.getMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
} finally {
stopLoading()
}
}
// 创建成功之后需要选中
nextReloadTickSelection(newPath.name)
// 立即刷新
reload()
}
}
fun newFolderOrFile(name: String, isFile: Boolean) {
coroutineScope.launch {
if (requestLoading()) {
try {
doNewFolderOrFile(getWorkdir().resolve(name), isFile)
} finally {
stopLoading()
}
}
// 创建成功之后需要修改和选中
nextReloadTickSelection(name)
// 立即刷新
reload()
}
}
private suspend fun doNewFolderOrFile(path: Path, isFile: Boolean) {
if (Files.exists(path)) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.transport.file-already-exists", path.name),
messageType = JOptionPane.ERROR_MESSAGE
)
}
return
}
// 创建文件夹
withContext(Dispatchers.IO) {
runCatching { if (isFile) Files.createFile(path) else Files.createDirectories(path) }.onFailure {
withContext(Dispatchers.Swing) {
if (it is Exception) {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
}
}
}
fun requestLoading(): Boolean {
if (isLoading.compareAndSet(false, true)) {
if (SwingUtilities.isEventDispatchThread()) {
loadingPanel.start()
} else {
SwingUtilities.invokeLater { loadingPanel.start() }
}
return true
}
return false
}
fun stopLoading() {
if (isLoading.compareAndSet(true, false)) {
if (SwingUtilities.isEventDispatchThread()) {
loadingPanel.stop()
} else {
SwingUtilities.invokeLater { loadingPanel.stop() }
}
}
}
fun reload(rememberSelection: Boolean = false) {
if (!requestLoading()) return
if (fileSystem.isSFTP()) loadingPanel.start()
val oldWorkdir = workdir
val path = nav.getSelectedPath()
coroutineScope.launch {
try {
if (rememberSelection) {
withContext(Dispatchers.Swing) {
table.selectedRows.sortedDescending().map { model.getAttr(it).name }
.forEach { nextReloadTickSelection(it) }
}
}
runCatching { model.reload(path, useFileHiding) }.onFailure {
if (it is Exception) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, ExceptionUtils.getMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
}.onSuccess {
withContext(Dispatchers.Swing) {
workdir = path
// 触发工作目录变动
firePropertyChange("workdir", oldWorkdir, workdir)
}
}
withContext(Dispatchers.Swing) {
// 触发
triggerNextReloadTicks()
}
} finally {
stopLoading()
if (fileSystem.isSFTP()) {
withContext(Dispatchers.Swing) { loadingPanel.stop() }
}
}
}
}
private fun getHomeDirectory(): Path {
if (fileSystem.isSFTP()) {
val fs = fileSystem as SftpFileSystem
val host = fs.session.getAttribute(SshClients.HOST_KEY) ?: return fs.defaultDir
val defaultDirectory = host.options.sftpDefaultDirectory
if (defaultDirectory.isNotBlank()) {
return runCatching { fs.getPath(defaultDirectory) }
.getOrElse { fs.defaultDir }
}
return fs.defaultDir
}
if (sftp.defaultDirectory.isNotBlank()) {
return runCatching { fileSystem.getPath(sftp.defaultDirectory) }
.getOrElse { fileSystem.getPath(SystemUtils.USER_HOME) }
}
return fileSystem.getPath(SystemUtils.USER_HOME)
}
fun getWorkdir(): Path {
return workdir
}
private fun registerNextReloadTick(consumer: Consumer<Unit>) {
nextReloadTicks += Consumer<Unit> { t ->
assertEventDispatchThread()
consumer.accept(t)
}
}
private fun triggerNextReloadTicks() {
for (nextReloadTick in nextReloadTicks) {
nextReloadTick.accept(Unit)
}
nextReloadTicks = emptyArray()
}
override fun dispose() {
if (disposed.compareAndSet(false, true)) {
val rootChildren = transportManager.getTransports(0L)
for (child in rootChildren) {
if (child.source.fileSystem == fileSystem ||
child.target.fileSystem == fileSystem
) {
child.changeStatus(TransportStatus.Failed)
}
}
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return if (dataKey == SFTPDataProviders.FileSystemViewTable) table as T else null
}
private class LoadingPanel : JPanel() {
private val busyLabel = JXBusyLabel()
init {
isOpaque = false
border = BorderFactory.createEmptyBorder(50, 0, 0, 0)
add(busyLabel, BorderLayout.CENTER)
addMouseListener(object : MouseAdapter() {})
isVisible = false
}
fun start() {
busyLabel.isBusy = true
isVisible = true
}
fun stop() {
busyLabel.isBusy = false
isVisible = false
}
}
private class LayeredPane : JLayeredPane() {
override fun doLayout() {
synchronized(treeLock) {
val w = width
val h = height
for (c in components) {
c.setBounds(0, 0, w, h)
}
}
}
}
}

View File

@@ -0,0 +1,838 @@
package app.termora.sftp
import app.termora.*
import app.termora.actions.AnActionEvent
import app.termora.actions.SettingsAction
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.sshd.sftp.client.SftpClient
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.apache.sshd.sftp.client.fs.SftpPath
import org.apache.sshd.sftp.client.fs.SftpPosixFileAttributes
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.awt.Component
import java.awt.Insets
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.awt.event.*
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
import java.text.MessageFormat
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import kotlin.collections.ArrayDeque
import kotlin.io.path.*
import kotlin.time.Duration.Companion.milliseconds
@Suppress("DuplicatedCode")
class FileSystemViewTable(
private val fileSystem: FileSystem,
private val transportManager: TransportManager,
private val coroutineScope: CoroutineScope
) : JTable(), Disposable {
companion object {
private val log = LoggerFactory.getLogger(FileSystemViewTable::class.java)
}
private val sftp get() = Database.getDatabase().sftp
private val model = FileSystemViewTableModel()
private val table = this
private val owner get() = SwingUtilities.getWindowAncestor(this)
private val sftpPanel
get() = SwingUtilities.getAncestorOfClass(SFTPPanel::class.java, this)
as SFTPPanel
private val fileSystemViewPanel
get() = SwingUtilities.getAncestorOfClass(FileSystemViewPanel::class.java, this)
as FileSystemViewPanel
private val actionManager get() = ActionManager.getInstance()
private val isDisposed = AtomicBoolean(false)
init {
initViews()
initEvents()
}
private fun initViews() {
super.setModel(model)
super.getTableHeader().setReorderingAllowed(false)
super.setDragEnabled(true)
super.setDropMode(DropMode.ON_OR_INSERT_ROWS)
super.setCellSelectionEnabled(false)
super.setRowSelectionAllowed(true)
super.setRowHeight(UIManager.getInt("Table.rowHeight"))
super.setAutoResizeMode(AUTO_RESIZE_OFF)
super.setFillsViewportHeight(true)
super.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"showHorizontalLines" to true,
"showVerticalLines" to true,
"cellMargins" to Insets(0, 4, 0, 4),
)
)
setDefaultRenderer(Any::class.java, object : DefaultTableCellRenderer() {
override fun getTableCellRendererComponent(
table: JTable?,
value: Any?,
isSelected: Boolean,
hasFocus: Boolean,
row: Int,
column: Int
): Component {
foreground = null
val c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
icon = if (column == FileSystemViewTableModel.COLUMN_NAME) model.getAttr(row).icon else null
foreground = if (!isSelected && model.getAttr(row).isHidden)
UIManager.getColor("textInactiveText") else foreground
return c
}
})
columnModel.getColumn(FileSystemViewTableModel.COLUMN_NAME).preferredWidth = 250
columnModel.getColumn(FileSystemViewTableModel.COLUMN_LAST_MODIFIED_TIME).preferredWidth = 130
}
private fun initEvents() {
// contextmenu
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isRightMouseButton(e)) {
val r = table.rowAtPoint(e.point)
if (r >= 0 && r < table.rowCount) {
if (!table.isRowSelected(r)) {
table.setRowSelectionInterval(r, r)
}
} else {
table.clearSelection()
}
val rows = table.selectedRows
if (!table.hasFocus()) {
table.requestFocusInWindow()
}
showContextMenu(rows, e)
} else if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val row = table.selectedRow
if (row <= 0 || row >= table.rowCount) return
val attr = model.getAttr(row)
if (attr.isDirectory) return
// 传输
transfer(arrayOf(attr))
}
}
})
// Delete key
table.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) {
val rows = selectedRows
if (rows.contains(0)) return
val attrs = rows.map { model.getAttr(it) }.toTypedArray()
val files = attrs.map { it.path }.toTypedArray()
deletePaths(files, false)
} else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F5) {
fileSystemViewPanel.reload(true)
} else if (!SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_F2) {
renameSelection()
}
}
})
table.transferHandler = object : TransferHandler() {
override fun canImport(support: TransferSupport): Boolean {
val dropLocation = support.dropLocation as? JTable.DropLocation ?: return false
// 如果不是新增行,并且光标不在第一列,那么不允许
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false
// 如果不是新增行,如果在一个文件上,那么不允许
if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
return data is FileSystemTableRowTransferable && data.source != table
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
return !fileSystem.isLocal()
}
return false
}
override fun importData(support: TransferSupport): Boolean {
val dropLocation = support.dropLocation as? JTable.DropLocation ?: return false
// 如果不是新增行,并且光标不在第一列,那么不允许
if (!dropLocation.isInsertRow && dropLocation.column != FileSystemViewTableModel.COLUMN_NAME) return false
// 如果不是新增行,如果在一个文件上,那么不允许
if (!dropLocation.isInsertRow && model.getAttr(dropLocation.row).isFile) return false
var targetWorkdir: Path? = null
// 变更工作目录
if (!dropLocation.isInsertRow) {
targetWorkdir = model.getAttr(dropLocation.row).path
}
if (support.isDataFlavorSupported(FileSystemTableRowTransferable.dataFlavor)) {
val data = support.transferable.getTransferData(FileSystemTableRowTransferable.dataFlavor)
if (data !is FileSystemTableRowTransferable) return false
// 委托源表开始传输
data.source.transfer(data.attrs.toTypedArray(), false, targetWorkdir)
return true
} else if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
if (files.isEmpty()) return false
val paths = files.filterIsInstance<File>()
.map { FileSystemViewTableModel.Attr(it.toPath()) }
.toTypedArray()
if (paths.isEmpty()) return false
val localTarget = sftpPanel.getLocalTarget()
val table = localTarget.getData(SFTPDataProviders.FileSystemViewTable) ?: return false
// 委托最左侧的本地文件系统传输
table.transfer(paths, true, targetWorkdir)
return true
}
return false
}
override fun getSourceActions(c: JComponent?): Int {
return COPY
}
override fun createTransferable(c: JComponent?): Transferable? {
val attrs = table.selectedRows.filter { it != 0 }.map { model.getAttr(it) }
if (attrs.isEmpty()) return null
return FileSystemTableRowTransferable(table, attrs)
}
}
// 快速导航
addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
val c = e.keyChar
val count = model.rowCount
val row = selectedRow + 1
for (i in row until count) if (navigate(i, c)) return
for (i in 0 until count) if (navigate(i, c)) return
}
private fun navigate(row: Int, c: Char): Boolean {
val name = model.getAttr(row).name
if (name.startsWith(c, true)) {
clearSelection()
addRowSelectionInterval(row, row)
table.scrollRectToVisible(table.getCellRect(row, 0, true))
return true
}
return false
}
})
}
override fun dispose() {
if (isDisposed.compareAndSet(false, true)) {
if (!fileSystem.isSFTP()) {
coroutineScope.cancel()
}
}
}
private fun showContextMenu(rows: IntArray, e: MouseEvent) {
val attrs = rows.map { model.getAttr(it) }.toTypedArray()
val files = attrs.map { it.path }.toTypedArray()
val hasParent = rows.contains(0)
val popupMenu = FlatPopupMenu()
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
// 创建文件夹
val newFolder = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder"))
// 创建文件
val newFile = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file"))
// 传输
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
// 编辑
val edit = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.edit"))
edit.isEnabled = fileSystem.isSFTP() && attrs.all { it.isFile }
popupMenu.addSeparator()
// 复制路径
val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path"))
// 如果是本地,那么支持打开本地路径
if (fileSystem.isLocal()) {
popupMenu.add(
I18n.getString(
"termora.transport.table.contextmenu.open-in-folder",
if (SystemInfo.isMacOS) I18n.getString("termora.finder")
else if (SystemInfo.isWindows) I18n.getString("termora.explorer")
else I18n.getString("termora.folder")
)
).addActionListener {
Application.browseInFolder(files.last().toFile())
}
}
popupMenu.addSeparator()
// 重命名
val rename = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.rename"))
// 删除
val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete"))
// rm -rf
val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.warningIntroduction))
// 只有 SFTP 可以
if (!fileSystem.isSFTP()) {
rmrf.isVisible = false
}
// 修改权限
val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
permission.isEnabled = false
// 如果是本地系统文件,那么不允许修改权限,用户应该自己修改
if (fileSystem.isSFTP() && rows.isNotEmpty()) {
permission.isEnabled = true
}
popupMenu.addSeparator()
// 刷新
val refresh = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.refresh"))
popupMenu.add(refresh)
popupMenu.addSeparator()
// 新建
popupMenu.add(newMenu)
// 新建文件夹
newFolder.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
newFolderOrFile(false)
}
})
// 新建文件
newFile.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
newFolderOrFile(true)
}
})
rename.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
renameSelection()
}
})
delete.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
deletePaths(files, false)
}
})
rmrf.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
deletePaths(files, true)
}
})
copyPath.addActionListener {
val sb = StringBuilder()
attrs.forEach { sb.append(it.path.absolutePathString()).appendLine() }
sb.deleteCharAt(sb.length - 1)
toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null)
}
edit.addActionListener { if (files.isNotEmpty()) editFiles(files) }
permission.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
val last = attrs.last()
val dialog = PosixFilePermissionDialog(
SwingUtilities.getWindowAncestor(table),
last.posixFilePermissions
)
val permissions = dialog.open() ?: return
if (fileSystemViewPanel.requestLoading()) {
coroutineScope.launch(Dispatchers.IO) {
val c = runCatching { Files.setPosixFilePermissions(last.path, permissions) }.onFailure {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
ExceptionUtils.getMessage(it),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
// stop loading
fileSystemViewPanel.stopLoading()
// reload
if (c.isSuccess) {
fileSystemViewPanel.reload(true)
}
}
}
}
})
refresh.addActionListener { fileSystemViewPanel.reload() }
transfer.addActionListener { transfer(attrs) }
if (rows.isEmpty() || hasParent) {
transfer.isEnabled = false
rename.isEnabled = false
delete.isEnabled = false
edit.isEnabled = false
rmrf.isEnabled = false
copyPath.isEnabled = false
permission.isEnabled = false
} else {
transfer.isEnabled = sftpPanel.canTransfer(table)
}
popupMenu.show(table, e.x, e.y)
}
private fun renameSelection() {
val index = selectedRow
if (index < 0) return
val attr = model.getAttr(index)
val text = OptionPane.showInputDialog(
owner,
value = attr.name,
title = I18n.getString("termora.transport.table.contextmenu.rename")
) ?: return
if (text.isBlank() || text == attr.name) return
if (model.getPathNames().contains(text)) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.transport.file-already-exists", text),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
fileSystemViewPanel.renameTo(attr.path, attr.path.parent.resolve(text))
}
private fun editFiles(files: Array<Path>) {
if (files.isEmpty()) return
if (SystemInfo.isLinux) {
if (sftp.editCommand.isBlank()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.transport.table.contextmenu.edit-command"),
messageType = JOptionPane.INFORMATION_MESSAGE
)
actionManager.getAction(SettingsAction.SETTING)
?.actionPerformed(AnActionEvent(this, StringUtils.EMPTY, EventObject(this)))
return
}
}
for (file in files) {
val dir = Application.createSubTemporaryDir()
val path = Paths.get(dir.absolutePathString(), file.name)
val newTransport = createTransport(file, false, 0L)
.apply { target = path }
transportManager.addTransportListener(object : TransportListener {
override fun onTransportChanged(transport: Transport) {
if (transport != newTransport) return
if (transport.status != TransportStatus.Done && transport.status != TransportStatus.Failed) return
transportManager.removeTransportListener(this)
if (transport.status != TransportStatus.Done) return
// 监听文件变动
listenFileChange(path, file)
}
})
transportManager.addTransport(newTransport)
}
}
private fun listenFileChange(localPath: Path, remotePath: Path) {
try {
if (sftp.editCommand.isNotBlank()) {
ProcessBuilder(
parseCommand(
MessageFormat.format(
sftp.editCommand,
localPath.absolutePathString()
)
)
).start()
} else if (SystemInfo.isMacOS) {
ProcessBuilder("open", "-a", "TextEdit", localPath.absolutePathString()).start()
} else if (SystemInfo.isWindows) {
ProcessBuilder("notepad", localPath.absolutePathString()).start()
} else {
return
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
return
}
var lastModifiedTime = localPath.getLastModifiedTime().toMillis()
coroutineScope.launch(Dispatchers.IO) {
while (coroutineScope.isActive) {
try {
if (isDisposed.get() || !Files.exists(localPath)) break
val nowModifiedTime = localPath.getLastModifiedTime().toMillis()
if (nowModifiedTime != lastModifiedTime) {
lastModifiedTime = nowModifiedTime
if (log.isDebugEnabled) {
log.debug("Listening to file {} changes", localPath.absolutePathString())
}
withContext(Dispatchers.Swing) {
transportManager.addTransport(
createTransport(localPath, false, 0L)
.apply { target = remotePath })
}
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
break
}
delay(500.milliseconds)
}
}
}
private fun parseCommand(command: String): List<String> {
val result = mutableListOf<String>()
val matcher = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(command)
while (matcher.find()) {
if (matcher.group(1) != null) {
result.add(matcher.group(1)) // 处理双引号部分
} else {
result.add(matcher.group(2).replace("\\\\ ", " "))
}
}
return result
}
private fun newFolderOrFile(isFile: Boolean) {
val name = if (isFile) I18n.getString("termora.transport.table.contextmenu.new.file")
else I18n.getString("termora.welcome.contextmenu.new.folder.name")
val text = OptionPane.showInputDialog(owner, title = name, value = name) ?: return
if (text.isBlank()) return
if (model.getPathNames().contains(text)) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.transport.file-already-exists", text),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
fileSystemViewPanel.newFolderOrFile(text, isFile)
}
private fun transfer(
attrs: Array<FileSystemViewTableModel.Attr>,
fromLocalSystem: Boolean = false,
targetWorkdir: Path? = null
) {
coroutineScope.launch {
try {
doTransfer(attrs, fromLocalSystem, targetWorkdir)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
private fun deletePaths(paths: Array<Path>, rm: Boolean = false) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"),
messageType = if (rm) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE
) != JOptionPane.YES_OPTION
) {
return
}
if (!fileSystemViewPanel.requestLoading()) {
return
}
coroutineScope.launch {
runCatching {
if (fileSystem.isSFTP()) {
deleteSftpPaths(paths, rm)
} else {
deleteRecursively(paths)
}
}.onFailure {
if (log.isErrorEnabled) {
log.error(it.message, it)
}
}
// 停止加载
fileSystemViewPanel.stopLoading()
// 刷新
fileSystemViewPanel.reload()
}
}
private fun deleteSftpPaths(paths: Array<Path>, rm: Boolean = false) {
val fs = this.fileSystem as SftpFileSystem
if (rm) {
for (path in paths) {
fs.session.executeRemoteCommand(
"rm -rf '${path.absolutePathString()}'",
OutputStream.nullOutputStream(),
Charsets.UTF_8
)
}
} else {
fs.client.use {
for (path in paths) {
deleteRecursivelySFTP(path as SftpPath, it)
}
}
}
}
private fun deleteRecursively(paths: Array<Path>) {
for (path in paths) {
FileUtils.deleteQuietly(path.toFile())
}
}
/**
* 优化删除效率,采用一个连接
*/
private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) {
val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory()
if (isDirectory) {
for (e in sftpClient.readDir(path.toString())) {
if (e.filename == ".." || e.filename == ".") {
continue
}
if (e.attributes.isDirectory) {
deleteRecursivelySFTP(path.resolve(e.filename), sftpClient)
} else {
sftpClient.remove(path.resolve(e.filename).toString())
}
}
sftpClient.rmdir(path.toString())
} else {
sftpClient.remove(path.toString())
}
}
/**
* 开始查找所有子,查找到之后立即添加任务,如果添加失败(任意一个)那么立即终止
*/
private fun doTransfer(
attrs: Array<FileSystemViewTableModel.Attr>,
fromLocalSystem: Boolean,
targetWorkdir: Path?
) {
if (attrs.isEmpty()) return
val sftpPanel = this.sftpPanel
val target = sftpPanel.getTarget(table) ?: return
var isTerminate = false
val queue = ArrayDeque<Transport>()
for (attr in attrs) {
/**
* 定义一个添加器,它可以自动的判断导入/拖拽行为
*/
val adder = object {
fun add(transport: Transport): Boolean {
return addTransport(
sftpPanel,
if (fromLocalSystem) attr.path.parent else null,
target,
targetWorkdir,
transport
)
}
}
if (attr.isFile) {
if (!adder.add(createTransport(attr.path, false, 0).apply { scanned() })) {
isTerminate = true
break
}
continue
}
queue.clear()
try {
walk(attr.path, object : FileVisitor<Path> {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
val transport = createTransport(dir, true, queue.lastOrNull()?.id ?: 0L)
.apply { queue.addLast(this) }
if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true }
}
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
if (queue.isEmpty()) return FileVisitResult.SKIP_SIBLINGS
val transport = createTransport(file, false, queue.last().id).apply { scanned() }
if (adder.add(transport)) return FileVisitResult.CONTINUE
return FileVisitResult.TERMINATE.apply { isTerminate = true }
}
override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult {
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
// 标记为扫描完毕
queue.removeLast().scanned()
return FileVisitResult.CONTINUE
}
})
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
isTerminate = true
}
if (isTerminate) break
}
if (isTerminate) {
// 把剩余的文件夹标记为扫描完毕
while (queue.isNotEmpty()) queue.removeLast().scanned()
}
}
private fun walk(dir: Path, visitor: FileVisitor<Path>) {
if (fileSystem is SftpFileSystem) {
val attr = SftpPosixFileAttributes(dir, SftpClient.Attributes())
fileSystem.client.use { walkSFTP(dir, attr, visitor, it) }
} else {
Files.walkFileTree(dir, setOf(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, visitor)
}
}
private fun walkSFTP(
dir: Path,
attr: SftpPosixFileAttributes,
visitor: FileVisitor<Path>,
client: SftpClient
): FileVisitResult {
if (visitor.preVisitDirectory(dir, attr) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
val paths = client.readDir(dir.absolutePathString())
for (e in paths) {
if (e.filename == ".." || e.filename == ".") continue
if (e.attributes.isDirectory) {
if (walkSFTP(dir.resolve(e.filename), attr, visitor, client) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
} else {
val result = visitor.visitFile(dir.resolve(e.filename), attr)
if (result == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
} else if (result == FileVisitResult.SKIP_SUBTREE) {
break
}
}
}
if (visitor.postVisitDirectory(dir, null) == FileVisitResult.TERMINATE) {
return FileVisitResult.TERMINATE
}
return FileVisitResult.CONTINUE
}
private fun addTransport(
sftpPanel: SFTPPanel,
sourceWorkdir: Path?,
target: FileSystemViewPanel,
targetWorkdir: Path?,
transport: Transport
): Boolean {
return sftpPanel.addTransport(table, sourceWorkdir, target, targetWorkdir, transport)
}
private fun createTransport(source: Path, isDirectory: Boolean, parentId: Long): Transport {
val transport = Transport(
source = source,
target = source,
parentId = parentId,
isDirectory = isDirectory,
)
if (transport.isFile) {
transport.filesize.addAndGet(source.fileSize())
}
return transport
}
private class FileSystemTableRowTransferable(
val source: FileSystemViewTable,
val attrs: List<FileSystemViewTableModel.Attr>
) : Transferable {
companion object {
val dataFlavor = DataFlavor(FileSystemTableRowTransferable::class.java, "TableRowTransferable")
}
override fun getTransferDataFlavors(): Array<DataFlavor> {
return arrayOf(dataFlavor)
}
override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean {
return flavor == dataFlavor
}
override fun getTransferData(flavor: DataFlavor?): Any {
if (flavor != dataFlavor) {
throw UnsupportedFlavorException(flavor)
}
return this
}
}
}

View File

@@ -0,0 +1,273 @@
package app.termora.sftp
import app.termora.I18n
import app.termora.NativeStringComparator
import app.termora.formatBytes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.apache.sshd.sftp.client.fs.SftpPath
import org.slf4j.LoggerFactory
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.PosixFilePermissions
import java.util.*
import javax.swing.table.DefaultTableModel
import kotlin.io.path.*
class FileSystemViewTableModel : DefaultTableModel() {
companion object {
const val COLUMN_NAME = 0
const val COLUMN_TYPE = 1
const val COLUMN_FILE_SIZE = 2
const val COLUMN_LAST_MODIFIED_TIME = 3
const val COLUMN_ATTRS = 4
const val COLUMN_OWNER = 5
private val log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java)
private fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
val result = mutableSetOf<PosixFilePermission>()
// 将十进制权限转换为八进制字符串
val octalPermissions = sftpPermissions.toString(8)
// 仅取后三位权限部分
if (octalPermissions.length < 3) {
return result
}
val permissionBits = octalPermissions.takeLast(3)
// 解析每一部分的权限
val owner = permissionBits[0].digitToInt()
val group = permissionBits[1].digitToInt()
val others = permissionBits[2].digitToInt()
// 处理所有者权限
if ((owner and 4) != 0) result.add(PosixFilePermission.OWNER_READ)
if ((owner and 2) != 0) result.add(PosixFilePermission.OWNER_WRITE)
if ((owner and 1) != 0) result.add(PosixFilePermission.OWNER_EXECUTE)
// 处理组权限
if ((group and 4) != 0) result.add(PosixFilePermission.GROUP_READ)
if ((group and 2) != 0) result.add(PosixFilePermission.GROUP_WRITE)
if ((group and 1) != 0) result.add(PosixFilePermission.GROUP_EXECUTE)
// 处理其他用户权限
if ((others and 4) != 0) result.add(PosixFilePermission.OTHERS_READ)
if ((others and 2) != 0) result.add(PosixFilePermission.OTHERS_WRITE)
if ((others and 1) != 0) result.add(PosixFilePermission.OTHERS_EXECUTE)
return result
}
}
override fun getValueAt(row: Int, column: Int): Any {
val attr = getAttr(row)
return when (column) {
COLUMN_NAME -> attr.name
COLUMN_FILE_SIZE -> if (attr.isDirectory) StringUtils.EMPTY else formatBytes(attr.size)
COLUMN_TYPE -> attr.type
COLUMN_LAST_MODIFIED_TIME -> if (attr.modified > 0) DateFormatUtils.format(
Date(attr.modified),
"yyyy/MM/dd HH:mm"
) else StringUtils.EMPTY
COLUMN_ATTRS -> attr.permissions
COLUMN_OWNER -> attr.owner
else -> StringUtils.EMPTY
}
}
override fun getDataVector(): Vector<Vector<Any>> {
return super.getDataVector()
}
override fun getColumnCount(): Int {
return 6
}
override fun getColumnClass(columnIndex: Int): Class<*> {
return when (columnIndex) {
COLUMN_NAME -> String::class.java
else -> super.getColumnClass(columnIndex)
}
}
fun getAttr(row: Int): Attr {
return super.getValueAt(row, 0) as Attr
}
fun getPathNames(): Set<String> {
val names = linkedSetOf<String>()
for (i in 0 until rowCount) {
names.add(getAttr(i).name)
}
return names
}
override fun getColumnName(column: Int): String {
return when (column) {
COLUMN_NAME -> I18n.getString("termora.transport.table.filename")
COLUMN_FILE_SIZE -> I18n.getString("termora.transport.table.size")
COLUMN_TYPE -> I18n.getString("termora.transport.table.type")
COLUMN_LAST_MODIFIED_TIME -> I18n.getString("termora.transport.table.modified-time")
COLUMN_ATTRS -> I18n.getString("termora.transport.table.permissions")
COLUMN_OWNER -> I18n.getString("termora.transport.table.owner")
else -> StringUtils.EMPTY
}
}
override fun isCellEditable(row: Int, column: Int): Boolean {
return false
}
suspend fun reload(dir: Path, useFileHiding: Boolean) {
if (log.isDebugEnabled) {
log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding)
}
val attrs = mutableListOf<Attr>()
if (dir.parent != null) {
attrs.add(ParentAttr(dir.parent))
}
withContext(Dispatchers.IO) {
Files.list(dir).use { paths ->
for (path in paths) {
val attr = if (path is SftpPath) SftpAttr(path) else Attr(path)
if (useFileHiding && attr.isHidden) continue
attrs.add(attr)
}
}
}
attrs.sortWith(compareBy<Attr> { !it.isDirectory }.thenComparing { a, b ->
NativeStringComparator.getInstance().compare(
a.name,
b.name
)
})
withContext(Dispatchers.Swing) {
while (rowCount > 0) removeRow(0)
attrs.forEach { addRow(arrayOf(it)) }
}
}
open class Attr(val path: Path) {
/**
* 名称
*/
open val name by lazy { path.name }
/**
* 文件类型
*/
open val type by lazy {
if (path.fileSystem.isWindows()) NativeFileIcons.getIcon(name, isFile).second
else if (isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
else NativeFileIcons.getIcon(name, isFile).second
}
/**
* 大小
*/
open val size by lazy { path.fileSize() }
/**
* 修改时间
*/
open val modified by lazy { path.getLastModifiedTime().toMillis() }
/**
* 获取所有者
*/
open val owner by lazy { StringUtils.EMPTY }
/**
* 获取操作系统图标
*/
open val icon by lazy { NativeFileIcons.getIcon(name, isFile).first }
/**
* 是否是文件夹
*/
open val isDirectory by lazy { path.isDirectory() }
/**
* 是否是文件
*/
open val isFile by lazy { !isDirectory }
/**
* 是否是文件夹
*/
open val isHidden by lazy { path.isHidden() }
open val isSymbolicLink by lazy { path.isSymbolicLink() }
/**
* 获取权限
*/
open val permissions: String by lazy {
posixFilePermissions.let {
if (it.isNotEmpty()) PosixFilePermissions.toString(
it
) else StringUtils.EMPTY
}
}
open val posixFilePermissions by lazy { if (path.fileSystem.isUnix()) path.getPosixFilePermissions() else emptySet() }
open fun toFile(): File {
if (path.fileSystem.isSFTP()) {
return File(path.absolutePathString())
}
return path.toFile()
}
}
class ParentAttr(path: Path) : Attr(path) {
override val name by lazy { ".." }
override val isDirectory = true
override val isFile = false
override val isHidden = false
override val permissions = StringUtils.EMPTY
override val modified = 0L
override val type = StringUtils.EMPTY
override val icon by lazy { NativeFileIcons.getFolderIcon() }
override val isSymbolicLink = false
}
class SftpAttr(sftpPath: SftpPath) : Attr(sftpPath) {
private val attributes = sftpPath.attributes
override val isSymbolicLink = attributes.isSymbolicLink
override val isDirectory = if (isSymbolicLink) sftpPath.isDirectory() else attributes.isDirectory
override val isHidden = name.startsWith(".")
override val size = attributes.size
override val owner: String = StringUtils.defaultString(attributes.owner)
override val modified = attributes.modifyTime.toMillis()
override val permissions: String = PosixFilePermissions.toString(fromSftpPermissions(attributes.permissions))
override val posixFilePermissions = fromSftpPermissions(attributes.permissions)
override fun toFile(): File {
return File(path.absolutePathString())
}
}
}

View File

@@ -0,0 +1,81 @@
package app.termora.sftp
import app.termora.Application
import app.termora.I18n
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeLeafIcon
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.FileUtils
import org.apache.commons.io.FilenameUtils
import org.apache.commons.lang3.SystemUtils
import org.eclipse.jgit.util.LRUMap
import java.util.*
import javax.swing.Icon
import javax.swing.filechooser.FileSystemView.getFileSystemView
object NativeFileIcons {
/**
* key: filename , value: <icon,description>
*/
private val cache = LRUMap<String, Pair<Icon, String>>(16, 512)
private val folderIcon = FlatTreeClosedIcon()
private val fileIcon = FlatTreeLeafIcon()
init {
if (SystemUtils.IS_OS_UNIX) {
cache[SystemUtils.USER_HOME] = Pair(FlatTreeClosedIcon(), I18n.getString("termora.folder"))
}
}
fun getFolderIcon(): Icon {
return getIcon(UUID.randomUUID().toString(), false).first
}
fun getFileIcon(filename: String): Icon {
return getIcon(filename, true).first
}
fun getIcon(filename: String, isFile: Boolean = true): Pair<Icon, String> {
if (isFile) {
val extension = FilenameUtils.getExtension(filename)
if (cache.containsKey(extension)) {
return cache.getValue(extension)
}
} else {
if (cache.containsKey(SystemUtils.USER_HOME)) {
return cache.getValue(SystemUtils.USER_HOME)
}
}
val isDirectory = !isFile
if (SystemInfo.isWindows) {
val file = if (isDirectory) FileUtils.getFile(SystemUtils.USER_HOME) else
FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}.${filename}")
if (isFile && !file.exists()) {
file.createNewFile()
}
val icon = getFileSystemView().getSystemIcon(file, 16, 16)
val description = getFileSystemView().getSystemTypeDescription(file)
val pair = icon to description
if (isDirectory) {
cache[SystemUtils.USER_HOME] = pair
} else {
cache[FilenameUtils.getExtension(file.name)] = pair
}
if (isFile) FileUtils.deleteQuietly(file)
return pair
}
return Pair(
if (isDirectory) folderIcon else fileIcon,
if (isDirectory) I18n.getString("termora.folder") else FilenameUtils.getExtension(filename)
)
}
}

View File

@@ -1,4 +1,4 @@
package app.termora.transport
package app.termora.sftp
import app.termora.DialogWrapper
import app.termora.I18n

View File

@@ -0,0 +1,62 @@
package app.termora.sftp
import app.termora.HostManager
import app.termora.HostTerminalTab
import app.termora.Icons
import app.termora.Protocol
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.actions.DataProviders
import org.apache.commons.lang3.StringUtils
class SFTPAction : AnAction("SFTP", Icons.folder) {
private val hostManager get() = HostManager.getInstance()
override fun actionPerformed(evt: AnActionEvent) {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
var sftpTab: SFTPTab? = null
for (tab in terminalTabbedManager.getTerminalTabs()) {
if (tab is SFTPTab) {
sftpTab = tab
break
}
}
// 创建一个新的
if (sftpTab == null) {
sftpTab = SFTPTab()
terminalTabbedManager.addTerminalTab(sftpTab, false)
}
var hostId = if (evt is SFTPActionEvent) evt.hostId else StringUtils.EMPTY
// 如果不是特定事件那么尝试获取选中的Tab如果是一个 Host 并且是 SSH 协议那么直接打开
if (hostId.isBlank()) {
val tab = terminalTabbedManager.getSelectedTerminalTab()
if (tab is HostTerminalTab) {
if (tab.host.protocol == Protocol.SSH || tab.host.protocol == Protocol.SFTPPty) {
hostId = tab.host.id
}
}
}
terminalTabbedManager.setSelectedTerminalTab(sftpTab)
if (hostId.isBlank()) return
val tabbed = sftpTab.getData(SFTPDataProviders.RightSFTPTabbed) ?: return
// 如果已经打开了 那么直接选中
for (i in 0 until tabbed.tabCount) {
val fileSystemViewPanel = tabbed.getFileSystemViewPanel(i) ?: continue
if (fileSystemViewPanel.host.id == hostId) {
tabbed.selectedIndex = i
return
}
}
val host = hostManager.getHost(hostId) ?: return
tabbed.addSFTPFileSystemViewPanelTab(host)
}
}

View File

@@ -0,0 +1,11 @@
package app.termora.sftp
import app.termora.actions.AnActionEvent
import org.apache.commons.lang3.StringUtils
import java.util.*
class SFTPActionEvent(
source: Any,
val hostId: String,
event: EventObject
) : AnActionEvent(source, StringUtils.EMPTY, event)

View File

@@ -0,0 +1,12 @@
package app.termora.sftp
import app.termora.terminal.DataKey
object SFTPDataProviders {
val TransportManager = DataKey(app.termora.sftp.TransportManager::class)
val FileSystemViewPanel = DataKey(app.termora.sftp.FileSystemViewPanel::class)
val CoroutineScope = DataKey(kotlinx.coroutines.CoroutineScope::class)
val FileSystemViewTable = DataKey(app.termora.sftp.FileSystemViewTable::class)
val LeftSFTPTabbed = DataKey(SFTPTabbed::class)
val RightSFTPTabbed = DataKey(SFTPTabbed::class)
}

View File

@@ -1,11 +1,9 @@
package app.termora.transport
package app.termora.sftp
import app.termora.*
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.actions.DataProvider
import app.termora.terminal.DataKey
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.*
@@ -23,15 +21,18 @@ import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.event.ActionEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.*
class SftpFileSystemPanel(
var host: Host? = null
) : JPanel(BorderLayout()), Disposable {
class SFTPFileSystemViewPanel(
var host: Host? = null,
private val transportManager: TransportManager,
) : JPanel(BorderLayout()), Disposable, DataProvider {
companion object {
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java)
private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java)
private enum class State {
Initialized,
@@ -50,11 +51,14 @@ class SftpFileSystemPanel(
private val selectHostPanel = SelectHostPanel()
private val connectFailedPanel = ConnectFailedPanel()
private val isDisposed = AtomicBoolean(false)
private val that = this
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val properties get() = Database.getDatabase().properties
private var client: SshClient? = null
private var session: ClientSession? = null
private var fileSystem: SftpFileSystem? = null
var fileSystemPanel: FileSystemPanel? = null
private var fileSystemPanel: FileSystemViewPanel? = null
init {
@@ -71,12 +75,11 @@ class SftpFileSystemPanel(
}
private fun initEvents() {
Disposer.register(this, selectHostPanel)
}
@OptIn(DelicateCoroutinesApi::class)
fun connect() {
GlobalScope.launch(Dispatchers.IO) {
coroutineScope.launch {
if (state != State.Connecting) {
state = State.Connecting
@@ -100,42 +103,17 @@ class SftpFileSystemPanel(
connectingPanel.stop()
}
}
}
}
private suspend fun doConnect() {
val thisHost = this.host ?: return
var host = thisHost.copy(authentication = thisHost.authentication.copy(), updateDate = System.currentTimeMillis())
closeIO()
try {
val client = SshClients.openClient(host).apply { client = this }
withContext(Dispatchers.Swing) {
val owner = SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel)
client.userInteraction = TerminalUserInteraction(owner)
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// 弹出授权框
if (host.authentication.type == AuthenticationType.No) {
val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication()
host = host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(
host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
}
}
val (client, host) = SshClients.openClient(thisHost, SwingUtilities.getWindowAncestor(that))
this.client = client
val session = SshClients.openSession(host, client).apply { session = this }
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
session.addCloseFutureListener { onClose() }
@@ -152,18 +130,10 @@ class SftpFileSystemPanel(
withContext(Dispatchers.Swing) {
state = State.Connected
val fileSystemPanel = FileSystemPanel(fileSystem, host)
val fileSystemPanel = FileSystemViewPanel(thisHost, fileSystem, transportManager, coroutineScope)
cardPanel.add(fileSystemPanel, State.Connected.name)
cardLayout.show(cardPanel, State.Connected.name)
firePropertyChange("TabName", StringUtils.EMPTY, host.name)
this@SftpFileSystemPanel.fileSystemPanel = fileSystemPanel
// 立即加载
fileSystemPanel.reload()
that.fileSystemPanel = fileSystemPanel
}
}
@@ -199,6 +169,7 @@ class SftpFileSystemPanel(
override fun dispose() {
if (isDisposed.compareAndSet(false, true)) {
closeIO()
coroutineScope.cancel()
}
}
@@ -269,7 +240,7 @@ class SftpFileSystemPanel(
AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) {
override fun actionPerformed(e: ActionEvent) {
state = State.Initialized
this@SftpFileSystemPanel.firePropertyChange("TabName", StringUtils.SPACE, StringUtils.EMPTY)
that.setTabTitle(I18n.getString("termora.transport.sftp.select-host"))
cardLayout.show(cardPanel, State.Initialized.name)
}
}).apply {
@@ -281,44 +252,65 @@ class SftpFileSystemPanel(
}
}
private inner class SelectHostPanel : JPanel(BorderLayout()) {
private inner class SelectHostPanel : JPanel(BorderLayout()), Disposable {
private val tree = NewHostTree()
init {
initView()
initEvents()
}
private fun initView() {
val formMargin = "4dlu"
val layout = FormLayout(
"default:grow, pref, default:grow",
"40dlu, pref, $formMargin, pref, $formMargin, pref"
)
tree.contextmenu = false
tree.dragEnabled = false
tree.doubleClickConnection = false
val scrollPane = JScrollPane(tree)
scrollPane.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
add(scrollPane, BorderLayout.CENTER)
val errorInfo = JLabel(I18n.getString("termora.transport.sftp.connect-a-host"))
errorInfo.horizontalAlignment = SwingConstants.CENTER
TreeUtils.loadExpansionState(tree, properties.getString("SFTPTabbed.Tree.state", StringUtils.EMPTY))
}
val builder = FormBuilder.create().layout(layout).debug(false)
builder.add(FlatOptionPaneInformationIcon()).xy(2, 2)
builder.add(errorInfo).xyw(1, 4, 3, "fill, center")
builder.add(JXHyperlink(object : AnAction(I18n.getString("termora.transport.sftp.select-host")) {
override fun actionPerformed(evt: AnActionEvent) {
val dialog = NewHostTreeDialog(evt.window)
dialog.setFilter { it.host.protocol == Protocol.SSH }
dialog.setTreeName("SftpFileSystemPanel.SelectHostTree")
dialog.allowMulti = false
dialog.setLocationRelativeTo(this@SelectHostPanel)
dialog.isVisible = true
this@SftpFileSystemPanel.host = dialog.hosts.firstOrNull() ?: return
connect()
private fun initEvents() {
tree.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val node = tree.getLastSelectedPathNode() ?: return
if (node.isFolder) return
val host = node.data as Host
that.setTabTitle(host.name)
that.host = host
that.connect()
}
}
}).apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
isFocusable = false
}).xy(2, 6)
add(builder.build(), BorderLayout.CENTER)
})
}
override fun dispose() {
properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree))
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return when (dataKey) {
SFTPDataProviders.FileSystemViewPanel -> fileSystemPanel as T?
SFTPDataProviders.CoroutineScope -> coroutineScope as T?
else -> null
}
}
private fun setTabTitle(title: String) {
val tabbed = SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, that)
if (tabbed is JTabbedPane) {
for (i in 0 until tabbed.tabCount) {
if (tabbed.getComponentAt(i) == that) {
tabbed.setTitleAt(i, title)
break
}
}
}
}
}

View File

@@ -0,0 +1,215 @@
package app.termora.sftp
import app.termora.*
import app.termora.actions.DataProvider
import app.termora.actions.DataProviderSupport
import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.terminal.DataKey
import okio.withLock
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import java.awt.BorderLayout
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.nio.file.FileSystem
import java.nio.file.FileSystems
import java.nio.file.Path
import javax.swing.*
import kotlin.io.path.absolutePathString
fun FileSystem.isSFTP() = this is SftpFileSystem
fun FileSystem.isUnix() = isLocal() && SystemUtils.IS_OS_UNIX
fun FileSystem.isWindows() = isLocal() && SystemUtils.IS_OS_WINDOWS
fun FileSystem.isLocal() = StringUtils.startsWithAny(javaClass.name, "java", "sun")
class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
private val transportTable = TransportTable()
private val transportManager get() = transportTable.model
private val dataProviderSupport = DataProviderSupport()
private val leftComponent = SFTPTabbed(transportManager)
private val rightComponent = SFTPTabbed(transportManager)
init {
initViews()
initEvents()
FileSystems.getDefault()
}
private fun initViews() {
putClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE, true)
val splitPane = JSplitPane()
splitPane.resizeWeight = 0.5
splitPane.leftComponent = leftComponent
splitPane.rightComponent = rightComponent
splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
splitPane.addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
removeComponentListener(this)
splitPane.setDividerLocation(splitPane.resizeWeight)
}
})
leftComponent.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
rightComponent.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor)
val rootSplitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT)
val scrollPane = JScrollPane(transportTable)
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
rootSplitPane.resizeWeight = 0.7
rootSplitPane.topComponent = splitPane
rootSplitPane.bottomComponent = scrollPane
rootSplitPane.addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
removeComponentListener(this)
rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight)
}
})
add(rootSplitPane, BorderLayout.CENTER)
}
private fun initEvents() {
Disposer.register(this, leftComponent)
Disposer.register(this, rightComponent)
Disposer.register(this, transportTable)
dataProviderSupport.addData(SFTPDataProviders.TransportManager, transportManager)
dataProviderSupport.addData(SFTPDataProviders.LeftSFTPTabbed, leftComponent)
dataProviderSupport.addData(SFTPDataProviders.RightSFTPTabbed, rightComponent)
// default tab
leftComponent.addTab(
I18n.getString("termora.transport.local"),
FileSystemViewPanel(
Host(
id = "local",
name = I18n.getString("termora.transport.local"),
protocol = Protocol.Local,
), FileSystems.getDefault(), transportManager
)
)
leftComponent.setTabClosable(0, false)
// default tab
rightComponent.addTab(
I18n.getString("termora.transport.sftp.select-host"),
SFTPFileSystemViewPanel(transportManager = transportManager)
)
rightComponent.addChangeListener {
if (rightComponent.tabCount == 0 && !rightComponent.isDisposed) {
rightComponent.addTab(
I18n.getString("termora.transport.sftp.select-host"),
SFTPFileSystemViewPanel(transportManager = transportManager)
)
}
}
leftComponent.setTabCloseCallback { _, index -> tabCloseCallback(leftComponent, index) }
rightComponent.setTabCloseCallback { _, index -> tabCloseCallback(rightComponent, index) }
}
private fun tabCloseCallback(tabbed: SFTPTabbed, index: Int) {
assertEventDispatchThread()
val c = tabbed.getFileSystemViewPanel(index)
if (c == null) {
tabbed.removeTabAt(index)
return
}
val fs = c.fileSystem
val root = transportManager.root
transportManager.lock.withLock {
val deletedIds = mutableListOf<Long>()
for (i in 0 until root.childCount) {
val child = root.getChildAt(i) as? TransportTreeTableNode ?: continue
if (child.transport.source.fileSystem == fs ||
child.transport.target.fileSystem == fs
) {
deletedIds.add(child.transport.id)
}
}
if (deletedIds.isNotEmpty()) {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) != JOptionPane.OK_OPTION
) {
return
}
}
deletedIds.forEach { transportManager.removeTransport(it) }
}
// 删除并销毁
tabbed.removeTabAt(index)
}
/**
* 返回失败表示没有创建成功
*/
fun addTransport(
source: JComponent,
sourceWorkdir: Path?,
target: FileSystemViewPanel,
targetWorkdir: Path?,
transport: Transport
): Boolean {
val sourcePanel = SwingUtilities.getAncestorOfClass(FileSystemViewPanel::class.java, source)
as? FileSystemViewPanel ?: return false
val targetPanel = target as? FileSystemViewPanel ?: return false
if (sourcePanel.isDisposed || targetPanel.isDisposed) return false
val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir()).absolutePathString()
val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir()).absolutePathString()
val targetFileSystem = targetPanel.fileSystem
val sourcePath = transport.source.absolutePathString()
transport.target = targetFileSystem.getPath(
myTargetWorkdir,
StringUtils.removeStart(sourcePath, mySourceWorkdir)
)
return transportManager.addTransport(transport)
}
fun canTransfer(source: JComponent): Boolean {
return getTarget(source) != null
}
fun getTarget(source: JComponent): FileSystemViewPanel? {
val sourceTabbed = SwingUtilities.getAncestorOfClass(SFTPTabbed::class.java, source)
as? SFTPTabbed ?: return null
val isLeft = sourceTabbed == leftComponent
val targetTabbed = if (isLeft) rightComponent else leftComponent
return targetTabbed.getSelectedFileSystemViewPanel()
}
/**
* 获取本地文件系统面板
*/
fun getLocalTarget(): FileSystemViewPanel {
return leftComponent.getFileSystemViewPanel(0) as FileSystemViewPanel
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return dataProviderSupport.getData(dataKey)
}
}

View File

@@ -0,0 +1,82 @@
package app.termora.sftp
import app.termora.*
import app.termora.terminal.DataKey
import java.beans.PropertyChangeListener
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
class SFTPTab : RememberFocusTerminalTab() {
private val sftpPanel = SFTPPanel()
private val sftp get() = Database.getDatabase().sftp
init {
Disposer.register(this, sftpPanel)
}
override fun getTitle(): String {
return "SFTP"
}
override fun getIcon(): Icon {
return Icons.folder
}
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
}
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
}
override fun canClose(): Boolean {
return !sftp.pinTab
}
override fun willBeClose(): Boolean {
if (!canClose()) return false
val transportManager = sftpPanel.getData(SFTPDataProviders.TransportManager) ?: return true
if (transportManager.getTransportCount() > 0) {
return OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(getJComponent()),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}
val leftTabbed = sftpPanel.getData(SFTPDataProviders.LeftSFTPTabbed) ?: return true
val rightTabbed = sftpPanel.getData(SFTPDataProviders.RightSFTPTabbed) ?: return true
if (hasActiveTab(leftTabbed) || hasActiveTab(rightTabbed)) {
return OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(getJComponent()),
I18n.getString("termora.transport.sftp.close-tab-has-active-session"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}
return true
}
private fun hasActiveTab(tabbed: SFTPTabbed): Boolean {
for (i in 0 until tabbed.tabCount) {
val c = tabbed.getFileSystemViewPanel(i) ?: continue
if (c.host.id != "local") {
return true
}
}
return false
}
override fun getJComponent(): JComponent {
return sftpPanel
}
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
return sftpPanel.getData(dataKey)
}
}

View File

@@ -0,0 +1,174 @@
package app.termora.sftp
import app.termora.*
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
import javax.swing.JButton
import javax.swing.JToolBar
import javax.swing.SwingUtilities
import javax.swing.UIManager
import kotlin.io.path.absolutePathString
import kotlin.math.max
@Suppress("DuplicatedCode")
class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPane(), Disposable {
private val addBtn = JButton(Icons.add)
private val tabbed = this
private val disposed = AtomicBoolean(false)
private val hostManager get() = HostManager.getInstance()
val isDisposed get() = disposed.get()
init {
initViews()
initEvents()
}
private fun initViews() {
super.setTabLayoutPolicy(SCROLL_TAB_LAYOUT)
super.setTabsClosable(true)
super.setTabType(TabType.underlined)
val toolbar = JToolBar()
toolbar.add(addBtn)
super.setTrailingComponent(toolbar)
}
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)
}
}
})
// 右键菜单
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (!SwingUtilities.isRightMouseButton(e)) {
return
}
val index = indexAtLocation(e.x, e.y)
if (index < 0) return
showContextMenu(index, e)
}
})
}
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
val panel = getFileSystemViewPanel(tabIndex) ?: return
val popupMenu = FlatPopupMenu()
// 克隆
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
clone.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val host = hostManager.getHost(panel.host.id) ?: return
addSFTPFileSystemViewPanelTab(
host.copy(
options = host.options.copy(
sftpDefaultDirectory = panel.getWorkdir().absolutePathString()
)
)
)
}
})
// 编辑
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
edit.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
if (panel.host.id == "local") {
return
}
val host = hostManager.getHost(panel.host.id) ?: return
val dialog = HostDialog(evt.window, host)
dialog.setLocationRelativeTo(evt.window)
dialog.isVisible = true
hostManager.addHost(dialog.host ?: return)
}
})
clone.isEnabled = panel.host.id != "local"
edit.isEnabled = clone.isEnabled
popupMenu.show(this, e.x, e.y)
}
fun addSFTPFileSystemViewPanelTab(host: Host) {
val panel = SFTPFileSystemViewPanel(host, transportManager)
addTab(host.name, panel)
panel.connect()
selectedIndex = tabCount - 1
}
/**
* 获取当前的 FileSystemViewPanel
*/
fun getSelectedFileSystemViewPanel(): FileSystemViewPanel? {
return getFileSystemViewPanel(selectedIndex)
}
fun getFileSystemViewPanel(index: Int): FileSystemViewPanel? {
if (tabCount < 1 || index < 0) return null
val c = getComponentAt(index)
if (c is FileSystemViewPanel) {
return c
}
if (c is SFTPFileSystemViewPanel) {
return c.getData(SFTPDataProviders.FileSystemViewPanel)
}
return null
}
override fun updateUI() {
styleMap = mapOf(
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
"hoverColor" to UIManager.getColor("TabbedPane.background"),
"tabHeight" to 30
)
super.updateUI()
}
override fun removeTabAt(index: Int) {
val c = getComponentAt(index)
if (c is Disposable) {
Disposer.dispose(c)
}
super.removeTabAt(index)
}
override fun dispose() {
if (disposed.compareAndSet(false, true)) {
while (tabCount > 0) removeTabAt(0)
}
}
}

View File

@@ -0,0 +1,56 @@
package app.termora.sftp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
class SpeedReporter(private val coroutineScope: CoroutineScope) {
companion object {
val millis = TimeUnit.MILLISECONDS.toMillis(500)
}
private val events = ConcurrentLinkedQueue<Triple<Transport, Long, Long>>()
init {
collect()
}
fun report(transport: Transport, bytes: Long, time: Long) {
events.add(Triple(transport, bytes, time))
}
private fun collect() {
// 异步上报数据
coroutineScope.launch {
while (coroutineScope.isActive) {
val time = System.currentTimeMillis()
val map = linkedMapOf<Transport, Long>()
// 收集
while (events.isNotEmpty() && events.peek().second < time) {
val (a, b) = events.poll()
map[a] = map.computeIfAbsent(a) { 0 } + b
}
if (map.isNotEmpty()) {
for ((a, b) in map) {
if (b > 0) {
reportTransferredFilesize(a, b, time)
}
}
}
delay(millis.milliseconds)
}
}
}
private fun reportTransferredFilesize(transport: Transport, bytes: Long, time: Long) {
transport.reportTransferredFilesize(bytes, time)
}
}

View File

@@ -0,0 +1,274 @@
package app.termora.sftp
import app.termora.Database
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.apache.commons.io.IOUtils
import org.apache.commons.net.io.Util
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.BasicFileAttributeView
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.name
enum class TransportStatus {
Ready,
Processing,
Failed,
Done,
}
/**
* 传输单位:单个文件
*/
class Transport(
/**
* 唯一 ID
*/
val id: Long = idGenerator.incrementAndGet(),
/**
* 是否是文件夹
*/
val isDirectory: Boolean = false,
/**
* 父
*/
val parentId: Long = 0,
/**
* 源
*/
val source: Path,
/**
* 目标
*/
var target: Path,
) {
companion object {
val idGenerator = AtomicLong(0)
private val exception = RuntimeException("Nothing")
private val log = LoggerFactory.getLogger(Transport::class.java)
private val isPreserveModificationTime get() = Database.getDatabase().sftp.preserveModificationTime
}
private val scanned by lazy { AtomicBoolean(false) }
/**
* 计数器
*/
private val counter by lazy { SlidingWindowByteCounter() }
/**
* 父
*/
var parent: Transport? = null
set(value) {
if (field != null) throw IllegalStateException("parent already exists")
field = value
// 上报大小
reportFilesize(filesize.get())
}
/**
* 文件大小,对于文件夹来说,文件大小是不确定的,它取决于文件夹下的文件
*/
val filesize = AtomicLong(0)
/**
* 已经传输完成的文件大小
*/
val transferredFilesize = AtomicLong(0)
/**
* 如果是文件夹,是否已经扫描完毕。如果已经扫描完毕,那么该文件夹传输完成后可以立即删除
*/
val isScanned get() = scanned.get()
val isFile = !isDirectory
val isRoot = parentId == 0L
/**
* 获取最近一秒内的速度
*/
val speed get() = counter.getLastSecondBytes()
/**
* 状态
*/
@Volatile
var status: TransportStatus = TransportStatus.Ready
private set
/**
* 失败异常
*/
var exception: Throwable = Transport.exception
fun scanned() {
scanned.compareAndSet(false, true)
}
fun changeStatus(status: TransportStatus): Boolean {
synchronized(this) {
if (status == TransportStatus.Processing) {
if (this.status != TransportStatus.Ready) {
return false
}
} else if (status == TransportStatus.Failed || status == TransportStatus.Done) {
if (this.status != TransportStatus.Ready && this.status != TransportStatus.Processing) {
return false
}
} else if (status == TransportStatus.Ready) {
if (this.status != TransportStatus.Ready) {
return false
}
}
this.status = status
return true
}
}
private val c = AtomicLong(0)
/**
* 开始传输
*/
suspend fun transport(reporter: SpeedReporter) {
if (isDirectory) {
withContext(Dispatchers.IO) {
try {
if (!target.exists()) {
target.createDirectories()
}
} catch (e: FileAlreadyExistsException) {
if (log.isWarnEnabled) {
log.warn("Directory ${target.name} already exists")
}
} catch (e: Exception) {
exception = e
throw e
}
}
return
}
withContext(Dispatchers.IO) {
val input = Files.newInputStream(source)
val output = Files.newOutputStream(target)
try {
val buff = ByteArray(Util.DEFAULT_COPY_BUFFER_SIZE)
var len: Int
while (input.read(buff).also { len = it } != -1 && this.isActive) {
// 写入
output.write(buff, 0, len)
val size = len.toLong()
val now = System.currentTimeMillis()
// 上报传输的字节数量
reporter.report(this@Transport, size, now)
// 如果状态错误,那么可能已经取消了
if (status != TransportStatus.Processing) {
throw TransportStatusException("status is $status")
}
}
} finally {
IOUtils.closeQuietly(input, output)
}
// 尝试修改时间
preserveModificationTime()
}
}
private fun preserveModificationTime() {
// 设置修改时间
if (isPreserveModificationTime) {
Files.getFileAttributeView(target, BasicFileAttributeView::class.java)
.setTimes(source.getLastModifiedTime(), source.getLastModifiedTime(), null)
}
}
/**
* 一层层上报文件大小
*/
fun reportFilesize(bytes: Long) {
val p = parent ?: return
if (isRoot) return
// 父状态不正常
if (p.status == TransportStatus.Failed) return
// 父的文件大小就是自己的文件大小
p.filesize.addAndGet(bytes)
// 递归上报
p.reportFilesize(bytes)
}
/**
* 一层层上报传输大小
*/
fun reportTransferredFilesize(bytes: Long, time: Long) {
var p = this as Transport?
while (p != null) {
// 记录上报的数量,用于统计速度
if (bytes > 0) p.counter.addBytes(bytes, time)
// 状态不正常
if (p.status == TransportStatus.Failed) return
// 父的传输文件大小就是自己的传输文件大小
p.transferredFilesize.addAndGet(bytes)
p = p.parent
c.incrementAndGet()
}
}
}
private class SlidingWindowByteCounter {
private val events = ConcurrentLinkedQueue<Pair<Long, Long>>()
private val oneSecondInMillis = TimeUnit.SECONDS.toMillis(1)
fun addBytes(bytes: Long, time: Long) {
// 添加当前事件
events.add(time to bytes)
// 移除过期事件(超过 1 秒的记录)
while (events.isNotEmpty() && events.peek().first < time - oneSecondInMillis) {
events.poll()
}
}
fun getLastSecondBytes(): Long {
val currentTime = System.currentTimeMillis()
// 累加最近 1 秒内的字节数
return events.filter { it.first >= currentTime - oneSecondInMillis }
.sumOf { it.second }
}
}

View File

@@ -0,0 +1,10 @@
package app.termora.sftp
import java.util.*
interface TransportListener : EventListener {
/**
* 状态变化
*/
fun onTransportChanged(transport: Transport) {}
}

View File

@@ -0,0 +1,11 @@
package app.termora.sftp
interface TransportManager {
fun addTransport(transport: Transport): Boolean
fun getTransport(id: Long): Transport?
fun getTransports(pId: Long): List<Transport>
fun getTransportCount(): Int
fun removeTransport(id: Long)
fun addTransportListener(listener: TransportListener)
fun removeTransportListener(listener: TransportListener)
}

View File

@@ -0,0 +1,3 @@
package app.termora.sftp
class TransportStatusException(message: String) : RuntimeException(message)

View File

@@ -0,0 +1,261 @@
package app.termora.sftp
import app.termora.Disposable
import app.termora.Disposer
import app.termora.I18n
import app.termora.OptionPane
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.util.SystemInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.jdesktop.swingx.JXTreeTable
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import java.awt.Component
import java.awt.Graphics
import java.awt.Insets
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.*
import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.tree.DefaultTreeCellRenderer
import kotlin.math.floor
import kotlin.time.Duration.Companion.milliseconds
@Suppress("DuplicatedCode")
class TransportTable : JXTreeTable(), Disposable {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val model = TransportTableModel(coroutineScope)
private val table = this
private val transportManager = model as TransportManager
init {
initViews()
initEvents()
}
private fun initViews() {
super.getTableHeader().setReorderingAllowed(false)
super.setTreeTableModel(model)
super.setRowHeight(UIManager.getInt("Table.rowHeight"))
super.setAutoResizeMode(JTable.AUTO_RESIZE_OFF)
super.setFillsViewportHeight(true)
super.putClientProperty(
FlatClientProperties.STYLE, mapOf(
"cellMargins" to Insets(0, 4, 0, 4),
"selectionArc" to 0,
)
)
super.setTreeCellRenderer(object : DefaultTreeCellRenderer() {
override fun getTreeCellRendererComponent(
tree: JTree?,
value: Any?,
sel: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean
): Component {
val node = value as DefaultMutableTreeTableNode
val transport = node.userObject as? Transport
val text = Objects.toString(node.getValueAt(TransportTableModel.COLUMN_NAME))
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
icon = if (transport?.isDirectory == true) NativeFileIcons.getFolderIcon()
else NativeFileIcons.getFileIcon(text)
return c
}
})
columnModel.getColumn(TransportTableModel.COLUMN_NAME).preferredWidth = 300
columnModel.getColumn(TransportTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200
columnModel.getColumn(TransportTableModel.COLUMN_TARGET_PATH).preferredWidth = 200
columnModel.getColumn(TransportTableModel.COLUMN_STATUS).preferredWidth = 100
columnModel.getColumn(TransportTableModel.COLUMN_PROGRESS).preferredWidth = 150
columnModel.getColumn(TransportTableModel.COLUMN_SIZE).preferredWidth = 140
columnModel.getColumn(TransportTableModel.COLUMN_SPEED).preferredWidth = 80
val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }
columnModel.getColumn(TransportTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer
columnModel.getColumn(TransportTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer
columnModel.getColumn(TransportTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer
columnModel.getColumn(TransportTableModel.COLUMN_ESTIMATED_TIME).cellRenderer = centerTableCellRenderer
columnModel.getColumn(TransportTableModel.COLUMN_PROGRESS).cellRenderer =
object : DefaultTableCellRenderer() {
private var progress = 0.0
private var progressInt = 0
private val padding = 4
init {
horizontalAlignment = SwingConstants.CENTER
}
override fun getTableCellRendererComponent(
table: JTable?,
value: Any?,
isSelected: Boolean,
hasFocus: Boolean,
row: Int,
column: Int
): Component {
this.progress = 0.0
this.progressInt = 0
if (value is Transport) {
if (value.status == TransportStatus.Processing) {
this.progress = value.transferredFilesize.get() * 1.0 / value.filesize.get()
this.progressInt = floor(progress * 100.0).toInt()
// 因为有一些 0B 大小的文件所以如果在进行中那么最大就是99
if (this.progress >= 1 && value.status == TransportStatus.Processing) {
this.progress = 0.99
this.progressInt = floor(progress * 100.0).toInt()
}
}
}
return super.getTableCellRendererComponent(
table,
"${progressInt}%",
isSelected,
hasFocus,
row,
column
)
}
override fun paintComponent(g: Graphics) {
// 原始背景
g.color = background
g.fillRect(0, 0, width, height)
// 进度条背景
g.color = UIManager.getColor("Table.selectionInactiveBackground")
g.fillRect(0, padding, width, height - padding * 2)
// 进度条颜色
g.color = UIManager.getColor("ProgressBar.foreground")
g.fillRect(0, padding, (width * progress).toInt(), height - padding * 2)
// 大于某个阀值的时候,就要改变颜色
if (progress >= 0.45) {
foreground = selectionForeground
}
// 绘制文字
ui.paint(g, this)
}
}
}
private fun initEvents() {
// contextmenu
table.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isRightMouseButton(e)) {
val r = table.rowAtPoint(e.point)
if (r >= 0 && r < table.rowCount) {
if (!table.isRowSelected(r)) {
table.setRowSelectionInterval(r, r)
}
} else {
table.clearSelection()
}
val rows = table.selectedRows
if (!table.hasFocus()) {
table.requestFocusInWindow()
}
showContextMenu(rows, e)
}
}
})
// 刷新状态
coroutineScope.launch(Dispatchers.Swing) { refreshView() }
// Delete key
table.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) {
val transports = selectedRows.map { getPathForRow(it).lastPathComponent }
.filterIsInstance<TransportTreeTableNode>().map { it.transport }
if (transports.isEmpty()) return
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(table),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
transports.forEach { transportManager.removeTransport(it.id) }
}
}
}
})
Disposer.register(this, model)
}
private fun showContextMenu(rows: IntArray, e: MouseEvent) {
val transports = rows.map { getPathForRow(it).lastPathComponent }
.filterIsInstance<TransportTreeTableNode>().map { it.transport }
val popupMenu = FlatPopupMenu()
val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete"))
val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all"))
delete.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
for (transport in transports) {
transportManager.removeTransport(transport.id)
}
}
}
deleteAll.addActionListener {
if (OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(this),
I18n.getString("termora.keymgr.delete-warning"),
messageType = JOptionPane.WARNING_MESSAGE
) == JOptionPane.YES_OPTION
) {
transportManager.removeTransport(0)
}
}
delete.isEnabled = transports.isNotEmpty()
popupMenu.show(this, e.x, e.y)
}
private suspend fun refreshView() {
while (coroutineScope.isActive) {
for (row in 0 until rowCount) {
val treePath = getPathForRow(row) ?: continue
val node = treePath.lastPathComponent as? TransportTreeTableNode ?: continue
model.valueForPathChanged(treePath, node.transport)
}
delay(SpeedReporter.millis.milliseconds)
}
}
override fun dispose() {
coroutineScope.cancel()
}
}

View File

@@ -0,0 +1,448 @@
package app.termora.sftp
import app.termora.Disposable
import app.termora.I18n
import app.termora.assertEventDispatchThread
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import okio.withLock
import org.apache.commons.lang3.ArrayUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import org.jdesktop.swingx.treetable.DefaultTreeTableModel
import org.jdesktop.swingx.treetable.MutableTreeTableNode
import org.slf4j.LoggerFactory
import java.util.concurrent.locks.ReentrantLock
import javax.swing.SwingUtilities
import kotlin.io.path.name
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
class TransportTableModel(private val coroutineScope: CoroutineScope) :
DefaultTreeTableModel(DefaultMutableTreeTableNode()), TransportManager, Disposable {
val lock = ReentrantLock()
private val transports = linkedMapOf<Long, TransportTreeTableNode>()
private val reporter = SpeedReporter(coroutineScope)
private var listeners = emptyArray<TransportListener>()
private val activeTransports = linkedMapOf<Long, Job>()
/**
* 最多的平行任务
*/
private val maxParallels = max(min(Runtime.getRuntime().availableProcessors(), 4), 1)
companion object {
private val log = LoggerFactory.getLogger(TransportTableModel::class.java)
const val COLUMN_COUNT = 8
const val COLUMN_NAME = 0
const val COLUMN_STATUS = 1
const val COLUMN_PROGRESS = 2
const val COLUMN_SIZE = 3
const val COLUMN_SOURCE_PATH = 4
const val COLUMN_TARGET_PATH = 5
const val COLUMN_SPEED = 6
const val COLUMN_ESTIMATED_TIME = 7
}
init {
setColumnIdentifiers(
listOf(
I18n.getString("termora.transport.jobs.table.name"),
I18n.getString("termora.transport.jobs.table.status"),
I18n.getString("termora.transport.jobs.table.progress"),
I18n.getString("termora.transport.jobs.table.size"),
I18n.getString("termora.transport.jobs.table.source-path"),
I18n.getString("termora.transport.jobs.table.target-path"),
I18n.getString("termora.transport.jobs.table.speed"),
I18n.getString("termora.transport.jobs.table.estimated-time")
)
)
coroutineScope.launch { run() }
}
override fun getRoot(): DefaultMutableTreeTableNode {
return super.getRoot() as DefaultMutableTreeTableNode
}
override fun isCellEditable(node: Any?, column: Int): Boolean {
return false
}
override fun addTransport(transport: Transport): Boolean {
return lock.withLock {
if (!transport.isRoot) {
// 判断父是否存在
if (!transports.containsKey(transport.parentId)) {
return@withLock false
}
// 检测状态
if (!validGrandfatherStatus(transport)) {
changeStatus(transport, TransportStatus.Failed)
}
}
val newNode = TransportTreeTableNode(transport)
val parentId = transport.parentId
val root = getRoot()
val p = if (parentId == 0L || !transports.contains(parentId)) {
root
} else {
transports.getValue(transport.parentId).apply { transport.parent = this.transport }
}
transports[transport.id] = newNode
if ((transports.containsKey(parentId) || p == root) && transports.containsKey(transport.id)) {
// 同步加入节点
SwingUtilities.invokeLater { insertNodeInto(newNode, p, p.childCount) }
}
return@withLock true
}
}
override fun getTransport(id: Long): Transport? {
return transports[id]?.transport
}
override fun getTransports(pId: Long): List<Transport> {
lock.withLock {
if (pId == 0L) {
return getRoot().children().toList().filterIsInstance<TransportTreeTableNode>()
.map { it.transport }
}
val p = transports[pId] ?: return emptyList()
return p.children().toList().filterIsInstance<TransportTreeTableNode>()
.map { it.transport }
}
}
override fun getTransportCount(): Int {
return transports.size
}
/**
* 获取祖先的状态,如果祖先状态不正常,那么子直接定义为失败
*
* @return true 正常
*/
private fun validGrandfatherStatus(transport: Transport): Boolean {
lock.withLock {
// 如果自己/父不正常,那么失败
if (transport.isRoot) return transport.status != TransportStatus.Failed
// 父不存在,那么直接定义失败
val p = transports[transport.parentId] ?: return false
// 父状态不正常,那么失败
if (p.transport.status == TransportStatus.Failed) return false
return validGrandfatherStatus(p.transport)
}
}
override fun removeTransport(id: Long) {
assertEventDispatchThread()
lock.withLock {
// ID 为空就是清空
if (id <= 0) {
// 定义为失败
transports.forEach { changeStatus(it.value.transport, TransportStatus.Failed) }
// 清除所有任务
transports.clear()
// 取消任务
activeTransports.forEach { it.value.cancel() }
activeTransports.clear()
val root = getRoot()
while (root.childCount > 0) {
val c = root.getChildAt(0)
if (c is MutableTreeTableNode) {
removeNodeFromParent(c)
}
}
return
}
val n = transports[id] ?: return
val deletedIds = mutableListOf<Long>()
n.visit { deletedIds.add(it.transport.id) }
deletedIds.add(id)
for (deletedId in deletedIds) {
val node = transports[deletedId] ?: continue
// 定义为失败
changeStatus(node.transport, TransportStatus.Failed)
if (deletedId == id) {
val p = if (node.transport.isRoot) root else transports[node.transport.parentId]
if (p != null) {
removeNodeFromParent(node)
}
}
// 尝试取消
activeTransports[deletedId]?.cancel()
transports.remove(deletedId)
}
// 如果不是成功,那么就是人工手动删除
if (n.transport.status != TransportStatus.Done) {
// 文件大小减去尚未传输的
n.transport.reportFilesize(-abs((n.transport.filesize.get() - n.transport.transferredFilesize.get())))
}
}
}
override fun addTransportListener(listener: TransportListener) {
listeners += listener
}
override fun removeTransportListener(listener: TransportListener) {
listeners = ArrayUtils.removeElement(listeners, listener)
}
private suspend fun run() {
while (coroutineScope.isActive) {
val nodes = getReadyTransport()
if (nodes.isEmpty()) {
delay((Random.nextInt(100, 250)).milliseconds)
continue
}
// pre process
val readyNodes = mutableListOf<TransportTreeTableNode>()
for (node in nodes) {
val transport = node.transport
// 因为有可能返回刚刚清理的 Transport如果不返回清理的 Transport 那么就只能返回 null返回null就要等待 N 毫秒
if (transport.status != TransportStatus.Ready) continue
// 如果祖先状态异常,那么直接定义为失败
if (!validGrandfatherStatus(transport)) {
changeStatus(transport, TransportStatus.Failed)
continue
}
// 进行中
if (!changeStatus(transport, TransportStatus.Processing)) continue
// 能走到这里表示准备好的任务
readyNodes.add(node)
}
// 如果没有准备好的节点,那么跳过
if (readyNodes.isEmpty()) continue
// 激活中的任务
val activeTransports = mutableMapOf<Long, Job>()
// 同步传输
for (node in readyNodes) {
val transport = node.transport
activeTransports[transport.id] = coroutineScope.launch { doTransport(node) }
}
// 设置为全局的
lock.withLock {
this.activeTransports.forEach { it.value.cancel() }
this.activeTransports.clear()
this.activeTransports.putAll(activeTransports)
}
try {
// 等待所有任务
activeTransports.values.joinAll()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
private suspend fun doTransport(node: TransportTreeTableNode) {
val transport = node.transport
try {
// 传输
transport.transport(reporter)
// 变更状态,文件夹不需要变更状态,因为当文件夹下所有文件都成功时,文件夹自然会成功
if (transport.isFile) {
changeStatus(transport, TransportStatus.Done)
}
} catch (e: Exception) {
// 记录异常
transport.exception = ExceptionUtils.getRootCause(e)
if (e is TransportStatusException) {
if (log.isWarnEnabled) {
log.warn("{}: {}", transport.source.name, e.message)
}
} else if (log.isErrorEnabled) {
log.error(e.message, e)
}
// 定义为失败
changeStatus(transport, TransportStatus.Failed)
} finally {
// 从激活中移除
if (lock.tryLock()) {
try {
activeTransports.remove(transport.id)
} finally {
lock.unlock()
}
}
// 安全删除
if (transport.status == TransportStatus.Done) {
safeRemoveTransport(node)
}
}
}
private fun fireTransportEvent(transport: Transport) {
for (listener in listeners) {
listener.onTransportChanged(transport)
}
}
private suspend fun safeRemoveTransport(node: TransportTreeTableNode) {
withContext(Dispatchers.Swing) {
lock.withLock {
var n = node as TransportTreeTableNode?
while (n != null) {
// 如果还有子,跳过
if (n.childCount != 0) break
// 如果文件夹还没扫描完,那么不处理
if (n.transport.isDirectory && !n.transport.isScanned) break
// 提前保存一下父
val p = n.parent as? TransportTreeTableNode
// 设置成功
changeStatus(n.transport, TransportStatus.Done)
// 删除
removeTransport(n.transport.id)
// 继续向上查找
n = p
}
}
}
}
private suspend fun getReadyTransport(): List<TransportTreeTableNode> {
val nodes = mutableListOf<TransportTreeTableNode>()
val removeNodes = mutableListOf<TransportTreeTableNode>()
lock.withLock {
val stack = ArrayDeque<TransportTreeTableNode>()
val root = getRoot()
for (i in root.childCount - 1 downTo 0) {
val child = root.getChildAt(i)
if (child is TransportTreeTableNode) {
stack.addLast(child)
}
}
while (stack.isNotEmpty()) {
val node = stack.removeLast()
val transport = node.transport
// 如果父已经失败,那么自己也定义为失败,之所以定义失败要走下去是因为它的子也要定义为失败
if (transport.parent?.status == TransportStatus.Failed) {
changeStatus(transport, TransportStatus.Failed)
}
// 这是一个比较特殊的情况,因为传输任务和文件夹扫描并不是一个线程。
// 如果该文件夹最后一个任务传输任务完成后(已经尝试清理)这时候
// 因为还没有“定义为扫描完毕”那么清理任务就会认为还在扫描,但是已经
// 扫描完了,所以这里要执行一次清理。
if (transport.isDirectory && transport.status == TransportStatus.Processing) {
if (node.childCount == 0 && transport.isScanned) {
removeNodes.add(node)
break
}
}
if (transport.status == TransportStatus.Ready) {
if (transport.isDirectory) {
// 文件夹不允许和文件作为并行任务
if (nodes.isNotEmpty()) break
// 加入任务立即退出
nodes.add(node)
break
} else if (transport.isFile) {
// 如果要准备加入的并行任务不是一个父,那么不允许
if (nodes.isNotEmpty() && nodes.last().transport.parentId != transport.parentId) break
// 加入任务
nodes.add(node)
// 如果超出了最大
if (nodes.size >= maxParallels) break
}
}
// 文件不可能有子
if (transport.isFile) {
continue
}
for (i in node.childCount - 1 downTo 0) {
val child = node.getChildAt(i)
if (child is TransportTreeTableNode) {
stack.addLast(child)
}
}
}
}
// 如果有要清理的节点,那么直接返回清理的节点
if (removeNodes.isNotEmpty()) {
removeNodes.forEach { safeRemoveTransport(it) }
return removeNodes
}
return nodes
}
private fun changeStatus(transport: Transport, status: TransportStatus): Boolean {
return transport.changeStatus(status).apply { if (this) fireTransportEvent(transport) }
}
override fun dispose() {
lock.withLock {
// remove all
removeTransport(0L)
coroutineScope.cancel()
}
}
}

View File

@@ -0,0 +1,72 @@
package app.termora.sftp
import app.termora.I18n
import app.termora.formatBytes
import app.termora.formatSeconds
import org.apache.commons.io.file.PathUtils
import org.apache.sshd.sftp.client.fs.SftpFileSystem
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
import java.nio.file.Path
import kotlin.io.path.absolutePathString
class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) {
val transport get() = userObject as Transport
override fun getValueAt(column: Int): Any {
val isProcessing = transport.status == TransportStatus.Processing
val speed = if (isProcessing) transport.speed else 0
val estimatedTime = if (isProcessing && speed > 0)
(transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0
return when (column) {
TransportTableModel.COLUMN_NAME -> PathUtils.getFileNameString(transport.source)
TransportTableModel.COLUMN_STATUS -> formatStatus(transport)
TransportTableModel.COLUMN_SIZE -> size()
TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-"
TransportTableModel.COLUMN_ESTIMATED_TIME -> if (isProcessing) formatSeconds(estimatedTime) else "-"
TransportTableModel.COLUMN_SOURCE_PATH -> formatPath(transport.source)
TransportTableModel.COLUMN_TARGET_PATH -> formatPath(transport.target)
else -> super.getValueAt(column)
}
}
private fun formatPath(path: Path): String {
if (path.fileSystem.isSFTP()) {
val hostname = ((path.fileSystem as SftpFileSystem).session as JGitClientSession).hostConfigEntry.hostName
return hostname + ":" + path.absolutePathString()
}
return path.toUri().scheme + ":" + path.absolutePathString()
}
private fun formatStatus(transport: Transport): String {
return when (transport.status) {
TransportStatus.Processing -> I18n.getString("termora.transport.sftp.status.transporting")
TransportStatus.Ready -> I18n.getString("termora.transport.sftp.status.waiting")
TransportStatus.Done -> I18n.getString("termora.transport.sftp.status.done")
TransportStatus.Failed -> I18n.getString("termora.transport.sftp.status.failed") + ": " + transport.exception.message
}
}
private fun size(): String {
val transferredFilesize = transport.transferredFilesize.get()
val filesize = transport.filesize.get()
if (transferredFilesize <= 0) return formatBytes(filesize)
return "${formatBytes(transferredFilesize)}/${formatBytes(filesize)}"
}
override fun getColumnCount(): Int {
return TransportTableModel.COLUMN_COUNT
}
fun visit(consumer: (TransportTreeTableNode) -> Unit) {
if (childCount == 0) return
for (child in children()) {
if (child is TransportTreeTableNode) {
child.visit(consumer)
consumer.invoke(child)
}
}
}
}

View File

@@ -6,8 +6,7 @@ import app.termora.Icons
import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey
import app.termora.terminal.Terminal
import app.termora.terminal.panel.TerminalWriter
class SnippetAction private constructor() : AnAction(I18n.getString("termora.snippet.title"), Icons.codeSpan) {
companion object {
@@ -23,9 +22,8 @@ class SnippetAction private constructor() : AnAction(I18n.getString("termora.sni
}
fun runSnippet(snippet: Snippet, terminal: Terminal) {
fun runSnippet(snippet: Snippet, writer: TerminalWriter) {
if (snippet.type != SnippetType.Snippet) return
val terminalModel = terminal.getTerminalModel()
val map = mapOf(
"\\r" to ControlCharacters.CR,
"\\n" to ControlCharacters.LF,
@@ -35,13 +33,10 @@ class SnippetAction private constructor() : AnAction(I18n.getString("termora.sni
"\\b" to ControlCharacters.BS,
)
if (terminalModel.hasData(DataKey.PtyConnector)) {
var text = snippet.snippet
for (e in map.entries) {
text = text.replace(e.key, e.value.toString())
}
val ptyConnector = terminalModel.getData(DataKey.PtyConnector)
ptyConnector.write(text.toByteArray(ptyConnector.getCharset()))
var text = snippet.snippet
for (e in map.entries) {
text = text.replace(e.key, e.value.toString())
}
writer.write(TerminalWriter.WriteRequest.fromBytes(text.toByteArray(writer.getCharset())))
}
}

View File

@@ -4,9 +4,12 @@ import app.termora.*
import org.apache.commons.lang3.StringUtils
import java.awt.Dimension
import java.awt.Window
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.BorderFactory
import javax.swing.JComponent
import javax.swing.JScrollPane
import javax.swing.SwingUtilities
class SnippetTreeDialog(owner: Window) : DialogWrapper(owner) {
private val snippetTree = SnippetTree()
@@ -23,6 +26,15 @@ class SnippetTreeDialog(owner: Window) : DialogWrapper(owner) {
setLocationRelativeTo(null)
init()
snippetTree.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val node = snippetTree.getLastSelectedPathNode() ?: return
if (node.isFolder) return
doOKAction()
}
}
})
Disposer.register(disposable, object : Disposable {
override fun dispose() {

View File

@@ -58,11 +58,10 @@ open class ColorPaletteImpl(private val terminal: Terminal) : ColorPalette {
for (g in 0 until 6) {
for (b in 0 until 6) {
val idx = 36 * r + 6 * g + b
xterm256Colors[idx] = Color(
getCubeColorValue(r),
getCubeColorValue(g),
getCubeColorValue(b),
).rgb
val x = getCubeColorValue(r)
val y = getCubeColorValue(g)
val z = getCubeColorValue(b)
xterm256Colors[idx] = 65536 * x + 256 * y + z
}
}
}
@@ -70,7 +69,7 @@ open class ColorPaletteImpl(private val terminal: Terminal) : ColorPalette {
for (gray in 0..23) {
val a = 10 * gray + 8
val idx = 216 + gray
xterm256Colors[idx] = Color(a, a, a).rgb
xterm256Colors[idx] = 65536 * a + 256 * a + a
}
}

View File

@@ -1,6 +1,7 @@
package app.termora.terminal
import app.termora.assertEventDispatchThread
import app.termora.terminal.panel.TerminalWriter
import org.slf4j.LoggerFactory
import kotlin.math.max
import kotlin.math.min
@@ -476,20 +477,20 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
return
}
if (!terminalModel.hasData(DataKey.PtyConnector)) {
if (!terminalModel.hasData(DataKey.TerminalWriter)) {
return
}
val ptyConnector = terminalModel.getData(DataKey.PtyConnector)
val writer = terminalModel.getData(DataKey.TerminalWriter)
val m = args.first()
if (m == '6') {
val position = terminal.getCursorModel().getPosition()
val bytes = "${ControlCharacters.ESC}[${position.y};${position.x}R".toByteArray(ptyConnector.getCharset())
ptyConnector.write(bytes)
val bytes = "${ControlCharacters.ESC}[${position.y};${position.x}R".toByteArray(writer.getCharset())
writer.write(TerminalWriter.WriteRequest.fromBytes(bytes))
} else if (m == '5') {
val bytes = "${ControlCharacters.ESC}[0n".toByteArray(ptyConnector.getCharset())
ptyConnector.write(bytes)
val bytes = "${ControlCharacters.ESC}[0n".toByteArray(writer.getCharset())
writer.write(TerminalWriter.WriteRequest.fromBytes(bytes))
}
}
@@ -896,7 +897,6 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
}
}
}
break
}
// foreground default
@@ -936,7 +936,6 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
}
}
}
break
}
// background default

View File

@@ -189,9 +189,9 @@ class DataKey<T : Any>(val clazz: KClass<T>) {
val CursorStyle = DataKey(app.termora.terminal.CursorStyle::class)
/**
* Pty Connector
* TerminalWriter
*/
val PtyConnector = DataKey(app.termora.terminal.PtyConnector::class)
val TerminalWriter = DataKey(app.termora.terminal.panel.TerminalWriter::class)
}
}

View File

@@ -14,6 +14,11 @@ open class DocumentImpl(private val terminal: Terminal) : Document {
companion object {
private val log = LoggerFactory.getLogger(DocumentImpl::class.java)
/**
* 超出的行数
*/
val OverflowLines = DataKey(Int::class)
}
init {

View File

@@ -4,6 +4,20 @@ package app.termora.terminal
open class MarkupModelImpl(private val terminal: Terminal) : MarkupModel {
private val highlighters = mutableMapOf<Int, MutableList<Highlighter>>()
init {
terminal.getTerminalModel().addDataListener(object : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
if (key != DocumentImpl.OverflowLines) return
if (highlighters.isEmpty()) return
val row = data as Int
// 因为第 N 行被删除了,所以这里要删除这一行的荧光笔
for (i in 0 until row) {
terminal.getMarkupModel().removeAllHighlightersInLine(0)
}
}
})
}
override fun addHighlighter(highlighter: Highlighter) {
val range = highlighter.getHighlighterRange()
highlighters.getOrPut(range.start.y) { mutableListOf() }.addLast(highlighter)

View File

@@ -32,6 +32,34 @@ open class SelectionModelImpl(private val terminal: Terminal) : SelectionModel {
}
}
init {
terminal.getTerminalModel().addDataListener(object : DataListener {
private val cols get() = terminal.getTerminalModel().getCols()
override fun onChanged(key: DataKey<*>, data: Any) {
if (key != DocumentImpl.OverflowLines) return
if (!hasSelection() || isSelectAll()) return
val row = data as Int
val startPosition = startPosition.copy(y = max(startPosition.y - row, 1))
val endPosition = endPosition.copy(y = endPosition.y - row)
if (endPosition.y < 1 || endPosition.y < startPosition.y) {
clearSelection()
return
}
// 设置新的选择区域
setSelection(startPosition, endPosition)
}
private fun isSelectAll(): Boolean {
return hasSelection() &&
startPosition.y == 1 && startPosition.x == 1 &&
endPosition.y == document.getLineCount() && endPosition.x == cols
}
})
}
override fun getSelectedText(): String {
val sb = StringBuilder()

View File

@@ -86,11 +86,6 @@ class TerminalLine {
if (chars.size() > i) {
if (chars.get(i).isNull) {
chars.set(i, Char.Space)
// 如果等于默认,那么替换成当前的样式
// 如果不是默认,那么不需要替换样式
if (styles.getTextStyle(i) == TextStyle.Default) {
styles.set(i, buffer.style)
}
}
} else {
break

View File

@@ -61,8 +61,8 @@ class TerminalLineBuffer(
if (!resizing) {
while (size > terminalModel.getMaxRows()) {
removeFirst()
// 因为第一行被删除了,所以这里要删除这一行的荧光笔
terminal.getMarkupModel().removeAllHighlightersInLine(0)
// 超出的行数,前 N 行已经被清理
terminal.getTerminalModel().setData(DocumentImpl.OverflowLines, 1)
}
}
}

View File

@@ -4,7 +4,7 @@ import java.util.*
@Suppress("MemberVisibilityCanBePrivate")
class TerminalReader {
private val buffer = LinkedList<Char>()
private val buffer = ArrayDeque<Char>()
fun addLast(char: Char) {
@@ -12,7 +12,9 @@ class TerminalReader {
}
fun addFirst(chars: List<Char>) {
buffer.addAll(0, chars)
for (i in chars.size - 1 downTo 0) {
addFirst(chars[i])
}
}
@@ -25,7 +27,7 @@ class TerminalReader {
}
fun addLast(text: String) {
text.toCharArray().forEach { addLast(it) }
text.forEach { addLast(it) }
}
fun read(): Char {

View File

@@ -41,13 +41,13 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
actionListeners.forEach { it.actionPerformed(evt) }
if (isSelected) {
TerminalPanelFactory.getAllTerminalPanel().forEach {
TerminalPanelFactory.getInstance().getTerminalPanels().forEach {
it.getData(FloatingToolbar)?.triggerShow()
}
} else {
// 触发者的不隐藏
val c = evt.getData(FloatingToolbar)
TerminalPanelFactory.getAllTerminalPanel().forEach {
TerminalPanelFactory.getInstance().getTerminalPanels().forEach {
val e = it.getData(FloatingToolbar)
if (c != e) {
e?.triggerHide()
@@ -157,13 +157,13 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable {
btn.addActionListener(object : AnAction() {
override fun actionPerformed(evt: AnActionEvent) {
val tab = evt.getData(DataProviders.TerminalTab) ?: return
val terminal = tab.getData(DataProviders.Terminal) ?: 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, terminal)
SnippetAction.getInstance().runSnippet(node.data, writer)
}
})
return btn

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