feat: 支持终端日志记录 (#7)

This commit is contained in:
hstyi
2025-01-07 17:32:47 +08:00
committed by hstyi
parent 75f8d1de99
commit 022ae402cc
24 changed files with 554 additions and 21 deletions

View File

@@ -47,4 +47,9 @@ object Actions {
* 打开一个主机
*/
const val OPEN_HOST = "OpenHostAction"
/**
* 终端日志记录
*/
const val TERMINAL_LOGGER = "TerminalLogAction"
}

View File

@@ -8,9 +8,15 @@ import kotlinx.coroutines.swing.Swing
import java.beans.PropertyChangeEvent
import javax.swing.Icon
abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
abstract class HostTerminalTab(
val host: Host,
protected val terminal: Terminal = TerminalFactory.instance.createTerminal()
) : PropertyTerminalTab() {
companion object {
val Host = DataKey(app.termora.Host::class)
}
protected val coroutineScope by lazy { CoroutineScope(Dispatchers.Swing) }
protected val terminal = TerminalFactory.instance.createTerminal()
protected val terminalModel get() = terminal.getTerminalModel()
protected var unread = false
set(value) {
@@ -25,6 +31,7 @@ abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
}
init {
terminal.getTerminalModel().setData(Host, host)
terminal.getTerminalModel().addDataListener(object : DataListener {
override fun onChanged(key: DataKey<*>, data: Any) {
if (key == VisualTerminal.Written) {
@@ -51,6 +58,7 @@ abstract class HostTerminalTab(val host: Host) : PropertyTerminalTab() {
}
override fun dispose() {
terminal.close()
coroutineScope.cancel()
}

View File

@@ -14,6 +14,7 @@ object Icons {
val settings by lazy { DynamicIcon("icons/settings.svg", "icons/settings_dark.svg") }
val pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") }
val empty by lazy { DynamicIcon("icons/empty.svg") }
val changelog by lazy { DynamicIcon("icons/changelog.svg", "icons/changelog_dark.svg") }
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
@@ -47,11 +48,11 @@ object Icons {
val google by lazy { DynamicIcon("icons/google-small.svg") }
val aliyun by lazy { DynamicIcon("icons/aliyun.svg") }
val yandexCloud by lazy { DynamicIcon("icons/yandexCloud.svg") }
val aws by lazy { DynamicIcon("icons/aws.svg","icons/aws_dark.svg") }
val aws by lazy { DynamicIcon("icons/aws.svg", "icons/aws_dark.svg") }
val huawei by lazy { DynamicIcon("icons/huawei.svg") }
val baidu by lazy { DynamicIcon("icons/baiduyun.svg") }
val tianyi by lazy { DynamicIcon("icons/tianyiyun.svg") }
val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg","icons/digitalocean_dark.svg") }
val digitalocean by lazy { DynamicIcon("icons/digitalocean.svg", "icons/digitalocean_dark.svg") }
val terminalUnread by lazy { DynamicIcon("icons/terminalUnread.svg", "icons/terminalUnread_dark.svg") }
val dbPrimitive by lazy { DynamicIcon("icons/dbPrimitive.svg", "icons/dbPrimitive_dark.svg") }
val linux by lazy { DynamicIcon("icons/linux.svg", "icons/linux_dark.svg") }
@@ -73,6 +74,7 @@ object Icons {
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") }
val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
val dotListFiles by lazy { DynamicIcon("icons/dotListFiles.svg", "icons/dotListFiles_dark.svg") }
val fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") }
val expand by lazy { DynamicIcon("icons/expand.svg", "icons/expand_dark.svg") }

View File

@@ -6,6 +6,7 @@ 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 org.jdesktop.swingx.JXLabel
import java.awt.BorderLayout
import java.awt.Component
@@ -122,7 +123,7 @@ object OptionPane {
if (Desktop.isDesktopSupported() && Desktop.getDesktop()
.isSupported(Desktop.Action.BROWSE_FILE_DIR)
) {
if (JOptionPane.YES_OPTION == showConfirmDialog(
if (yMessage.isEmpty() || JOptionPane.YES_OPTION == showConfirmDialog(
parentComponent,
yMessage,
optionType = JOptionPane.YES_NO_OPTION

View File

@@ -1,9 +1,6 @@
package app.termora
import app.termora.terminal.ControlCharacters
import app.termora.terminal.PtyConnector
import app.termora.terminal.PtyConnectorDelegate
import app.termora.terminal.TerminalKeyEvent
import app.termora.terminal.*
import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.exception.ExceptionUtils
@@ -12,7 +9,11 @@ import java.awt.event.KeyEvent
import javax.swing.JComponent
import kotlin.time.Duration.Companion.milliseconds
abstract class PtyHostTerminalTab(host: Host) : HostTerminalTab(host) {
abstract class PtyHostTerminalTab(
host: Host,
terminal: Terminal = TerminalFactory.instance.createTerminal()
) : HostTerminalTab(host, terminal) {
companion object {
private val log = LoggerFactory.getLogger(PtyHostTerminalTab::class.java)
}

View File

@@ -34,6 +34,9 @@ class SFTPTerminalTab : Disposable, TerminalTab {
return transportPanel
}
override fun canClone(): Boolean {
return false
}
override fun canClose(): Boolean {
assertEventDispatchThread()

View File

@@ -3,6 +3,7 @@ package app.termora
import app.termora.db.Database
import app.termora.terminal.*
import app.termora.terminal.panel.TerminalPanel
import app.termora.tlog.TerminalLoggerDataListener
import java.awt.Color
import javax.swing.UIManager
@@ -15,6 +16,10 @@ class TerminalFactory {
fun createTerminal(): Terminal {
val terminal = MyVisualTerminal()
// terminal logger listener
terminal.getTerminalModel().addDataListener(TerminalLoggerDataListener(terminal))
terminals.add(terminal)
return terminal
}
@@ -23,7 +28,7 @@ class TerminalFactory {
return terminals
}
private inner class MyVisualTerminal : VisualTerminal() {
open class MyVisualTerminal : VisualTerminal() {
private val terminalModel by lazy { MyTerminalModel(this) }
override fun getTerminalModel(): TerminalModel {
@@ -31,13 +36,13 @@ class TerminalFactory {
}
}
private inner class MyTerminalModel(terminal: Terminal) : TerminalModelImpl(terminal) {
open class MyTerminalModel(terminal: Terminal) : TerminalModelImpl(terminal) {
private val colorPalette by lazy { MyColorPalette(terminal) }
private val config get() = Database.instance.terminal
init {
setData(DataKey.CursorStyle, config.cursor)
setData(TerminalPanel.Debug, config.debug)
this.setData(DataKey.CursorStyle, config.cursor)
this.setData(TerminalPanel.Debug, config.debug)
}
override fun getColorPalette(): ColorPalette {
@@ -97,7 +102,7 @@ class TerminalFactory {
}
private inner class MyColorPalette(terminal: Terminal) : ColorPaletteImpl(terminal) {
class MyColorPalette(terminal: Terminal) : ColorPaletteImpl(terminal) {
private val colorTheme by lazy { FlatLafColorTheme() }
override fun getTheme(): ColorTheme {
return colorTheme

View File

@@ -42,5 +42,10 @@ interface TerminalTab : Disposable {
*/
fun canClose(): Boolean = true
/**
* 是否可以克隆
*/
fun canClone(): Boolean = true
}

View File

@@ -71,6 +71,7 @@ class TerminalTabbed(
}))
toolbar.add(Box.createHorizontalStrut(UIManager.getInt("TabbedPane.tabHeight")))
toolbar.add(Box.createHorizontalGlue())
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.TERMINAL_LOGGER)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.MACRO)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEYWORD_HIGHLIGHT_EVERYWHERE)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEY_MANAGER)))
@@ -248,6 +249,7 @@ class TerminalTabbed(
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
val c = tabbedPane.getComponentAt(tabIndex) as JComponent
val tab = tabs[tabIndex]
val popupMenu = FlatPopupMenu()
@@ -288,7 +290,6 @@ class TerminalTabbed(
openInNewWindow.addActionListener {
val index = tabbedPane.selectedIndex
if (index > 0) {
val tab = tabs[index]
removeTabAt(index, false)
val dialog = TerminalTabDialog(
owner = SwingUtilities.getWindowAncestor(this),
@@ -332,12 +333,11 @@ class TerminalTabbed(
clone.isEnabled = close.isEnabled
openInNewWindow.isEnabled = close.isEnabled
// SFTP不允许克隆
if (clone.isEnabled && getSelectedTerminalTab() is SFTPTerminalTab) {
// 如果不允许克隆
if (clone.isEnabled && !tab.canClone()) {
clone.isEnabled = false
}
if (close.isEnabled) {
popupMenu.addSeparator()
val reconnect = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.reconnect"))

View File

@@ -4,6 +4,7 @@ import app.termora.findeverywhere.FindEverywhere
import app.termora.highlight.KeywordHighlightDialog
import app.termora.keymgr.KeyManagerDialog
import app.termora.macro.MacroAction
import app.termora.tlog.TerminalLoggerAction
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatDesktop
@@ -233,6 +234,9 @@ class TermoraFrame : JFrame() {
}
})
// 终端日志记录
ActionManager.getInstance().addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
// macro
ActionManager.getInstance().addAction(Actions.MACRO, MacroAction())

View File

@@ -3,8 +3,8 @@ package app.termora.native
import app.termora.native.osx.DispatchNative
import com.formdev.flatlaf.util.SystemInfo
import de.jangassen.jfa.foundation.Foundation
import de.jangassen.jfa.foundation.Foundation.NSArray
import jnafilechooser.api.JnaFileChooser
import org.apache.commons.lang3.StringUtils
import java.awt.Window
import java.io.File
import java.util.concurrent.CompletableFuture
@@ -17,6 +17,12 @@ class FileChooser {
var allowsOtherFileTypes = true
var canCreateDirectories = true
var win32Filters = mutableListOf<Pair<String, List<String>>>()
var osxAllowedFileTypes = emptyList<String>()
/**
* 默认的打开目录
*/
var defaultDirectory = StringUtils.EMPTY
fun showOpenDialog(owner: Window? = null): CompletableFuture<List<File>> {
val future = CompletableFuture<List<File>>()
@@ -26,6 +32,17 @@ class FileChooser {
val fileChooser = JnaFileChooser()
fileChooser.isMultiSelectionEnabled = allowsMultiSelection
fileChooser.setTitle(title)
if (defaultDirectory.isNotBlank()) {
fileChooser.setCurrentDirectory(defaultDirectory)
}
if (win32Filters.isNotEmpty()) {
for ((name, filters) in win32Filters) {
fileChooser.addFilter(name, *filters.toTypedArray())
}
}
if (fileChooser.showOpenDialog(owner)) {
future.complete(fileChooser.selectedFiles.toList())
} else {
@@ -91,6 +108,27 @@ class FileChooser {
// 是否允许多选
Foundation.invoke(openPanelInstance, "setAllowsMultipleSelection:", allowsMultiSelection)
// 限制文件类型
if (osxAllowedFileTypes.isNotEmpty()) {
Foundation.invoke(
openPanelInstance,
"setAllowedFileTypes:",
Foundation.fillArray(osxAllowedFileTypes.toTypedArray())
)
}
if (defaultDirectory.isNotBlank()) {
Foundation.invoke(
openPanelInstance,
"setDirectoryURL:",
Foundation.invoke(
"NSURL",
"fileURLWithPath:",
Foundation.nsString(defaultDirectory)
)
)
}
// 标题
if (title.isNotBlank()) {
Foundation.invoke(openPanelInstance, "setTitle:", Foundation.nsString(title))
@@ -103,7 +141,7 @@ class FileChooser {
}
val files = mutableListOf<File>()
val urls = NSArray(Foundation.invoke(openPanelInstance, "URLs"))
val urls = Foundation.NSArray(Foundation.invoke(openPanelInstance, "URLs"))
for (i in 0 until urls.count()) {
val url = Foundation.invoke(urls.at(i), "path")
if (url != null) {

View File

@@ -0,0 +1,57 @@
package app.termora.tlog
import app.termora.TerminalFactory
import app.termora.terminal.*
import org.slf4j.LoggerFactory
class LogViewerTerminal : TerminalFactory.MyVisualTerminal() {
companion object {
private val log = LoggerFactory.getLogger(LogViewerTerminal::class.java)
}
private val document by lazy { MyDocument(this) }
private val terminalModel by lazy { LogViewerTerminalModel(this) }
override fun getDocument(): Document {
return document
}
override fun getTerminalModel(): TerminalModel {
return terminalModel
}
private class MyDocument(terminal: Terminal) : DocumentImpl(terminal) {
override fun eraseInDisplay(n: Int) {
// 预览日志的时候,不处理清屏操作,不然会导致日志看不到。
// 例如,用户输入了 cat xxx.txt ,然后执行了 clear 那么就看不到了
if (n == 3) {
if (log.isDebugEnabled) {
log.debug("ignore $n eraseInDisplay")
}
return
}
super.eraseInDisplay(n)
}
}
@Suppress("UNCHECKED_CAST")
private class LogViewerTerminalModel(terminal: Terminal) : TerminalFactory.MyTerminalModel(terminal) {
override fun getMaxRows(): Int {
return Int.MAX_VALUE
}
override fun <T : Any> getData(key: DataKey<T>): T {
if (key == DataKey.ShowCursor) {
return false as T
}
return super.getData(key)
}
override fun <T : Any> getData(key: DataKey<T>, defaultValue: T): T {
if (key == DataKey.ShowCursor) {
return false as T
}
return super.getData(key, defaultValue)
}
}
}

View File

@@ -0,0 +1,73 @@
package app.termora.tlog
import app.termora.Host
import app.termora.Icons
import app.termora.Protocol
import app.termora.PtyHostTerminalTab
import app.termora.terminal.PtyConnector
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileNotFoundException
import java.nio.file.Files
import javax.swing.Icon
class LogViewerTerminalTab(private val file: File) : PtyHostTerminalTab(
Host(
name = file.name,
protocol = Protocol.Local
),
LogViewerTerminal()
) {
init {
// 不记录日志
terminal.getTerminalModel().setData(TerminalLoggerDataListener.IgnoreTerminalLogger, true)
}
override suspend fun openPtyConnector(): PtyConnector {
if (!file.exists()) {
throw FileNotFoundException(file.absolutePath)
}
val input = withContext(Dispatchers.IO) {
Files.newBufferedReader(file.toPath())
}
return object : PtyConnector {
override fun read(buffer: CharArray): Int {
return input.read(buffer)
}
override fun write(buffer: ByteArray, offset: Int, len: Int) {
}
override fun resize(rows: Int, cols: Int) {
}
override fun waitFor(): Int {
return -1
}
override fun close() {
input.close()
}
}
}
override fun getIcon(): Icon {
return Icons.listFiles
}
override fun canReconnect(): Boolean {
return false
}
override fun canClone(): Boolean {
return false
}
}

View File

@@ -0,0 +1,109 @@
package app.termora.tlog
import app.termora.*
import app.termora.db.Database
import app.termora.native.FileChooser
import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.FileUtils
import java.awt.Window
import java.awt.event.ActionEvent
import java.io.File
import java.time.LocalDate
import javax.swing.JComponent
import javax.swing.JFileChooser
import javax.swing.SwingUtilities
class TerminalLoggerAction : AnAction(I18n.getString("termora.terminal-logger"), Icons.listFiles) {
private val properties by lazy { Database.instance.properties }
/**
* 是否开启了记录
*/
var isRecording = properties.getString("terminal.logger.isRecording")?.toBoolean() ?: false
private set(value) {
field = value
properties.putString("terminal.logger.isRecording", value.toString())
}
init {
smallIcon = if (isRecording) Icons.dotListFiles else Icons.listFiles
}
override fun actionPerformed(evt: ActionEvent) {
val source = evt.source
if (source !is JComponent) return
val popupMenu = FlatPopupMenu()
if (isRecording) {
// stop
popupMenu.add(I18n.getString("termora.terminal-logger.stop-recording")).addActionListener {
isRecording = false
smallIcon = Icons.listFiles
}
} else {
// start
popupMenu.add(I18n.getString("termora.terminal-logger.start-recording")).addActionListener {
isRecording = true
smallIcon = Icons.dotListFiles
}
}
popupMenu.addSeparator()
// 打开日志浏览
popupMenu.add(I18n.getString("termora.terminal-logger.open-log-viewer")).addActionListener {
openLogViewer(SwingUtilities.getWindowAncestor(source))
}
// 打开日志文件夹
popupMenu.add(
I18n.getString(
"termora.terminal-logger.open-in-folder",
if (SystemInfo.isMacOS) I18n.getString("termora.finder")
else if (SystemInfo.isWindows) I18n.getString("termora.explorer")
else I18n.getString("termora.folder")
)
).addActionListener {
val dir = getLogDir()
Application.browse(dir.toURI())
}
val width = popupMenu.preferredSize.width
popupMenu.show(source, -(width / 2) + source.width / 2, source.height)
}
private fun openLogViewer(owner: Window) {
val fc = FileChooser()
fc.allowsMultiSelection = true
fc.title = I18n.getString("termora.terminal-logger.open-log-viewer")
fc.fileSelectionMode = JFileChooser.FILES_ONLY
if (SystemInfo.isMacOS) {
fc.osxAllowedFileTypes = listOf("log")
} else if (SystemInfo.isWindows) {
fc.win32Filters.add(Pair("Log files", listOf("log")))
}
fc.defaultDirectory = getLogDir().absolutePath
println(fc.defaultDirectory)
fc.showOpenDialog(owner).thenAccept { files ->
if (files.isNotEmpty()) {
SwingUtilities.invokeLater {
val manager = Application.getService(TerminalTabbedManager::class)
for (file in files) {
val tab = LogViewerTerminalTab(file)
tab.start()
manager.addTerminalTab(tab)
}
}
}
}
}
fun getLogDir(): File {
val dir = FileUtils.getFile(Application.getBaseDataDir(), "terminal", "logs", LocalDate.now().toString())
FileUtils.forceMkdir(dir)
return dir
}
}

View File

@@ -0,0 +1,147 @@
package app.termora.tlog
import app.termora.*
import app.termora.terminal.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.onSuccess
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.jdesktop.swingx.action.ActionManager
import org.slf4j.LoggerFactory
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.nio.file.Paths
import java.util.*
class TerminalLoggerDataListener(private val terminal: Terminal) : DataListener {
companion object {
/**
* 忽略日志的标记
*/
val IgnoreTerminalLogger = DataKey(Boolean::class)
private val log = LoggerFactory.getLogger(TerminalLoggerDataListener::class.java)
}
private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }
private val channel by lazy { Channel<String>(Channel.UNLIMITED) }
private lateinit var file: File
private lateinit var writer: BufferedWriter
private val host: Host?
get() {
if (terminal.getTerminalModel().hasData(HostTerminalTab.Host)) {
return terminal.getTerminalModel().getData(HostTerminalTab.Host)
}
return null
}
init {
terminal.addTerminalListener(object : TerminalListener {
override fun onClose(terminal: Terminal) {
close()
}
})
}
override fun onChanged(key: DataKey<*>, data: Any) {
if (key != VisualTerminal.Written) {
return
}
// 如果忽略了,那么跳过
if (terminal.getTerminalModel().getData(IgnoreTerminalLogger, false)) {
return
}
val host = this.host ?: return
val action = ActionManager.getInstance().getAction(Actions.TERMINAL_LOGGER)
if (action !is TerminalLoggerAction || !action.isRecording) {
return
}
// 尝试记录
tryRecord(data as String, host, action)
}
private fun tryRecord(text: String, host: Host, action: TerminalLoggerAction) {
if (!::writer.isInitialized) {
file = createFile(host, action.getLogDir())
writer = BufferedWriter(FileWriter(file, false))
if (log.isInfoEnabled) {
log.info("Terminal logger file: ${file.absolutePath}")
}
coroutineScope.launch {
while (coroutineScope.isActive) {
channel.receiveCatching().onSuccess {
writer.write(it)
}.onFailure { e ->
if (log.isErrorEnabled && e is Throwable) {
log.error(e.message, e)
}
}
}
}
val date = DateFormatUtils.format(Date(), I18n.getString("termora.date-format"))
channel.trySend("[BEGIN] ---- $date ----").isSuccess
channel.trySend("${ControlCharacters.LF}${ControlCharacters.CR}").isSuccess
}
channel.trySend(text).isSuccess
}
private fun createFile(host: Host, dir: File): File {
val now = DateFormatUtils.format(Date(), "HH_mm_ss_SSS")
val filename = "${dir.absolutePath}${File.separator}${host.name}.${now}.log"
return try {
// 如果名称中包含 :\\n 等符号会获取失败,那么采用 ID 代替
Paths.get(filename).toFile()
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
try {
Paths.get(dir.absolutePath, "${host.id}.${now}.log").toFile()
} catch (e: Exception) {
Paths.get(dir.absolutePath, "${UUID.randomUUID().toSimpleString()}.${now}.log").toFile()
}
}
}
private fun close() {
if (!::writer.isInitialized) {
return
}
channel.close()
coroutineScope.cancel()
// write end
runCatching {
val date = DateFormatUtils.format(Date(), I18n.getString("termora.date-format"))
writer.write("${ControlCharacters.LF}${ControlCharacters.CR}")
writer.write("[END] ---- $date ----")
}.onFailure {
if (log.isErrorEnabled) {
log.info(it.message, it)
}
}
IOUtils.closeQuietly(writer)
if (log.isInfoEnabled) {
log.info("Terminal logger file: {} saved", file.absolutePath)
}
}
}