mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 02:12:58 +08:00
feat: 支持终端日志记录 (#7)
This commit is contained in:
@@ -47,4 +47,9 @@ object Actions {
|
||||
* 打开一个主机
|
||||
*/
|
||||
const val OPEN_HOST = "OpenHostAction"
|
||||
|
||||
/**
|
||||
* 终端日志记录
|
||||
*/
|
||||
const val TERMINAL_LOGGER = "TerminalLogAction"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ class SFTPTerminalTab : Disposable, TerminalTab {
|
||||
return transportPanel
|
||||
}
|
||||
|
||||
override fun canClone(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun canClose(): Boolean {
|
||||
assertEventDispatchThread()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,5 +42,10 @@ interface TerminalTab : Disposable {
|
||||
*/
|
||||
fun canClose(): Boolean = true
|
||||
|
||||
/**
|
||||
* 是否可以克隆
|
||||
*/
|
||||
fun canClone(): Boolean = true
|
||||
|
||||
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
57
src/main/kotlin/app/termora/tlog/LogViewerTerminal.kt
Normal file
57
src/main/kotlin/app/termora/tlog/LogViewerTerminal.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/main/kotlin/app/termora/tlog/LogViewerTerminalTab.kt
Normal file
73
src/main/kotlin/app/termora/tlog/LogViewerTerminalTab.kt
Normal 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
|
||||
}
|
||||
}
|
||||
109
src/main/kotlin/app/termora/tlog/TerminalLoggerAction.kt
Normal file
109
src/main/kotlin/app/termora/tlog/TerminalLoggerAction.kt
Normal 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
|
||||
}
|
||||
}
|
||||
147
src/main/kotlin/app/termora/tlog/TerminalLoggerDataListener.kt
Normal file
147
src/main/kotlin/app/termora/tlog/TerminalLoggerDataListener.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user