mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 18:32:58 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c26e3d08a | ||
|
|
9b84fb4ec8 | ||
|
|
d8ec7b6d4a | ||
|
|
769c0d990b | ||
|
|
3f1ae38b61 | ||
|
|
e10fce21a2 | ||
|
|
a00557bb9d | ||
|
|
e478535ae5 | ||
|
|
7756758738 | ||
|
|
e0ea42faee | ||
|
|
e72c6b77b5 | ||
|
|
bcd3aacd6f | ||
|
|
570b0e08ad | ||
|
|
d703850e87 | ||
|
|
4bb1a411e8 | ||
|
|
9884ed19fa | ||
|
|
1ffaed3f36 | ||
|
|
4cb42953ad | ||
|
|
0248992dc3 | ||
|
|
9bab9db875 | ||
|
|
b283a3ea38 | ||
|
|
98ac2928b4 | ||
|
|
a0a6f43c10 | ||
|
|
0c158acbe0 | ||
|
|
9a97b3a304 | ||
|
|
aef44bd0da | ||
|
|
75c65d9ba8 | ||
|
|
93755db77f | ||
|
|
79d0a9a348 |
1
.github/workflows/windows-x86-64.yml
vendored
1
.github/workflows/windows-x86-64.yml
vendored
@@ -45,5 +45,4 @@ jobs:
|
||||
name: termora-windows-x86-64
|
||||
path: |
|
||||
build/distributions/*.zip
|
||||
build/distributions/*.msi
|
||||
build/distributions/*.exe
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
src/main/java/app/termora/MyKernel32.java
Normal file
38
src/main/java/app/termora/MyKernel32.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,6 @@ package app.termora
|
||||
|
||||
object Actions {
|
||||
|
||||
|
||||
/**
|
||||
* 将命令发送到多个会话
|
||||
*/
|
||||
const val MULTIPLE = "MultipleAction"
|
||||
|
||||
/**
|
||||
* 关键词高亮
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
91
src/main/kotlin/app/termora/ApplicationInitializr.kt
Normal file
91
src/main/kotlin/app/termora/ApplicationInitializr.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
201
src/main/kotlin/app/termora/ApplicationSingleton.kt
Normal file
201
src/main/kotlin/app/termora/ApplicationSingleton.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,24 +82,42 @@ 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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rootPanel.add(createCenterPanel(), BorderLayout.CENTER)
|
||||
val customTitleBar = this.customTitleBar
|
||||
if (customTitleBar != null) {
|
||||
customTitleBar.putProperty("controls.visible", controlsVisible)
|
||||
customTitleBar.height = titleBarHeight.toFloat()
|
||||
JBR.getWindowDecorations().setCustomTitleBar(this, customTitleBar)
|
||||
}
|
||||
}
|
||||
|
||||
val southPanel = createSouthPanel()
|
||||
if (southPanel != null) {
|
||||
@@ -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 addNotify() {
|
||||
super.addNotify()
|
||||
|
||||
// 显示后触发一次重绘制
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
this.controlsVisible = controlsVisible
|
||||
this.titleBarHeight = titleBarHeight
|
||||
this.titleIconVisible = titleIconVisible
|
||||
this.titleVisible = titleVisible
|
||||
this.fullWindowContent = fullWindowContent
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun doOKAction() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -132,6 +132,11 @@ data class Options(
|
||||
* 串口配置
|
||||
*/
|
||||
val serialComm: SerialComm = SerialComm(),
|
||||
|
||||
/**
|
||||
* SFTP 默认目录
|
||||
*/
|
||||
val sftpDefaultDirectory: String = StringUtils.EMPTY,
|
||||
) {
|
||||
companion object {
|
||||
val Default = Options()
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
11
src/main/kotlin/app/termora/MyFlatRootPaneUI.kt
Normal file
11
src/main/kotlin/app/termora/MyFlatRootPaneUI.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
41
src/main/kotlin/app/termora/NativeStringComparator.kt
Normal file
41
src/main/kotlin/app/termora/NativeStringComparator.kt
Normal 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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
// 选择文件
|
||||
if (type != ImportType.SSH) {
|
||||
val code = chooser.showOpenDialog(owner)
|
||||
|
||||
if (code != JFileChooser.APPROVE_OPTION) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val file = chooser.selectedFile
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
val height = UIManager.getInt("TabbedPane.tabHeight") - 10
|
||||
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 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
16
src/main/kotlin/app/termora/RememberFocusTerminalTab.kt
Normal file
16
src/main/kotlin/app/termora/RememberFocusTerminalTab.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,11 +408,9 @@ 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,27 +1358,55 @@ 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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private fun initView() {
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) {
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,32 +336,9 @@ 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()
|
||||
}
|
||||
|
||||
override fun acceptModifiedServerKey(
|
||||
clientSession: ClientSession?,
|
||||
remoteAddress: SocketAddress?,
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -43,6 +43,8 @@ interface TerminalTab : Disposable, DataProvider {
|
||||
*/
|
||||
fun canClose(): Boolean = true
|
||||
|
||||
fun willBeClose(): Boolean = true
|
||||
|
||||
/**
|
||||
* 是否可以克隆
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -7,4 +7,5 @@ interface TerminalTabbedManager {
|
||||
fun getTerminalTabs(): List<TerminalTab>
|
||||
fun setSelectedTerminalTab(tab: TerminalTab)
|
||||
fun closeTerminalTab(tab: TerminalTab, disposable: Boolean = true)
|
||||
fun refreshTerminalTabs()
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
forceHitTest()
|
||||
|
||||
// macos 需要判断是否全部删除
|
||||
// 当 Tab 为 0 的时候,需要加一个边距,避开控制栏
|
||||
if (SystemInfo.isMacOS && isWindowDecorationsSupported) {
|
||||
tabbedPane.addChangeListener {
|
||||
tabbedPane.leadingComponent = if (tabbedPane.tabCount == 0) {
|
||||
Box.createHorizontalStrut(titleBar.leftInset.toInt())
|
||||
} else {
|
||||
null
|
||||
if (SystemInfo.isLinux) {
|
||||
val mouseAdapter = object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
getMouseHandler()?.mouseClicked(e)
|
||||
}
|
||||
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
getMouseHandler()?.mousePressed(e)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
// 监听主题变化 需要动态修改控制栏颜色
|
||||
if (SystemInfo.isWindows && isWindowDecorationsSupported) {
|
||||
ThemeManager.getInstance().addThemeChangeListener(object : ThemeChangeListener {
|
||||
override fun onChanged() {
|
||||
titleBar.putProperty("controls.dark", FlatLaf.isLafDark())
|
||||
}
|
||||
})
|
||||
}
|
||||
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)
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class OpenLocalTerminalAction : AnAction(
|
||||
OpenHostActionEvent(
|
||||
evt.source,
|
||||
Host(
|
||||
id = "local",
|
||||
name = name,
|
||||
protocol = Protocol.Local
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
51
src/main/kotlin/app/termora/native/osx/NativeMacLibrary.kt
Normal file
51
src/main/kotlin/app/termora/native/osx/NativeMacLibrary.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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(
|
||||
for (listener in actionListeners) {
|
||||
listener.actionPerformed(
|
||||
ActionEvent(
|
||||
this@BookmarkButton,
|
||||
button,
|
||||
ActionEvent.ACTION_PERFORMED,
|
||||
StringUtils.EMPTY
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showBookmarks(e)
|
||||
}
|
||||
@@ -80,9 +78,10 @@ class BookmarkButton : JButton(Icons.bookmarks) {
|
||||
popupMenu.addSeparator()
|
||||
for (bookmark in bookmarks) {
|
||||
popupMenu.add(bookmark).addActionListener {
|
||||
super@BookmarkButton.fireActionPerformed(
|
||||
for (listener in actionListeners) {
|
||||
listener.actionPerformed(
|
||||
ActionEvent(
|
||||
this@BookmarkButton,
|
||||
button,
|
||||
ActionEvent.ACTION_PERFORMED,
|
||||
bookmark
|
||||
)
|
||||
@@ -90,6 +89,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.termora.transport
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.DynamicColor
|
||||
266
src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt
Normal file
266
src/main/kotlin/app/termora/sftp/FileSystemViewNav.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
468
src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt
Normal file
468
src/main/kotlin/app/termora/sftp/FileSystemViewPanel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
838
src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt
Normal file
838
src/main/kotlin/app/termora/sftp/FileSystemViewTable.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
273
src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt
Normal file
273
src/main/kotlin/app/termora/sftp/FileSystemViewTableModel.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
81
src/main/kotlin/app/termora/sftp/NativeFileIcons.kt
Normal file
81
src/main/kotlin/app/termora/sftp/NativeFileIcons.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.termora.transport
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.I18n
|
||||
62
src/main/kotlin/app/termora/sftp/SFTPAction.kt
Normal file
62
src/main/kotlin/app/termora/sftp/SFTPAction.kt
Normal 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)
|
||||
|
||||
}
|
||||
}
|
||||
11
src/main/kotlin/app/termora/sftp/SFTPActionEvent.kt
Normal file
11
src/main/kotlin/app/termora/sftp/SFTPActionEvent.kt
Normal 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)
|
||||
12
src/main/kotlin/app/termora/sftp/SFTPDataProviders.kt
Normal file
12
src/main/kotlin/app/termora/sftp/SFTPDataProviders.kt
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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()
|
||||
TreeUtils.loadExpansionState(tree, properties.getString("SFTPTabbed.Tree.state", StringUtils.EMPTY))
|
||||
}
|
||||
}).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
isFocusable = false
|
||||
}).xy(2, 6)
|
||||
add(builder.build(), BorderLayout.CENTER)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
215
src/main/kotlin/app/termora/sftp/SFTPPanel.kt
Normal file
215
src/main/kotlin/app/termora/sftp/SFTPPanel.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
82
src/main/kotlin/app/termora/sftp/SFTPTab.kt
Normal file
82
src/main/kotlin/app/termora/sftp/SFTPTab.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
174
src/main/kotlin/app/termora/sftp/SFTPTabbed.kt
Normal file
174
src/main/kotlin/app/termora/sftp/SFTPTabbed.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/main/kotlin/app/termora/sftp/SpeedReporter.kt
Normal file
56
src/main/kotlin/app/termora/sftp/SpeedReporter.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
274
src/main/kotlin/app/termora/sftp/Transport.kt
Normal file
274
src/main/kotlin/app/termora/sftp/Transport.kt
Normal 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 }
|
||||
}
|
||||
|
||||
}
|
||||
10
src/main/kotlin/app/termora/sftp/TransportListener.kt
Normal file
10
src/main/kotlin/app/termora/sftp/TransportListener.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import java.util.*
|
||||
|
||||
interface TransportListener : EventListener {
|
||||
/**
|
||||
* 状态变化
|
||||
*/
|
||||
fun onTransportChanged(transport: Transport) {}
|
||||
}
|
||||
11
src/main/kotlin/app/termora/sftp/TransportManager.kt
Normal file
11
src/main/kotlin/app/termora/sftp/TransportManager.kt
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package app.termora.sftp
|
||||
|
||||
class TransportStatusException(message: String) : RuntimeException(message)
|
||||
261
src/main/kotlin/app/termora/sftp/TransportTable.kt
Normal file
261
src/main/kotlin/app/termora/sftp/TransportTable.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
448
src/main/kotlin/app/termora/sftp/TransportTableModel.kt
Normal file
448
src/main/kotlin/app/termora/sftp/TransportTableModel.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt
Normal file
72
src/main/kotlin/app/termora/sftp/TransportTreeTableNode.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
writer.write(TerminalWriter.WriteRequest.fromBytes(text.toByteArray(writer.getCharset())))
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,8 +61,8 @@ class TerminalLineBuffer(
|
||||
if (!resizing) {
|
||||
while (size > terminalModel.getMaxRows()) {
|
||||
removeFirst()
|
||||
// 因为第一行被删除了,所以这里要删除这一行的荧光笔
|
||||
terminal.getMarkupModel().removeAllHighlightersInLine(0)
|
||||
// 超出的行数,前 N 行已经被清理
|
||||
terminal.getTerminalModel().setData(DocumentImpl.OverflowLines, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user