From 022ae402cc7830eea5ff3d06797466c1986e3a22 Mon Sep 17 00:00:00 2001 From: hstyi Date: Tue, 7 Jan 2025 17:32:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/app/termora/Actions.kt | 5 + .../kotlin/app/termora/HostTerminalTab.kt | 12 +- src/main/kotlin/app/termora/Icons.kt | 6 +- src/main/kotlin/app/termora/OptionPane.kt | 3 +- .../kotlin/app/termora/PtyHostTerminalTab.kt | 11 +- .../kotlin/app/termora/SFTPTerminalTab.kt | 3 + .../kotlin/app/termora/TerminalFactory.kt | 15 +- src/main/kotlin/app/termora/TerminalTab.kt | 5 + src/main/kotlin/app/termora/TerminalTabbed.kt | 8 +- src/main/kotlin/app/termora/TermoraFrame.kt | 4 + .../kotlin/app/termora/native/FileChooser.kt | 42 ++++- .../app/termora/tlog/LogViewerTerminal.kt | 57 +++++++ .../app/termora/tlog/LogViewerTerminalTab.kt | 73 +++++++++ .../app/termora/tlog/TerminalLoggerAction.kt | 109 +++++++++++++ .../tlog/TerminalLoggerDataListener.kt | 147 ++++++++++++++++++ src/main/resources/i18n/messages.properties | 7 + .../resources/i18n/messages_zh_CN.properties | 9 ++ .../resources/i18n/messages_zh_TW.properties | 9 ++ src/main/resources/icons/changelog.svg | 7 + src/main/resources/icons/changelog_dark.svg | 7 + src/main/resources/icons/dotListFiles.svg | 9 ++ .../resources/icons/dotListFiles_dark.svg | 9 ++ src/main/resources/icons/showLogs.svg | 9 ++ src/main/resources/icons/showLogs_dark.svg | 9 ++ 24 files changed, 554 insertions(+), 21 deletions(-) create mode 100644 src/main/kotlin/app/termora/tlog/LogViewerTerminal.kt create mode 100644 src/main/kotlin/app/termora/tlog/LogViewerTerminalTab.kt create mode 100644 src/main/kotlin/app/termora/tlog/TerminalLoggerAction.kt create mode 100644 src/main/kotlin/app/termora/tlog/TerminalLoggerDataListener.kt create mode 100644 src/main/resources/icons/changelog.svg create mode 100644 src/main/resources/icons/changelog_dark.svg create mode 100644 src/main/resources/icons/dotListFiles.svg create mode 100644 src/main/resources/icons/dotListFiles_dark.svg create mode 100644 src/main/resources/icons/showLogs.svg create mode 100644 src/main/resources/icons/showLogs_dark.svg diff --git a/src/main/kotlin/app/termora/Actions.kt b/src/main/kotlin/app/termora/Actions.kt index 962268f..2178cab 100644 --- a/src/main/kotlin/app/termora/Actions.kt +++ b/src/main/kotlin/app/termora/Actions.kt @@ -47,4 +47,9 @@ object Actions { * 打开一个主机 */ const val OPEN_HOST = "OpenHostAction" + + /** + * 终端日志记录 + */ + const val TERMINAL_LOGGER = "TerminalLogAction" } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostTerminalTab.kt b/src/main/kotlin/app/termora/HostTerminalTab.kt index 02525dd..6adfeae 100644 --- a/src/main/kotlin/app/termora/HostTerminalTab.kt +++ b/src/main/kotlin/app/termora/HostTerminalTab.kt @@ -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() } diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt index 78bb502..aa4e75c 100644 --- a/src/main/kotlin/app/termora/Icons.kt +++ b/src/main/kotlin/app/termora/Icons.kt @@ -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") } diff --git a/src/main/kotlin/app/termora/OptionPane.kt b/src/main/kotlin/app/termora/OptionPane.kt index 813ab48..a9ec26c 100644 --- a/src/main/kotlin/app/termora/OptionPane.kt +++ b/src/main/kotlin/app/termora/OptionPane.kt @@ -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 diff --git a/src/main/kotlin/app/termora/PtyHostTerminalTab.kt b/src/main/kotlin/app/termora/PtyHostTerminalTab.kt index d1885c0..40f71c1 100644 --- a/src/main/kotlin/app/termora/PtyHostTerminalTab.kt +++ b/src/main/kotlin/app/termora/PtyHostTerminalTab.kt @@ -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) } diff --git a/src/main/kotlin/app/termora/SFTPTerminalTab.kt b/src/main/kotlin/app/termora/SFTPTerminalTab.kt index 5de5e93..1051a75 100644 --- a/src/main/kotlin/app/termora/SFTPTerminalTab.kt +++ b/src/main/kotlin/app/termora/SFTPTerminalTab.kt @@ -34,6 +34,9 @@ class SFTPTerminalTab : Disposable, TerminalTab { return transportPanel } + override fun canClone(): Boolean { + return false + } override fun canClose(): Boolean { assertEventDispatchThread() diff --git a/src/main/kotlin/app/termora/TerminalFactory.kt b/src/main/kotlin/app/termora/TerminalFactory.kt index 4b68ce5..214201c 100644 --- a/src/main/kotlin/app/termora/TerminalFactory.kt +++ b/src/main/kotlin/app/termora/TerminalFactory.kt @@ -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 diff --git a/src/main/kotlin/app/termora/TerminalTab.kt b/src/main/kotlin/app/termora/TerminalTab.kt index 514ce0a..a623633 100644 --- a/src/main/kotlin/app/termora/TerminalTab.kt +++ b/src/main/kotlin/app/termora/TerminalTab.kt @@ -42,5 +42,10 @@ interface TerminalTab : Disposable { */ fun canClose(): Boolean = true + /** + * 是否可以克隆 + */ + fun canClone(): Boolean = true + } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/TerminalTabbed.kt b/src/main/kotlin/app/termora/TerminalTabbed.kt index 1d4ad9b..289fff7 100644 --- a/src/main/kotlin/app/termora/TerminalTabbed.kt +++ b/src/main/kotlin/app/termora/TerminalTabbed.kt @@ -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")) diff --git a/src/main/kotlin/app/termora/TermoraFrame.kt b/src/main/kotlin/app/termora/TermoraFrame.kt index 5ca12e3..9340ba5 100644 --- a/src/main/kotlin/app/termora/TermoraFrame.kt +++ b/src/main/kotlin/app/termora/TermoraFrame.kt @@ -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()) diff --git a/src/main/kotlin/app/termora/native/FileChooser.kt b/src/main/kotlin/app/termora/native/FileChooser.kt index 84b978c..2a0bccc 100644 --- a/src/main/kotlin/app/termora/native/FileChooser.kt +++ b/src/main/kotlin/app/termora/native/FileChooser.kt @@ -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>>() + var osxAllowedFileTypes = emptyList() + + /** + * 默认的打开目录 + */ + var defaultDirectory = StringUtils.EMPTY fun showOpenDialog(owner: Window? = null): CompletableFuture> { val future = CompletableFuture>() @@ -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() - 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) { diff --git a/src/main/kotlin/app/termora/tlog/LogViewerTerminal.kt b/src/main/kotlin/app/termora/tlog/LogViewerTerminal.kt new file mode 100644 index 0000000..163eb36 --- /dev/null +++ b/src/main/kotlin/app/termora/tlog/LogViewerTerminal.kt @@ -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 getData(key: DataKey): T { + if (key == DataKey.ShowCursor) { + return false as T + } + return super.getData(key) + } + + override fun getData(key: DataKey, defaultValue: T): T { + if (key == DataKey.ShowCursor) { + return false as T + } + return super.getData(key, defaultValue) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/tlog/LogViewerTerminalTab.kt b/src/main/kotlin/app/termora/tlog/LogViewerTerminalTab.kt new file mode 100644 index 0000000..31132d3 --- /dev/null +++ b/src/main/kotlin/app/termora/tlog/LogViewerTerminalTab.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/tlog/TerminalLoggerAction.kt b/src/main/kotlin/app/termora/tlog/TerminalLoggerAction.kt new file mode 100644 index 0000000..1c2e358 --- /dev/null +++ b/src/main/kotlin/app/termora/tlog/TerminalLoggerAction.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/tlog/TerminalLoggerDataListener.kt b/src/main/kotlin/app/termora/tlog/TerminalLoggerDataListener.kt new file mode 100644 index 0000000..6f5ff8d --- /dev/null +++ b/src/main/kotlin/app/termora/tlog/TerminalLoggerDataListener.kt @@ -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(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) + } + } +} \ No newline at end of file diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 02148de..8c26b2b 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -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 diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 00be132..75137b0 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -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=文本颜色 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 394167a..cd97c71 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -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=文字顏色 diff --git a/src/main/resources/icons/changelog.svg b/src/main/resources/icons/changelog.svg new file mode 100644 index 0000000..546db88 --- /dev/null +++ b/src/main/resources/icons/changelog.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/icons/changelog_dark.svg b/src/main/resources/icons/changelog_dark.svg new file mode 100644 index 0000000..25f5ebe --- /dev/null +++ b/src/main/resources/icons/changelog_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/icons/dotListFiles.svg b/src/main/resources/icons/dotListFiles.svg new file mode 100644 index 0000000..79af123 --- /dev/null +++ b/src/main/resources/icons/dotListFiles.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/icons/dotListFiles_dark.svg b/src/main/resources/icons/dotListFiles_dark.svg new file mode 100644 index 0000000..3a14494 --- /dev/null +++ b/src/main/resources/icons/dotListFiles_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/icons/showLogs.svg b/src/main/resources/icons/showLogs.svg new file mode 100644 index 0000000..423f88f --- /dev/null +++ b/src/main/resources/icons/showLogs.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/icons/showLogs_dark.svg b/src/main/resources/icons/showLogs_dark.svg new file mode 100644 index 0000000..fea011e --- /dev/null +++ b/src/main/resources/icons/showLogs_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + +