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 OPEN_HOST = "OpenHostAction"
/**
* 终端日志记录
*/
const val TERMINAL_LOGGER = "TerminalLogAction"
} }

View File

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

View File

@@ -14,6 +14,7 @@ object Icons {
val settings by lazy { DynamicIcon("icons/settings.svg", "icons/settings_dark.svg") } 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 pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") }
val empty by lazy { DynamicIcon("icons/empty.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 add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_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") } val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
@@ -73,6 +74,7 @@ object Icons {
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") } 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 folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_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 fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_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") } 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 com.jetbrains.JBR
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.JXLabel import org.jdesktop.swingx.JXLabel
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Component import java.awt.Component
@@ -122,7 +123,7 @@ object OptionPane {
if (Desktop.isDesktopSupported() && Desktop.getDesktop() if (Desktop.isDesktopSupported() && Desktop.getDesktop()
.isSupported(Desktop.Action.BROWSE_FILE_DIR) .isSupported(Desktop.Action.BROWSE_FILE_DIR)
) { ) {
if (JOptionPane.YES_OPTION == showConfirmDialog( if (yMessage.isEmpty() || JOptionPane.YES_OPTION == showConfirmDialog(
parentComponent, parentComponent,
yMessage, yMessage,
optionType = JOptionPane.YES_NO_OPTION optionType = JOptionPane.YES_NO_OPTION

View File

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

View File

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

View File

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

View File

@@ -42,5 +42,10 @@ interface TerminalTab : Disposable {
*/ */
fun canClose(): Boolean = true 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.createHorizontalStrut(UIManager.getInt("TabbedPane.tabHeight")))
toolbar.add(Box.createHorizontalGlue()) 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.MACRO)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEYWORD_HIGHLIGHT_EVERYWHERE))) toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEYWORD_HIGHLIGHT_EVERYWHERE)))
toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEY_MANAGER))) toolbar.add(actionContainerFactory.createButton(actionManager.getAction(Actions.KEY_MANAGER)))
@@ -248,6 +249,7 @@ class TerminalTabbed(
private fun showContextMenu(tabIndex: Int, e: MouseEvent) { private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
val c = tabbedPane.getComponentAt(tabIndex) as JComponent val c = tabbedPane.getComponentAt(tabIndex) as JComponent
val tab = tabs[tabIndex]
val popupMenu = FlatPopupMenu() val popupMenu = FlatPopupMenu()
@@ -288,7 +290,6 @@ class TerminalTabbed(
openInNewWindow.addActionListener { openInNewWindow.addActionListener {
val index = tabbedPane.selectedIndex val index = tabbedPane.selectedIndex
if (index > 0) { if (index > 0) {
val tab = tabs[index]
removeTabAt(index, false) removeTabAt(index, false)
val dialog = TerminalTabDialog( val dialog = TerminalTabDialog(
owner = SwingUtilities.getWindowAncestor(this), owner = SwingUtilities.getWindowAncestor(this),
@@ -332,12 +333,11 @@ class TerminalTabbed(
clone.isEnabled = close.isEnabled clone.isEnabled = close.isEnabled
openInNewWindow.isEnabled = close.isEnabled openInNewWindow.isEnabled = close.isEnabled
// SFTP不允许克隆 // 如果不允许克隆
if (clone.isEnabled && getSelectedTerminalTab() is SFTPTerminalTab) { if (clone.isEnabled && !tab.canClone()) {
clone.isEnabled = false clone.isEnabled = false
} }
if (close.isEnabled) { if (close.isEnabled) {
popupMenu.addSeparator() popupMenu.addSeparator()
val reconnect = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.reconnect")) 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.highlight.KeywordHighlightDialog
import app.termora.keymgr.KeyManagerDialog import app.termora.keymgr.KeyManagerDialog
import app.termora.macro.MacroAction import app.termora.macro.MacroAction
import app.termora.tlog.TerminalLoggerAction
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatDesktop import com.formdev.flatlaf.extras.FlatDesktop
@@ -233,6 +234,9 @@ class TermoraFrame : JFrame() {
} }
}) })
// 终端日志记录
ActionManager.getInstance().addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
// macro // macro
ActionManager.getInstance().addAction(Actions.MACRO, MacroAction()) ActionManager.getInstance().addAction(Actions.MACRO, MacroAction())

View File

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

View File

@@ -170,6 +170,13 @@ termora.tabbed.contextmenu.close-other-tabs=Close Other Tabs
termora.tabbed.contextmenu.close-all-tabs=Close All Tabs termora.tabbed.contextmenu.close-all-tabs=Close All Tabs
termora.tabbed.contextmenu.reconnect=Reconnect termora.tabbed.contextmenu.reconnect=Reconnect
# Terminal logger
termora.terminal-logger=Terminal Logger
termora.terminal-logger.start-recording=Start Recording
termora.terminal-logger.stop-recording=Stop Recording
termora.terminal-logger.open-log-viewer=Open Log Viewer
termora.terminal-logger.open-in-folder=Open in {0}
# Highlight # Highlight
termora.highlight=Highlight Sets termora.highlight=Highlight Sets

View File

@@ -166,6 +166,15 @@ termora.tabbed.contextmenu.close-other-tabs=关闭其他标签页
termora.tabbed.contextmenu.close-all-tabs=关闭所有标签页 termora.tabbed.contextmenu.close-all-tabs=关闭所有标签页
termora.tabbed.contextmenu.reconnect=重新连接 termora.tabbed.contextmenu.reconnect=重新连接
# Terminal logger
termora.terminal-logger=终端日志
termora.terminal-logger.start-recording=开始记录
termora.terminal-logger.stop-recording=停止记录
termora.terminal-logger.open-log-viewer=打开日志浏览器
termora.terminal-logger.open-in-folder=在 {0} 中打开
# Highlight # Highlight
termora.highlight=关键词高亮 termora.highlight=关键词高亮
termora.highlight.text-color=文本颜色 termora.highlight.text-color=文本颜色

View File

@@ -161,6 +161,15 @@ termora.tabbed.contextmenu.close-all-tabs=關閉所有標籤
termora.tabbed.contextmenu.reconnect=重新連接 termora.tabbed.contextmenu.reconnect=重新連接
# Terminal logger
termora.terminal-logger=終端日誌
termora.terminal-logger.start-recording=開始記錄
termora.terminal-logger.stop-recording=停止記錄
termora.terminal-logger.open-log-viewer=開啟日誌瀏覽器
termora.terminal-logger.open-in-folder=在 {0} 中打開
# Highlight # Highlight
termora.highlight=關鍵字高亮 termora.highlight=關鍵字高亮
termora.highlight.text-color=文字顏色 termora.highlight.text-color=文字顏色

View File

@@ -0,0 +1,7 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="1.5" stroke="#6C707E"/>
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#6C707E"/>
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#6C707E"/>
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,7 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="1.5" stroke="#CED0D6"/>
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,9 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="1.5" width="11" height="13" rx="1.5" stroke="#6C707E"/>
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#6C707E"/>
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#6C707E"/>
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#6C707E"/>
<path d="M16 13.5C16 14.8807 14.8807 16 13.5 16C12.1193 16 11 14.8807 11 13.5C11 12.1193 12.1193 11 13.5 11C14.8807 11 16 12.1193 16 13.5Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@@ -0,0 +1,9 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="1.5" width="11" height="13" rx="1.5" stroke="#CED0D6"/>
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#CED0D6"/>
<path d="M16 13.5C16 14.8807 14.8807 16 13.5 16C12.1193 16 11 14.8807 11 13.5C11 12.1193 12.1193 11 13.5 11C14.8807 11 16 12.1193 16 13.5Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@@ -0,0 +1,9 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14C12.5523 14 13 13.5523 13 13C13 12.4477 12.5523 12 12 12C11.4477 12 11 12.4477 11 13C11 13.5523 11.4477 14 12 14Z" fill="#3574F0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 16C9 16 8 13 8 13C8 13 9 10 12 10C15 10 16 13 16 13C16 13 15 16 12 16ZM14.9024 12.9785L14.9131 13L14.9024 13.0215C14.787 13.2525 14.6081 13.5582 14.3568 13.8598C13.8602 14.4557 13.1189 15 12 15C10.8811 15 10.1398 14.4557 9.64322 13.8598C9.39186 13.5582 9.21302 13.2525 9.09755 13.0215L9.08686 13L9.09755 12.9785C9.21302 12.7475 9.39186 12.4418 9.64322 12.1402C10.1398 11.5443 10.8811 11 12 11C13.1189 11 13.8602 11.5443 14.3568 12.1402C14.6081 12.4418 14.787 12.7475 14.9024 12.9785Z" fill="#3574F0"/>
<path d="M12 1C13.1046 1 14 1.89543 14 3V9.40904C13.6947 9.2748 13.3618 9.16675 13 9.09548V3C13 2.44772 12.5523 2 12 2H4C3.44772 2 3 2.44772 3 3V13C3 13.5523 3.44772 14 4 14H7.35149C7.49887 14.2842 7.7089 14.637 7.99347 15H4C2.89543 15 2 14.1046 2 13V3C2 2.15611 2.52265 1.4343 3.26192 1.1406C3.49028 1.04987 3.73932 1 4 1H12Z" fill="#6C707E"/>
<path d="M9.00093 10C8.64732 10.2692 8.35059 10.5672 8.10677 10.8598C8.06775 10.9066 8.03 10.9534 7.99347 11H5.5C5.22386 11 5 10.7761 5 10.5C5 10.2239 5.22386 10 5.5 10H9.00093Z" fill="#6C707E"/>
<path d="M10.5 6C10.7761 6 11 5.77614 11 5.5C11 5.22386 10.7761 5 10.5 5H5.5C5.22386 5 5 5.22386 5 5.5C5 5.77614 5.22386 6 5.5 6H10.5Z" fill="#6C707E"/>
<path d="M11 8C11 7.72386 10.7761 7.5 10.5 7.5H5.5C5.22386 7.5 5 7.72386 5 8C5 8.27614 5.22386 8.5 5.5 8.5H10.5C10.7761 8.5 11 8.27614 11 8Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,9 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14C12.5523 14 13 13.5523 13 13C13 12.4477 12.5523 12 12 12C11.4477 12 11 12.4477 11 13C11 13.5523 11.4477 14 12 14Z" fill="#548AF7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 16C9 16 8 13 8 13C8 13 9 10 12 10C15 10 16 13 16 13C16 13 15 16 12 16ZM14.9024 12.9785L14.9131 13L14.9024 13.0215C14.787 13.2525 14.6081 13.5582 14.3568 13.8598C13.8602 14.4557 13.1189 15 12 15C10.8811 15 10.1398 14.4557 9.64322 13.8598C9.39186 13.5582 9.21302 13.2525 9.09755 13.0215L9.08686 13L9.09755 12.9785C9.21302 12.7475 9.39186 12.4418 9.64322 12.1402C10.1398 11.5443 10.8811 11 12 11C13.1189 11 13.8602 11.5443 14.3568 12.1402C14.6081 12.4418 14.787 12.7475 14.9024 12.9785Z" fill="#548AF7"/>
<path d="M12 1C13.1046 1 14 1.89543 14 3V9.40904C13.6947 9.2748 13.3618 9.16675 13 9.09548V3C13 2.44772 12.5523 2 12 2H4C3.44772 2 3 2.44772 3 3V13C3 13.5523 3.44772 14 4 14H7.35149C7.49887 14.2842 7.7089 14.637 7.99347 15H4C2.89543 15 2 14.1046 2 13V3C2 2.15611 2.52265 1.4343 3.26192 1.1406C3.49028 1.04987 3.73932 1 4 1H12Z" fill="#CED0D6"/>
<path d="M9.00093 10C8.64732 10.2692 8.35059 10.5672 8.10677 10.8598C8.06775 10.9066 8.03 10.9534 7.99347 11H5.5C5.22386 11 5 10.7761 5 10.5C5 10.2239 5.22386 10 5.5 10H9.00093Z" fill="#CED0D6"/>
<path d="M10.5 6C10.7761 6 11 5.77614 11 5.5C11 5.22386 10.7761 5 10.5 5H5.5C5.22386 5 5 5.22386 5 5.5C5 5.77614 5.22386 6 5.5 6H10.5Z" fill="#CED0D6"/>
<path d="M11 8C11 7.72386 10.7761 7.5 10.5 7.5H5.5C5.22386 7.5 5 7.72386 5 8C5 8.27614 5.22386 8.5 5.5 8.5H10.5C10.7761 8.5 11 8.27614 11 8Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB