From 3d47840aa8e19153cce87dfc803174c9d9523962 Mon Sep 17 00:00:00 2001 From: hstyi Date: Wed, 25 Jun 2025 11:52:53 +0800 Subject: [PATCH] feat: WSL support on Windows --- src/main/kotlin/app/termora/Icons.kt | 4 + .../kotlin/app/termora/NewHostDialogV2.kt | 1 + .../app/termora/actions/NewHostAction.kt | 10 +- .../app/termora/plugin/PluginManager.kt | 5 + .../plugin/internal/wsl/WSLDistribution.kt | 8 + .../plugin/internal/wsl/WSLHostOptionsPane.kt | 298 ++++++++++++++++++ .../plugin/internal/wsl/WSLHostTerminalTab.kt | 58 ++++ .../plugin/internal/wsl/WSLInternalPlugin.kt | 24 ++ .../internal/wsl/WSLProtocolHostPanel.kt | 36 +++ .../wsl/WSLProtocolHostPanelExtension.kt | 24 ++ .../internal/wsl/WSLProtocolProvider.kt | 30 ++ .../wsl/WSLProtocolProviderExtension.kt | 14 + .../termora/plugin/internal/wsl/WSLSupport.kt | 45 +++ .../protocol/ProtocolHostPanelExtension.kt | 5 + .../kotlin/app/termora/snippet/SnippetTree.kt | 22 +- .../app/termora/transfer/TransportPanel.kt | 18 +- .../kotlin/app/termora/tree/NewHostTree.kt | 48 +-- ...MoreInfoSimpleTreeCellRendererExtension.kt | 9 + .../kotlin/app/termora/tree/SimpleTree.kt | 16 +- .../termora/tree/SimpleTreeCellRenderer.kt | 4 +- src/main/resources/i18n/messages.properties | 3 + .../resources/i18n/messages_zh_CN.properties | 2 + .../resources/i18n/messages_zh_TW.properties | 2 + src/main/resources/icons/almalinux.svg | 1 + src/main/resources/icons/debian.svg | 1 + src/main/resources/icons/fedora.svg | 1 + src/main/resources/icons/ubuntu.svg | 1 + 27 files changed, 622 insertions(+), 68 deletions(-) create mode 100644 src/main/kotlin/app/termora/plugin/internal/wsl/WSLDistribution.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/wsl/WSLHostOptionsPane.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/wsl/WSLHostTerminalTab.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/wsl/WSLInternalPlugin.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolHostPanel.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolHostPanelExtension.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolProvider.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolProviderExtension.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/wsl/WSLSupport.kt create mode 100644 src/main/resources/icons/almalinux.svg create mode 100644 src/main/resources/icons/debian.svg create mode 100644 src/main/resources/icons/fedora.svg create mode 100644 src/main/resources/icons/ubuntu.svg diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt index 99da77b..50ec042 100644 --- a/src/main/kotlin/app/termora/Icons.kt +++ b/src/main/kotlin/app/termora/Icons.kt @@ -92,6 +92,10 @@ object Icons { 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") } + val debian by lazy { DynamicIcon("icons/debian.svg") } + val fedora by lazy { DynamicIcon("icons/fedora.svg") } + val almalinux by lazy { DynamicIcon("icons/almalinux.svg") } + val ubuntu by lazy { DynamicIcon("icons/ubuntu.svg") } val success by lazy { DynamicIcon("icons/success.svg", "icons/success_dark.svg") } val errorDialog by lazy { DynamicIcon("icons/errorDialog.svg", "icons/errorDialog_dark.svg") } val network by lazy { DynamicIcon("icons/network.svg", "icons/network_dark.svg") } diff --git a/src/main/kotlin/app/termora/NewHostDialogV2.kt b/src/main/kotlin/app/termora/NewHostDialogV2.kt index 6fbaa56..8179527 100644 --- a/src/main/kotlin/app/termora/NewHostDialogV2.kt +++ b/src/main/kotlin/app/termora/NewHostDialogV2.kt @@ -59,6 +59,7 @@ class NewHostDialogV2(owner: Window, private val editHost: Host? = null) : Dialo toolbar.add(Box.createHorizontalGlue()) val extensions = ProtocolHostPanelExtension.extensions + .filter { it.canCreateProtocolHostPanel() } for ((index, extension) in extensions.withIndex()) { val protocol = extension.getProtocolProvider().getProtocol() val icon = FlatSVGIcon( diff --git a/src/main/kotlin/app/termora/actions/NewHostAction.kt b/src/main/kotlin/app/termora/actions/NewHostAction.kt index 4c3cd84..33ef5ab 100644 --- a/src/main/kotlin/app/termora/actions/NewHostAction.kt +++ b/src/main/kotlin/app/termora/actions/NewHostAction.kt @@ -2,7 +2,6 @@ package app.termora.actions import app.termora.NewHostDialogV2 import app.termora.tree.HostTreeNode -import app.termora.tree.NewHostTreeModel import javax.swing.tree.TreePath class NewHostAction : AnAction() { @@ -38,12 +37,9 @@ class NewHostAction : AnAction() { ) val newNode = HostTreeNode(host) - val model = tree.model - - if (model is NewHostTreeModel) { - model.insertNodeInto(newNode, lastNode, lastNode.childCount) - tree.selectionPath = TreePath(model.getPathToRoot(newNode)) - } + val model = tree.simpleTreeModel + model.insertNodeInto(newNode, lastNode, lastNode.childCount) + tree.selectionPath = TreePath(model.getPathToRoot(newNode)) } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/PluginManager.kt b/src/main/kotlin/app/termora/plugin/PluginManager.kt index e797495..08a9d8b 100644 --- a/src/main/kotlin/app/termora/plugin/PluginManager.kt +++ b/src/main/kotlin/app/termora/plugin/PluginManager.kt @@ -11,6 +11,7 @@ import app.termora.plugin.internal.rdp.RDPInternalPlugin import app.termora.plugin.internal.serial.SerialInternalPlugin import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin import app.termora.plugin.internal.ssh.SSHInternalPlugin +import app.termora.plugin.internal.wsl.WSLInternalPlugin import app.termora.swingCoroutineScope import app.termora.transfer.internal.local.LocalPlugin import app.termora.transfer.internal.sftp.SFTPPlugin @@ -115,6 +116,10 @@ internal class PluginManager private constructor() { plugins.add(PluginDescriptor(LocalInternalPlugin(), origin = PluginOrigin.Internal, version = version)) // rdp plugin plugins.add(PluginDescriptor(RDPInternalPlugin(), origin = PluginOrigin.Internal, version = version)) + // wsl plugin +// if (SystemUtils.IS_OS_WINDOWS) { + plugins.add(PluginDescriptor(WSLInternalPlugin(), origin = PluginOrigin.Internal, version = version)) +// } // sftp pty plugin plugins.add(PluginDescriptor(SFTPPtyInternalPlugin(), origin = PluginOrigin.Internal, version = version)) diff --git a/src/main/kotlin/app/termora/plugin/internal/wsl/WSLDistribution.kt b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLDistribution.kt new file mode 100644 index 0000000..76f4284 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLDistribution.kt @@ -0,0 +1,8 @@ +package app.termora.plugin.internal.wsl + +data class WSLDistribution( + val guid: String, + val distributionName: String, + val flavor:String, + val basePath: String, +) \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/wsl/WSLHostOptionsPane.kt b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLHostOptionsPane.kt new file mode 100644 index 0000000..5ed37e8 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLHostOptionsPane.kt @@ -0,0 +1,298 @@ +package app.termora.plugin.internal.wsl + +import app.termora.* +import com.formdev.flatlaf.FlatClientProperties +import com.formdev.flatlaf.ui.FlatTextBorder +import com.jgoodies.forms.builder.FormBuilder +import com.jgoodies.forms.layout.FormLayout +import org.apache.commons.lang3.StringUtils +import java.awt.BorderLayout +import java.awt.Component +import java.awt.KeyboardFocusManager +import java.awt.Window +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.nio.charset.Charset +import javax.swing.* + +internal open class WSLHostOptionsPane : OptionsPane() { + protected val generalOption = GeneralOption() + protected val terminalOption = TerminalOption() + protected val owner: Window get() = SwingUtilities.getWindowAncestor(this) + + init { + addOption(generalOption) + addOption(terminalOption) + } + + + open fun getHost(): Host { + val name = generalOption.nameTextField.text + val protocol = WSLProtocolProvider.PROTOCOL + val host = (generalOption.hostComboBox.selectedItem as WSLDistribution).distributionName + + val options = Options.Companion.Default.copy( + encoding = terminalOption.charsetComboBox.selectedItem as String, + env = terminalOption.environmentTextArea.text, + startupCommand = terminalOption.startupCommandTextField.text, + ) + + return Host( + name = name, + protocol = protocol, + host = host, + options = options, + sort = System.currentTimeMillis(), + remark = generalOption.remarkTextArea.text, + ) + } + + fun setHost(host: Host) { + generalOption.nameTextField.text = host.name + generalOption.hostComboBox.selectedItem = host.host + generalOption.remarkTextArea.text = host.remark + generalOption.hostComboBox.selectedItem = null + terminalOption.startupCommandTextField.text = host.options.startupCommand + terminalOption.environmentTextArea.text = host.options.env + terminalOption.charsetComboBox.selectedItem = host.options.encoding + + for (i in 0 until generalOption.hostComboBox.itemCount) { + if (generalOption.hostComboBox.getItemAt(i).distributionName == host.host) { + generalOption.hostComboBox.selectedIndex = i + break + } + } + + } + + fun validateFields(): Boolean { + // general + return (validateField(generalOption.nameTextField) + || validateField(generalOption.hostComboBox)).not() + } + + /** + * 返回 true 表示有错误 + */ + private fun validateField(textField: JTextField): Boolean { + if (textField.isEnabled && textField.text.isBlank()) { + setOutlineError(textField) + return true + } + return false + } + + /** + * 返回 true 表示有错误 + */ + private fun validateField(comboBox: JComboBox<*>): Boolean { + val selectedItem = comboBox.selectedItem + if (comboBox.isEnabled && (selectedItem == null || (selectedItem is String && selectedItem.isBlank()))) { + selectOptionJComponent(comboBox) + comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR) + comboBox.requestFocusInWindow() + return true + } + return false + } + + private fun setOutlineError(textField: JTextField) { + selectOptionJComponent(textField) + textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR) + textField.requestFocusInWindow() + } + + protected inner class GeneralOption : JPanel(BorderLayout()), Option { + val nameTextField = OutlineTextField(128) + val hostComboBox = OutlineComboBox() + val remarkTextArea = FixedLengthTextArea(512) + + init { + initView() + initEvents() + } + + private fun initView() { + + + hostComboBox.renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component? { + val text = if (value is WSLDistribution) value.distributionName else value + val c = super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus) + icon = null + if (value is WSLDistribution) { + icon = if (StringUtils.containsIgnoreCase(value.flavor, "debian")) { + Icons.debian + } else if (StringUtils.containsIgnoreCase(value.flavor, "ubuntu")) { + Icons.ubuntu + } else if (StringUtils.containsIgnoreCase(value.flavor, "fedora")) { + Icons.fedora + } else if (StringUtils.containsIgnoreCase(value.flavor, "alma")) { + Icons.almalinux + } else { + Icons.linux + } + } + return c + } + } + + add(getCenterComponent(), BorderLayout.CENTER) + } + + private fun initEvents() { + for (distribution in WSLSupport.getDistributions()) { + hostComboBox.addItem(distribution) + } + + + addComponentListener(object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) { + SwingUtilities.invokeLater { nameTextField.requestFocusInWindow() } + removeComponentListener(this) + } + }) + } + + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.settings + } + + override fun getTitle(): String { + return I18n.getString("termora.new-host.general") + } + + override fun getJComponent(): JComponent { + return this + } + + private fun getCenterComponent(): JComponent { + val layout = FormLayout( + "left:pref, $FORM_MARGIN, default:grow", + "pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref" + ) + remarkTextArea.setFocusTraversalKeys( + KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS) + ) + remarkTextArea.setFocusTraversalKeys( + KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS, + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS) + ) + + remarkTextArea.rows = 8 + remarkTextArea.lineWrap = true + remarkTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4) + + var rows = 1 + val step = 2 + val panel = FormBuilder.create().layout(layout).debug(false) + .add("${I18n.getString("termora.new-host.general.name")}:").xy(1, rows) + .add(nameTextField).xy(3, rows).apply { rows += step } + + .add("${I18n.getString("termora.new-host.wsl.distribution")}:").xy(1, rows) + .add(hostComboBox).xy(3, rows).apply { rows += step } + + .add("${I18n.getString("termora.new-host.general.remark")}:").xy(1, rows) + .add(JScrollPane(remarkTextArea).apply { border = FlatTextBorder() }) + .xy(3, rows).apply { rows += step } + + .build() + + + return panel + } + + } + + protected inner class TerminalOption : JPanel(BorderLayout()), Option { + val charsetComboBox = JComboBox() + val startupCommandTextField = OutlineTextField() + val environmentTextArea = FixedLengthTextArea(2048) + + + init { + initView() + initEvents() + } + + private fun initView() { + add(getCenterComponent(), BorderLayout.CENTER) + + startupCommandTextField.placeholderText = "--cd ~" + + + environmentTextArea.setFocusTraversalKeys( + KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS) + ) + environmentTextArea.setFocusTraversalKeys( + KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS, + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS) + ) + + environmentTextArea.rows = 8 + environmentTextArea.lineWrap = true + environmentTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4) + + for (e in Charset.availableCharsets()) { + charsetComboBox.addItem(e.key) + } + + charsetComboBox.selectedItem = "UTF-8" + + } + + private fun initEvents() { + + } + + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.terminal + } + + override fun getTitle(): String { + return I18n.getString("termora.new-host.terminal") + } + + override fun getJComponent(): JComponent { + return this + } + + private fun getCenterComponent(): JComponent { + val layout = FormLayout( + "left:pref, $FORM_MARGIN, default:grow", + "pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref" + ) + + var rows = 1 + val step = 2 + val panel = FormBuilder.create().layout(layout) + .add("${I18n.getString("termora.new-host.terminal.encoding")}:").xy(1, rows) + .add(charsetComboBox).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.terminal.startup-commands")}:").xy(1, rows) + .add(startupCommandTextField).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.terminal.env")}:").xy(1, rows) + .add(JScrollPane(environmentTextArea).apply { border = FlatTextBorder() }).xy(3, rows) + .apply { rows += step } + .build() + + + return panel + } + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/wsl/WSLHostTerminalTab.kt b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLHostTerminalTab.kt new file mode 100644 index 0000000..bc18985 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLHostTerminalTab.kt @@ -0,0 +1,58 @@ +package app.termora.plugin.internal.wsl + +import app.termora.Host +import app.termora.PtyConnectorFactory +import app.termora.PtyHostTerminalTab +import app.termora.WindowScope +import app.termora.terminal.PtyConnector +import org.apache.commons.io.Charsets +import org.apache.commons.io.FileUtils +import org.apache.commons.lang3.StringUtils +import java.nio.charset.StandardCharsets +import java.util.regex.Pattern + +class WSLHostTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) { + companion object { + fun parseCommand(command: String): List { + val result = mutableListOf() + val matcher = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(command) + + while (matcher.find()) { + if (matcher.group(1) != null) { + result.add(matcher.group(1)) // 处理双引号部分 + } else { + result.add(matcher.group(2).replace("\\\\ ", " ")) + } + } + return result + } + } + + override suspend fun openPtyConnector(): PtyConnector { + val winSize = terminalPanel.winSize() + val drive = System.getenv("SystemRoot") + val wsl = FileUtils.getFile(drive, "System32", "wsl.exe").absolutePath + val commands = mutableListOf() + commands.add(wsl) + commands.add("-d") + commands.add(host.host) + + if (StringUtils.isNoneBlank(host.options.startupCommand)) { + commands.addAll(parseCommand(host.options.startupCommand)) + } + + val ptyConnector = PtyConnectorFactory.getInstance().createPtyConnector( + commands = commands.toTypedArray(), + rows = winSize.rows, cols = winSize.cols, + env = host.options.envs(), + charset = Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8), + ) + + return ptyConnector + } + + + override fun sendStartupCommand(ptyConnector: PtyConnector, bytes: ByteArray) { + // Nothing + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/wsl/WSLInternalPlugin.kt b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLInternalPlugin.kt new file mode 100644 index 0000000..1d44a37 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLInternalPlugin.kt @@ -0,0 +1,24 @@ +package app.termora.plugin.internal.wsl + +import app.termora.plugin.Extension +import app.termora.plugin.InternalPlugin +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProviderExtension + +internal class WSLInternalPlugin : InternalPlugin() { + init { + support.addExtension(ProtocolProviderExtension::class.java) { WSLProtocolProviderExtension.instance } + support.addExtension(ProtocolHostPanelExtension::class.java) { WSLProtocolHostPanelExtension.instance } + } + + override fun getName(): String { + return "WSL Protocol" + } + + + override fun getExtensions(clazz: Class): List { + return support.getExtensions(clazz) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolHostPanel.kt b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolHostPanel.kt new file mode 100644 index 0000000..fd8339d --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolHostPanel.kt @@ -0,0 +1,36 @@ +package app.termora.plugin.internal.wsl + +import app.termora.Disposer +import app.termora.Host +import app.termora.protocol.ProtocolHostPanel +import java.awt.BorderLayout + +class WSLProtocolHostPanel : ProtocolHostPanel() { + private val pane = WSLHostOptionsPane() + + init { + initView() + initEvents() + } + + + private fun initView() { + add(pane, BorderLayout.CENTER) + Disposer.register(this, pane) + } + + private fun initEvents() {} + + + override fun getHost(): Host { + return pane.getHost() + } + + override fun setHost(host: Host) { + pane.setHost(host) + } + + override fun validateFields(): Boolean { + return pane.validateFields() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolHostPanelExtension.kt b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolHostPanelExtension.kt new file mode 100644 index 0000000..1931e51 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolHostPanelExtension.kt @@ -0,0 +1,24 @@ +package app.termora.plugin.internal.wsl + +import app.termora.protocol.ProtocolHostPanel +import app.termora.protocol.ProtocolHostPanelExtension +import app.termora.protocol.ProtocolProvider + +internal class WSLProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension { + companion object { + val instance by lazy { WSLProtocolHostPanelExtension() } + + } + + override fun getProtocolProvider(): ProtocolProvider { + return WSLProtocolProvider.instance + } + + override fun canCreateProtocolHostPanel(): Boolean { + return WSLSupport.isSupported + } + + override fun createProtocolHostPanel(): ProtocolHostPanel { + return WSLProtocolHostPanel() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolProvider.kt b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolProvider.kt new file mode 100644 index 0000000..e35a9e1 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolProvider.kt @@ -0,0 +1,30 @@ +package app.termora.plugin.internal.wsl + +import app.termora.* +import app.termora.actions.DataProvider +import app.termora.protocol.GenericProtocolProvider + +internal class WSLProtocolProvider private constructor() : GenericProtocolProvider { + companion object { + val instance by lazy { WSLProtocolProvider() } + const val PROTOCOL = "WSL" + } + + override fun getProtocol(): String { + return PROTOCOL + } + + override fun createTerminalTab(dataProvider: DataProvider, windowScope: WindowScope, host: Host): TerminalTab { + return WSLHostTerminalTab(windowScope, host) + } + + override fun getIcon(width: Int, height: Int): DynamicIcon { + return Icons.linux + } + + override fun canCreateTerminalTab(dataProvider: DataProvider, windowScope: WindowScope, host: Host): Boolean { + return WSLSupport.isSupported + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolProviderExtension.kt b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolProviderExtension.kt new file mode 100644 index 0000000..51ccba3 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLProtocolProviderExtension.kt @@ -0,0 +1,14 @@ +package app.termora.plugin.internal.wsl + +import app.termora.protocol.ProtocolProvider +import app.termora.protocol.ProtocolProviderExtension + +internal class WSLProtocolProviderExtension private constructor() : ProtocolProviderExtension { + companion object { + val instance by lazy { WSLProtocolProviderExtension() } + } + + override fun getProtocolProvider(): ProtocolProvider { + return WSLProtocolProvider.instance + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/wsl/WSLSupport.kt b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLSupport.kt new file mode 100644 index 0000000..186def3 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/wsl/WSLSupport.kt @@ -0,0 +1,45 @@ +package app.termora.plugin.internal.wsl + +import com.formdev.flatlaf.util.SystemInfo +import com.sun.jna.platform.win32.Advapi32Util +import com.sun.jna.platform.win32.WinReg +import org.apache.commons.io.FileUtils +import org.apache.commons.lang3.StringUtils + + +object WSLSupport { + val isSupported by lazy { checkSupported() } + + private fun checkSupported(): Boolean { + if (SystemInfo.isWindows.not()) return false + val drive = System.getenv("SystemRoot") ?: return false + val wsl = FileUtils.getFile(drive, "System32", "wsl.exe") + return wsl.exists() + } + + fun getDistributions(): List { + if (isSupported.not()) return emptyList() + + val baseKeyPath = "Software\\Microsoft\\Windows\\CurrentVersion\\Lxss" + val guids = Advapi32Util.registryGetKeys(WinReg.HKEY_CURRENT_USER, baseKeyPath) + val distributions = mutableListOf() + + for (guid in guids) { + val key = baseKeyPath + "\\" + guid + val distroName = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "DistributionName") + val basePath = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "BasePath") + val flavor = Advapi32Util.registryGetStringValue(WinReg.HKEY_CURRENT_USER, key, "Flavor") + if (StringUtils.isAnyBlank(distroName, guid, basePath, flavor)) continue + distributions.add( + WSLDistribution( + guid = guid, + flavor = flavor, + basePath = basePath, + distributionName = distroName + ) + ) + } + + return distributions + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/protocol/ProtocolHostPanelExtension.kt b/src/main/kotlin/app/termora/protocol/ProtocolHostPanelExtension.kt index e5db6ef..e194bd6 100644 --- a/src/main/kotlin/app/termora/protocol/ProtocolHostPanelExtension.kt +++ b/src/main/kotlin/app/termora/protocol/ProtocolHostPanelExtension.kt @@ -16,6 +16,11 @@ interface ProtocolHostPanelExtension : Extension { */ fun getProtocolProvider(): ProtocolProvider + /** + * 是否可以创建协议主机面板 + */ + fun canCreateProtocolHostPanel(): Boolean = true + /** * 创建协议主机面板 */ diff --git a/src/main/kotlin/app/termora/snippet/SnippetTree.kt b/src/main/kotlin/app/termora/snippet/SnippetTree.kt index f8ef4d8..9137572 100644 --- a/src/main/kotlin/app/termora/snippet/SnippetTree.kt +++ b/src/main/kotlin/app/termora/snippet/SnippetTree.kt @@ -15,7 +15,7 @@ import javax.swing.SwingUtilities import javax.swing.tree.TreePath class SnippetTree : SimpleTree() { - override val model = SnippetTreeModel() + override val simpleTreeModel = SnippetTreeModel() private val snippetManager get() = SnippetManager.getInstance() @@ -25,7 +25,7 @@ class SnippetTree : SimpleTree() { } private fun initViews() { - super.setModel(model) + super.setModel(simpleTreeModel) isEditable = true dragEnabled = true dropMode = DropMode.ON_OR_INSERT @@ -68,16 +68,16 @@ class SnippetTree : SimpleTree() { newFile(SnippetTreeNode(snippet)) } - rename.addActionListener { startEditingAtPath(TreePath(model.getPathToRoot(lastNode))) } - refresh.addActionListener { model.reload(lastNode) } + rename.addActionListener { startEditingAtPath(TreePath(simpleTreeModel.getPathToRoot(lastNode))) } + refresh.addActionListener { simpleTreeModel.reload(lastNode) } expandAll.addActionListener { for (node in getSelectionSimpleTreeNodes(true)) { - expandPath(TreePath(model.getPathToRoot(node))) + expandPath(TreePath(simpleTreeModel.getPathToRoot(node))) } } colspanAll.addActionListener { for (node in getSelectionSimpleTreeNodes(true).reversed()) { - collapsePath(TreePath(model.getPathToRoot(node))) + collapsePath(TreePath(simpleTreeModel.getPathToRoot(node))) } } remove.addActionListener(object : AnAction() { @@ -94,7 +94,7 @@ class SnippetTree : SimpleTree() { ) { for (c in nodes) { snippetManager.addSnippet(c.data.copy(deleted = true, updateDate = System.currentTimeMillis())) - model.removeNodeFromParent(c) + simpleTreeModel.removeNodeFromParent(c) // 将所有子孙也删除 for (child in c.getAllChildren()) { snippetManager.addSnippet( @@ -110,7 +110,7 @@ class SnippetTree : SimpleTree() { }) - rename.isEnabled = lastNode != model.root + rename.isEnabled = lastNode != simpleTreeModel.root remove.isEnabled = rename.isEnabled newFolder.isEnabled = lastNode.data.type == SnippetType.Folder newSnippet.isEnabled = newFolder.isEnabled @@ -130,18 +130,18 @@ class SnippetTree : SimpleTree() { val n = node as? SnippetTreeNode ?: return n.data = n.data.copy(name = text, updateDate = System.currentTimeMillis()) snippetManager.addSnippet(n.data) - model.nodeStructureChanged(n) + simpleTreeModel.nodeStructureChanged(n) } override fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>, index: Int) { // 从原来的父移除 - model.removeNodeFromParent(node) + simpleTreeModel.removeNodeFromParent(node) val nNode = node as? SnippetTreeNode ?: return val nParent = parent as? SnippetTreeNode ?: return nNode.data = nNode.data.copy(parentId = nParent.data.id, updateDate = System.currentTimeMillis()) - model.insertNodeInto(nNode, nParent, index) + simpleTreeModel.insertNodeInto(nNode, nParent, index) } override fun getSelectionSimpleTreeNodes(include: Boolean): List { diff --git a/src/main/kotlin/app/termora/transfer/TransportPanel.kt b/src/main/kotlin/app/termora/transfer/TransportPanel.kt index ce8d4b1..0e48cd1 100644 --- a/src/main/kotlin/app/termora/transfer/TransportPanel.kt +++ b/src/main/kotlin/app/termora/transfer/TransportPanel.kt @@ -5,6 +5,7 @@ import app.termora.* import app.termora.actions.DataProvider import app.termora.database.DatabaseManager import app.termora.plugin.ExtensionManager +import app.termora.plugin.internal.wsl.WSLHostTerminalTab import app.termora.transfer.TransportTableModel.Attributes import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.components.FlatToolBar @@ -47,7 +48,6 @@ import java.util.* import java.util.concurrent.CompletableFuture import java.util.concurrent.Future import java.util.concurrent.atomic.AtomicBoolean -import java.util.regex.Pattern import java.util.stream.Stream import javax.swing.* import javax.swing.TransferHandler @@ -952,7 +952,7 @@ class TransportPanel( val p = localPath.absolutePathString() if (editCommand.isNotBlank()) { - ProcessBuilder(parseCommand(MessageFormat.format(editCommand, p))).start() + ProcessBuilder(WSLHostTerminalTab.parseCommand(MessageFormat.format(editCommand, p))).start() } else if (SystemInfo.isMacOS) { ProcessBuilder("open", "-a", "TextEdit", "-W", p).start().onExit() .whenComplete { _, _ -> if (disposed.get().not()) Disposer.dispose(disposable) } @@ -973,20 +973,6 @@ class TransportPanel( return disposable } - private fun parseCommand(command: String): List { - val result = mutableListOf() - val matcher = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(command) - - while (matcher.find()) { - if (matcher.group(1) != null) { - result.add(matcher.group(1)) // 处理双引号部分 - } else { - result.add(matcher.group(2).replace("\\\\ ", " ")) - } - } - return result - } - override fun dispose() { transferIds.clear() } diff --git a/src/main/kotlin/app/termora/tree/NewHostTree.kt b/src/main/kotlin/app/termora/tree/NewHostTree.kt index a7262f5..95aba0f 100644 --- a/src/main/kotlin/app/termora/tree/NewHostTree.kt +++ b/src/main/kotlin/app/termora/tree/NewHostTree.kt @@ -73,7 +73,7 @@ class NewHostTree : SimpleTree(), Disposable { get() = enableManager.isShowTags() set(value) = enableManager.setShowTags(value) private var isPopupMenu = false - override val model = NewHostTreeModel.getInstance() + override val simpleTreeModel = NewHostTreeModel.getInstance() /** * 是否允许显示右键菜单 @@ -95,7 +95,7 @@ class NewHostTree : SimpleTree(), Disposable { } private fun initViews() { - super.setModel(model) + super.setModel(simpleTreeModel) isEditable = true dragEnabled = true isRootVisible = false @@ -124,7 +124,7 @@ class NewHostTree : SimpleTree(), Disposable { if (e.keyCode == KeyEvent.VK_ENTER && doubleClickConnection) { val nodes = getSelectionSimpleTreeNodes() if (nodes.size == 1 && nodes.first().isFolder) { - val path = TreePath(model.getPathToRoot(nodes.first())) + val path = TreePath(simpleTreeModel.getPathToRoot(nodes.first())) if (isExpanded(path)) { collapsePath(path) } else { @@ -161,7 +161,7 @@ class NewHostTree : SimpleTree(), Disposable { override fun canImport(support: TransferHandler.TransferSupport): Boolean { val dropLocation = support.dropLocation as? DropLocation ?: return false val node = dropLocation.path.lastPathComponent as? SimpleTreeNode<*> ?: return false - return node != model.getRoot() + return node != simpleTreeModel.getRoot() } override fun canCreateTransferable(c: JComponent): Boolean { @@ -178,7 +178,7 @@ class NewHostTree : SimpleTree(), Disposable { val tags = TagManager.getInstance().getTags(lastNode.host.ownerId) val nodes = getSelectionSimpleTreeNodes() val fullNodes = getSelectionSimpleTreeNodes(true) - val lastNodeParent = lastNode.parent ?: model.root + val lastNodeParent = lastNode.parent ?: simpleTreeModel.root val lastHost = lastNode.host val hasTeamNode = nodes.any { it is TeamTreeNode } @@ -252,8 +252,8 @@ class NewHostTree : SimpleTree(), Disposable { parentId = lastNode.id, ) val node = HostTreeNode(host) - model.insertNodeInto(node, lastNode, lastNode.folderCount) - selectionPath = TreePath(model.getPathToRoot(node)) + simpleTreeModel.insertNodeInto(node, lastNode, lastNode.folderCount) + selectionPath = TreePath(simpleTreeModel.getPathToRoot(node)) startEditingAtPath(selectionPath) } remove.addActionListener(object : ActionListener { @@ -268,7 +268,7 @@ class NewHostTree : SimpleTree(), Disposable { ) == JOptionPane.YES_OPTION ) { for (c in nodes) { - model.removeNodeFromParent(c) + simpleTreeModel.removeNodeFromParent(c) } } } @@ -278,20 +278,20 @@ class NewHostTree : SimpleTree(), Disposable { val p = c.parent ?: continue val newNode = copyNode(c, p.host.id) // 先入 Model - model.insertNodeInto(newNode, p, lastNodeParent.getIndex(c) + 1) + simpleTreeModel.insertNodeInto(newNode, p, lastNodeParent.getIndex(c) + 1) // 开启编辑 - selectionPath = TreePath(model.getPathToRoot(newNode)) + selectionPath = TreePath(simpleTreeModel.getPathToRoot(newNode)) } } - rename.addActionListener { startEditingAtPath(TreePath(model.getPathToRoot(lastNode))) } + rename.addActionListener { startEditingAtPath(TreePath(simpleTreeModel.getPathToRoot(lastNode))) } expandAll.addActionListener { for (node in fullNodes) { - expandPath(TreePath(model.getPathToRoot(node))) + expandPath(TreePath(simpleTreeModel.getPathToRoot(node))) } } colspanAll.addActionListener { for (node in fullNodes.reversed()) { - collapsePath(TreePath(model.getPathToRoot(node))) + collapsePath(TreePath(simpleTreeModel.getPathToRoot(node))) } } newHost.addActionListener(object : ActionListener { @@ -306,8 +306,8 @@ class NewHostTree : SimpleTree(), Disposable { ) val newNode = HostTreeNode(host) - model.insertNodeInto(newNode, lastNode, lastNode.childCount) - selectionPath = TreePath(model.getPathToRoot(newNode)) + simpleTreeModel.insertNodeInto(newNode, lastNode, lastNode.childCount) + selectionPath = TreePath(simpleTreeModel.getPathToRoot(newNode)) } }) property.addActionListener(object : ActionListener { @@ -318,10 +318,10 @@ class NewHostTree : SimpleTree(), Disposable { dialog.isVisible = true val host = dialog.host ?: return lastNode.host = host - model.nodeStructureChanged(lastNode) + simpleTreeModel.nodeStructureChanged(lastNode) } }) - refresh.addActionListener { model.reload(lastNode) } + refresh.addActionListener { simpleTreeModel.reload(lastNode) } newMenu.isEnabled = lastNode.isFolder remove.isEnabled = getSelectionSimpleTreeNodes().none { it.id == "0" } && hasTeamNode.not() @@ -353,7 +353,7 @@ class NewHostTree : SimpleTree(), Disposable { tags.add(tag.id) } lastNode.host = lastHost.copy(options = lastHost.options.copy(tags = tags)) - model.nodeStructureChanged(lastNode) + simpleTreeModel.nodeStructureChanged(lastNode) } } @@ -387,7 +387,7 @@ class NewHostTree : SimpleTree(), Disposable { override fun onRenamed(node: SimpleTreeNode<*>, text: String) { val lastNode = node as? HostTreeNode ?: return lastNode.host = lastNode.host.copy(name = text) - model.nodeStructureChanged(lastNode) + simpleTreeModel.nodeStructureChanged(lastNode) } override fun createTreeModelListener(): TreeModelListener { @@ -402,7 +402,7 @@ class NewHostTree : SimpleTree(), Disposable { override fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>, index: Int) { if (parent !is HostTreeNode || node !is HostTreeNode) return // 从原来的父移除 - model.removeNodeFromParent(node) + simpleTreeModel.removeNodeFromParent(node) node.data = node.data.copy( id = randomUUID(), @@ -411,7 +411,7 @@ class NewHostTree : SimpleTree(), Disposable { ownerType = parent.host.ownerType, ) - model.insertNodeInto(node, parent, index) + simpleTreeModel.insertNodeInto(node, parent, index) // 子也需要变基 for ((idx, e) in node.childrenNode().withIndex()) { @@ -445,7 +445,7 @@ class NewHostTree : SimpleTree(), Disposable { if (host.isFolder) { for (child in node.children()) { if (child is HostTreeNode) { - model.insertNodeInto( + simpleTreeModel.insertNodeInto( copyNode(child, newHost.id, idGenerator, level + 1), newNode, node.getIndex(child) ) @@ -650,10 +650,10 @@ class NewHostTree : SimpleTree(), Disposable { } // 重新加载 - model.reload(folder) + simpleTreeModel.reload(folder) // expand root - expandPath(TreePath(model.getPathToRoot(folder))) + expandPath(TreePath(simpleTreeModel.getPathToRoot(folder))) } private fun parseFromWindTerm(folder: HostTreeNode, file: File): List { diff --git a/src/main/kotlin/app/termora/tree/ShowMoreInfoSimpleTreeCellRendererExtension.kt b/src/main/kotlin/app/termora/tree/ShowMoreInfoSimpleTreeCellRendererExtension.kt index 1615353..582da3c 100644 --- a/src/main/kotlin/app/termora/tree/ShowMoreInfoSimpleTreeCellRendererExtension.kt +++ b/src/main/kotlin/app/termora/tree/ShowMoreInfoSimpleTreeCellRendererExtension.kt @@ -5,6 +5,7 @@ import app.termora.plugin.internal.extension.DynamicExtensionHandler import app.termora.plugin.internal.rdp.RDPProtocolProvider import app.termora.plugin.internal.serial.SerialProtocolProvider import app.termora.plugin.internal.ssh.SSHProtocolProvider +import app.termora.plugin.internal.wsl.WSLProtocolProvider import org.apache.commons.lang3.StringUtils import java.awt.Graphics2D import javax.swing.JComponent @@ -32,6 +33,12 @@ class ShowMoreInfoSimpleTreeCellRendererExtension private constructor() : Simple .let { Disposer.register(this, it) } } + // wsl + // key: guid + // value: name + private val map = mutableMapOf() + + @Suppress("CascadeIf") override fun createAnnotations( tree: JTree, value: Any?, @@ -58,6 +65,8 @@ class ShowMoreInfoSimpleTreeCellRendererExtension private constructor() : Simple } } else if (host.protocol == SerialProtocolProvider.PROTOCOL) { text = host.options.serialComm.port + } else if (host.protocol == WSLProtocolProvider.PROTOCOL) { + text = host.host } } diff --git a/src/main/kotlin/app/termora/tree/SimpleTree.kt b/src/main/kotlin/app/termora/tree/SimpleTree.kt index 2acfc85..d4b3314 100644 --- a/src/main/kotlin/app/termora/tree/SimpleTree.kt +++ b/src/main/kotlin/app/termora/tree/SimpleTree.kt @@ -22,7 +22,7 @@ import kotlin.math.min open class SimpleTree : JXTree() { - protected open val model get() = super.getModel() as SimpleTreeModel<*> + open val simpleTreeModel get() = super.getModel() as SimpleTreeModel<*> private val editor = OutlineTextField(64) protected val tree get() = this @@ -123,12 +123,12 @@ open class SimpleTree : JXTree() { if (tree.canCreateTransferable(c).not()) return null val nodes = getSelectionSimpleTreeNodes().toMutableList() if (nodes.isEmpty()) return null - if (nodes.contains(model.root)) return null + if (nodes.contains(simpleTreeModel.root)) return null val iterator = nodes.iterator() while (iterator.hasNext()) { val node = iterator.next() - val parents = model.getPathToRoot(node).filter { it != node } + val parents = simpleTreeModel.getPathToRoot(node).filter { it != node } if (parents.any { nodes.contains(it) }) { iterator.remove() } @@ -211,11 +211,11 @@ open class SimpleTree : JXTree() { } rebase(e, node, min(index, node.childCount)) - selectionPath = TreePath(model.getPathToRoot(e)) + selectionPath = TreePath(simpleTreeModel.getPathToRoot(e)) } // 先展开最顶级的 - expandPath(TreePath(model.getPathToRoot(node))) + expandPath(TreePath(simpleTreeModel.getPathToRoot(node))) return true } @@ -245,8 +245,8 @@ open class SimpleTree : JXTree() { private fun newNode(newNode: SimpleTreeNode<*>, index: Int): Boolean { val lastNode = lastSelectedPathComponent if (lastNode !is SimpleTreeNode<*>) return false - model.insertNodeInto(newNode, lastNode, index) - selectionPath = TreePath(model.getPathToRoot(newNode)) + simpleTreeModel.insertNodeInto(newNode, lastNode, index) + selectionPath = TreePath(simpleTreeModel.getPathToRoot(newNode)) startEditingAtPath(selectionPath) return true } @@ -291,7 +291,7 @@ open class SimpleTree : JXTree() { } protected open fun isCellEditable(e: EventObject?): Boolean { - return getLastSelectedPathNode() != model.root + return getLastSelectedPathNode() != simpleTreeModel.root } protected open fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>, index: Int) { diff --git a/src/main/kotlin/app/termora/tree/SimpleTreeCellRenderer.kt b/src/main/kotlin/app/termora/tree/SimpleTreeCellRenderer.kt index 5198c3b..4b7eca8 100644 --- a/src/main/kotlin/app/termora/tree/SimpleTreeCellRenderer.kt +++ b/src/main/kotlin/app/termora/tree/SimpleTreeCellRenderer.kt @@ -87,9 +87,9 @@ open class SimpleTreeCellRenderer : DefaultTreeCellRenderer() { val icon = this.icon if (icon is DynamicIcon && FlatLaf.isLafDark().not()) { val oldColorFilter = icon.colorFilter - icon.colorFilter = colorFilter +// icon.colorFilter = colorFilter icon.paintIcon(c, g, x, y) - icon.colorFilter = oldColorFilter +// icon.colorFilter = oldColorFilter return } } diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 8709316..23266d3 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -212,6 +212,9 @@ termora.new-host.tunneling.delete=${termora.remove} termora.new-host.rdp.desktop-placeholder=Default full screen (e.g. 1920×1080) termora.new-host.rdp.resolution=Resolution + +termora.new-host.wsl.distribution=DistroName + termora.new-host.test-connection=Test Connection termora.new-host.test-connection-successful=Connection successful diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 5a73a4d..dc42d1b 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -205,6 +205,8 @@ termora.new-host.tunneling.delete=${termora.remove} termora.new-host.rdp.desktop-placeholder=默认全屏(例如:1920×1080) termora.new-host.rdp.resolution=分辨率 +termora.new-host.wsl.distribution=分发版 + termora.new-host.jump-hosts=跳板机 # Key manager diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 12d634e..f4f9512 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -204,6 +204,8 @@ termora.new-host.tunneling.delete=${termora.remove} termora.new-host.rdp.desktop-placeholder=預設全螢幕(例如:1920×1080) termora.new-host.rdp.resolution=解析度 +termora.new-host.wsl.distribution=分發版 + termora.new-host.jump-hosts=跳板機 # Key manager diff --git a/src/main/resources/icons/almalinux.svg b/src/main/resources/icons/almalinux.svg new file mode 100644 index 0000000..2a23b5b --- /dev/null +++ b/src/main/resources/icons/almalinux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/debian.svg b/src/main/resources/icons/debian.svg new file mode 100644 index 0000000..612232a --- /dev/null +++ b/src/main/resources/icons/debian.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/fedora.svg b/src/main/resources/icons/fedora.svg new file mode 100644 index 0000000..f88caa7 --- /dev/null +++ b/src/main/resources/icons/fedora.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/ubuntu.svg b/src/main/resources/icons/ubuntu.svg new file mode 100644 index 0000000..d93a99c --- /dev/null +++ b/src/main/resources/icons/ubuntu.svg @@ -0,0 +1 @@ + \ No newline at end of file