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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.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
|
||||
termora.highlight=Highlight Sets
|
||||
|
||||
@@ -166,6 +166,15 @@ termora.tabbed.contextmenu.close-other-tabs=关闭其他标签页
|
||||
termora.tabbed.contextmenu.close-all-tabs=关闭所有标签页
|
||||
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
|
||||
termora.highlight=关键词高亮
|
||||
termora.highlight.text-color=文本颜色
|
||||
|
||||
@@ -161,6 +161,15 @@ termora.tabbed.contextmenu.close-all-tabs=關閉所有標籤
|
||||
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
|
||||
termora.highlight=關鍵字高亮
|
||||
termora.highlight.text-color=文字顏色
|
||||
|
||||
7
src/main/resources/icons/changelog.svg
Normal file
7
src/main/resources/icons/changelog.svg
Normal 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 |
7
src/main/resources/icons/changelog_dark.svg
Normal file
7
src/main/resources/icons/changelog_dark.svg
Normal 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 |
9
src/main/resources/icons/dotListFiles.svg
Normal file
9
src/main/resources/icons/dotListFiles.svg
Normal 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 |
9
src/main/resources/icons/dotListFiles_dark.svg
Normal file
9
src/main/resources/icons/dotListFiles_dark.svg
Normal 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 |
9
src/main/resources/icons/showLogs.svg
Normal file
9
src/main/resources/icons/showLogs.svg
Normal 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 |
9
src/main/resources/icons/showLogs_dark.svg
Normal file
9
src/main/resources/icons/showLogs_dark.svg
Normal 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 |
Reference in New Issue
Block a user