diff --git a/src/main/kotlin/app/termora/Database.kt b/src/main/kotlin/app/termora/Database.kt index ebbe281..6b47748 100644 --- a/src/main/kotlin/app/termora/Database.kt +++ b/src/main/kotlin/app/termora/Database.kt @@ -5,6 +5,7 @@ import app.termora.highlight.KeywordHighlight import app.termora.keymap.Keymap import app.termora.keymgr.OhKeyPair import app.termora.macro.Macro +import app.termora.snippet.Snippet import app.termora.sync.SyncType import app.termora.terminal.CursorStyle import jetbrains.exodus.bindings.StringBinding @@ -12,7 +13,6 @@ import jetbrains.exodus.env.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString import org.apache.commons.io.IOUtils import org.apache.commons.lang3.StringUtils import org.slf4j.LoggerFactory @@ -26,6 +26,7 @@ class Database private constructor(private val env: Environment) : Disposable { companion object { private const val KEYMAP_STORE = "Keymap" private const val HOST_STORE = "Host" + private const val SNIPPET_STORE = "Snippet" private const val KEYWORD_HIGHLIGHT_STORE = "KeywordHighlight" private const val MACRO_STORE = "Macro" private const val KEY_PAIR_STORE = "KeyPair" @@ -105,17 +106,6 @@ class Database private constructor(private val env: Environment) : Disposable { } } - fun removeAllHost() { - env.executeInTransaction { tx -> - val store = env.openStore(HOST_STORE, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx) - store.openCursor(tx).use { - while (it.next) { - it.deleteCurrent() - } - } - } - } - fun removeAllKeyPair() { env.executeInTransaction { tx -> val store = env.openStore(KEY_PAIR_STORE, StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, tx) @@ -152,15 +142,32 @@ class Database private constructor(private val env: Environment) : Disposable { } } - fun removeHost(id: String) { + fun addSnippet(snippet: Snippet) { + var text = ohMyJson.encodeToString(snippet) + if (doorman.isWorking()) { + text = doorman.encrypt(text) + } env.executeInTransaction { - delete(it, HOST_STORE, id) + put(it, SNIPPET_STORE, snippet.id, text) if (log.isDebugEnabled) { - log.debug("Removed Host: $id") + log.debug("Added Snippet: ${snippet.id} , ${snippet.name}") } } } + + fun getSnippets(): Collection { + val isWorking = doorman.isWorking() + return env.computeInTransaction { tx -> + openCursor(tx, SNIPPET_STORE) { _, value -> + if (isWorking) + ohMyJson.decodeFromString(doorman.decrypt(value)) + else + ohMyJson.decodeFromString(value) + }.values + } + } + fun getKeywordHighlights(): Collection { return env.computeInTransaction { tx -> openCursor(tx, KEYWORD_HIGHLIGHT_STORE) { _, value -> @@ -621,6 +628,7 @@ class Database private constructor(private val env: Environment) : Disposable { */ var rangeHosts by BooleanPropertyDelegate(true) var rangeKeyPairs by BooleanPropertyDelegate(true) + var rangeSnippets by BooleanPropertyDelegate(true) var rangeKeywordHighlights by BooleanPropertyDelegate(true) var rangeMacros by BooleanPropertyDelegate(true) var rangeKeymap by BooleanPropertyDelegate(true) diff --git a/src/main/kotlin/app/termora/FilterableHostTreeModel.kt b/src/main/kotlin/app/termora/FilterableHostTreeModel.kt index 8146e93..1819eca 100644 --- a/src/main/kotlin/app/termora/FilterableHostTreeModel.kt +++ b/src/main/kotlin/app/termora/FilterableHostTreeModel.kt @@ -41,7 +41,7 @@ class FilterableHostTreeModel( continue } - if (c.host.protocol != Protocol.Folder) { + if (c.data.protocol != Protocol.Folder) { if (filters.isNotEmpty() && filters.none { it.apply(c) }) { continue } diff --git a/src/main/kotlin/app/termora/HostTreeNode.kt b/src/main/kotlin/app/termora/HostTreeNode.kt index d8207b3..995dcce 100644 --- a/src/main/kotlin/app/termora/HostTreeNode.kt +++ b/src/main/kotlin/app/termora/HostTreeNode.kt @@ -1,17 +1,29 @@ package app.termora -import javax.swing.tree.DefaultMutableTreeNode +import com.formdev.flatlaf.icons.FlatTreeClosedIcon +import com.formdev.flatlaf.icons.FlatTreeOpenIcon +import javax.swing.Icon import javax.swing.tree.TreeNode -class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) { +class HostTreeNode(host: Host) : SimpleTreeNode(host) { companion object { private val hostManager get() = HostManager.getInstance() } + var host: Host + get() = data + set(value) = setUserObject(value) + + override val isFolder: Boolean + get() = data.protocol == Protocol.Folder + + override val id: String + get() = data.id + /** * 如果要重新赋值,记得修改 [Host.updateDate] 否则下次取出时可能时缓存的 */ - var host: Host + override var data: Host get() { val cacheHost = hostManager.getHost((userObject as Host).id) val myHost = userObject as Host @@ -22,22 +34,23 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) { } set(value) = setUserObject(value) - val folderCount - get() = children().toList().count { if (it is HostTreeNode) it.host.protocol == Protocol.Folder else false } + override val folderCount + get() = children().toList().count { if (it is HostTreeNode) it.data.protocol == Protocol.Folder else false } override fun getParent(): HostTreeNode? { return super.getParent() as HostTreeNode? } - fun getAllChildren(): List { - val children = mutableListOf() - for (child in children()) { - if (child is HostTreeNode) { - children.add(child) - children.addAll(child.getAllChildren()) - } + override fun getAllChildren(): List { + return super.getAllChildren().filterIsInstance() + } + + override fun getIcon(selected: Boolean, expanded: Boolean, hasFocus: Boolean): Icon { + return when (host.protocol) { + Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() + Protocol.Serial -> if (selected && hasFocus) Icons.plugin.dark else Icons.plugin + else -> if (selected && hasFocus) Icons.terminal.dark else Icons.terminal } - return children } fun childrenNode(): List { @@ -57,7 +70,7 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) { private fun deepClone(newNode: HostTreeNode, oldNode: HostTreeNode, scopes: Set = emptySet()) { for (child in oldNode.childrenNode()) { - if (scopes.isNotEmpty() && !scopes.contains(child.host.protocol)) continue + if (scopes.isNotEmpty() && !scopes.contains(child.data.protocol)) continue val newChildNode = child.clone() as HostTreeNode deepClone(newChildNode, child, scopes) newNode.add(newChildNode) @@ -65,7 +78,7 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) { } override fun clone(): Any { - val newNode = HostTreeNode(host) + val newNode = HostTreeNode(data) newNode.children = null newNode.parent = null return newNode @@ -74,7 +87,7 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) { override fun isNodeChild(aNode: TreeNode?): Boolean { if (aNode is HostTreeNode) { for (node in childrenNode()) { - if (node.host == aNode.host) { + if (node.data == aNode.data) { return true } } @@ -88,10 +101,10 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) { other as HostTreeNode - return host == other.host + return data == other.data } override fun hashCode(): Int { - return host.hashCode() + return data.hashCode() } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt index caff885..34b7beb 100644 --- a/src/main/kotlin/app/termora/Icons.kt +++ b/src/main/kotlin/app/termora/Icons.kt @@ -94,6 +94,9 @@ object Icons { val left by lazy { DynamicIcon("icons/left.svg", "icons/left_dark.svg") } val right by lazy { DynamicIcon("icons/right.svg", "icons/right_dark.svg") } val dotListFiles by lazy { DynamicIcon("icons/dotListFiles.svg", "icons/dotListFiles_dark.svg") } + val anyType by lazy { DynamicIcon("icons/anyType.svg", "icons/anyType_dark.svg") } + val toolWindowJsonPath by lazy { DynamicIcon("icons/toolWindowJsonPath.svg", "icons/toolWindowJsonPath_dark.svg") } + val codeSpan by lazy { DynamicIcon("icons/codeSpan.svg", "icons/codeSpan_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 applyNotConflictsLeft by lazy { diff --git a/src/main/kotlin/app/termora/NewHostTree.kt b/src/main/kotlin/app/termora/NewHostTree.kt index 0e4fe48..a59b7ea 100644 --- a/src/main/kotlin/app/termora/NewHostTree.kt +++ b/src/main/kotlin/app/termora/NewHostTree.kt @@ -5,8 +5,6 @@ import app.termora.actions.AnActionEvent import app.termora.actions.OpenHostAction import app.termora.transport.SFTPAction import com.formdev.flatlaf.extras.components.FlatPopupMenu -import com.formdev.flatlaf.icons.FlatTreeClosedIcon -import com.formdev.flatlaf.icons.FlatTreeOpenIcon import kotlinx.serialization.Serializable import kotlinx.serialization.json.* import org.apache.commons.csv.CSVFormat @@ -19,43 +17,31 @@ import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.exception.ExceptionUtils import org.ini4j.Ini import org.ini4j.Reg -import org.jdesktop.swingx.JXTree import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer import org.slf4j.LoggerFactory import org.w3c.dom.Element import org.w3c.dom.NodeList import java.awt.Component -import java.awt.Dimension -import java.awt.datatransfer.DataFlavor -import java.awt.datatransfer.Transferable -import java.awt.datatransfer.UnsupportedFlavorException import java.awt.event.* import java.io.* import java.util.* import java.util.function.Function import javax.swing.* -import javax.swing.event.CellEditorListener -import javax.swing.event.ChangeEvent -import javax.swing.event.PopupMenuEvent -import javax.swing.event.PopupMenuListener import javax.swing.filechooser.FileNameExtensionFilter import javax.swing.tree.TreePath import javax.swing.tree.TreeSelectionModel import javax.xml.parsers.DocumentBuilderFactory import javax.xml.xpath.XPathConstants import javax.xml.xpath.XPathFactory -import kotlin.math.min -class NewHostTree : JXTree() { +class NewHostTree : SimpleTree() { companion object { private val log = LoggerFactory.getLogger(NewHostTree::class.java) private val CSV_HEADERS = arrayOf("Folders", "Label", "Hostname", "Port", "Username", "Protocol") } - private val tree = this - private val editor = OutlineTextField(64) private val hostManager get() = HostManager.getInstance() private val properties get() = Database.getDatabase().properties private val owner get() = SwingUtilities.getWindowAncestor(this) @@ -64,7 +50,7 @@ class NewHostTree : JXTree() { get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean() set(value) = properties.putString("HostTree.showMoreInfo", value.toString()) - private val model = NewHostTreeModel() + override val model = NewHostTreeModel() /** * 是否允许显示右键菜单 @@ -89,7 +75,6 @@ class NewHostTree : JXTree() { isRootVisible = true dropMode = DropMode.ON_OR_INSERT selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION - editor.preferredSize = Dimension(220, 0) // renderer setCellRenderer(object : DefaultXTreeCellRenderer() { @@ -135,74 +120,16 @@ class NewHostTree : JXTree() { val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus) - icon = when (host.protocol) { - Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() - Protocol.Serial -> if (sel && tree.hasFocus()) Icons.plugin.dark else Icons.plugin - else -> if (sel && tree.hasFocus()) Icons.terminal.dark else Icons.terminal - } + icon = node.getIcon(sel, expanded, hasFocus) return c } }) - // rename - setCellEditor(object : DefaultCellEditor(editor) { - override fun isCellEditable(e: EventObject?): Boolean { - if (e is MouseEvent) { - return false - } - return super.isCellEditable(e) - } - - override fun getCellEditorValue(): Any { - val node = lastSelectedPathComponent as HostTreeNode - return node.host - } - }) } private fun initEvents() { - // 右键选中 + // double click addMouseListener(object : MouseAdapter() { - override fun mousePressed(e: MouseEvent) { - if (!SwingUtilities.isRightMouseButton(e)) { - return - } - - requestFocusInWindow() - - val selectionRows = selectionModel.selectionRows - - val selRow = getClosestRowForLocation(e.x, e.y) - if (selRow < 0) { - selectionModel.clearSelection() - return - } else if (selectionRows != null && selectionRows.contains(selRow)) { - return - } - - selectionPath = getPathForLocation(e.x, e.y) - - setSelectionRow(selRow) - } - - }) - - // contextmenu - addMouseListener(object : MouseAdapter() { - override fun mousePressed(e: MouseEvent) { - if (!(SwingUtilities.isRightMouseButton(e))) { - return - } - - if (Objects.isNull(lastSelectedPathComponent)) { - return - } - - if (contextmenu) { - SwingUtilities.invokeLater { showContextmenu(e) } - } - } - override fun mouseClicked(e: MouseEvent) { if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { val lastNode = lastSelectedPathComponent as? HostTreeNode ?: return @@ -216,7 +143,7 @@ class NewHostTree : JXTree() { addKeyListener(object : KeyAdapter() { override fun keyPressed(e: KeyEvent) { if (e.keyCode == KeyEvent.VK_ENTER && doubleClickConnection) { - val nodes = getSelectionHostTreeNodes(false) + val nodes = getSelectionSimpleTreeNodes() if (nodes.size == 1 && nodes.first().host.protocol == Protocol.Folder) { val path = TreePath(model.getPathToRoot(nodes.first())) if (isExpanded(path)) { @@ -225,7 +152,7 @@ class NewHostTree : JXTree() { expandPath(path) } } else { - for (node in getSelectionHostTreeNodes(true)) { + for (node in getSelectionSimpleTreeNodes(true)) { openHostAction?.actionPerformed(OpenHostActionEvent(e.source, node.host, e)) } } @@ -233,145 +160,16 @@ class NewHostTree : JXTree() { } }) - // rename - getCellEditor().addCellEditorListener(object : CellEditorListener { - override fun editingStopped(e: ChangeEvent) { - val lastHost = lastSelectedPathComponent - if (lastHost !is HostTreeNode || editor.text.isBlank() || editor.text == lastHost.host.name) { - return - } - lastHost.host = lastHost.host.copy(name = editor.text, updateDate = System.currentTimeMillis()) - hostManager.addHost(lastHost.host) - } - - override fun editingCanceled(e: ChangeEvent) { - } - }) - - // drag - transferHandler = object : TransferHandler() { - - override fun createTransferable(c: JComponent): Transferable? { - val nodes = getSelectionHostTreeNodes().toMutableList() - if (nodes.isEmpty()) return null - if (nodes.contains(model.root)) return null - - val iterator = nodes.iterator() - while (iterator.hasNext()) { - val node = iterator.next() - val parents = model.getPathToRoot(node).filter { it != node } - if (parents.any { nodes.contains(it) }) { - iterator.remove() - } - } - - return MoveHostTransferable(nodes) - } - - override fun getSourceActions(c: JComponent?): Int { - return MOVE - } - - override fun canImport(support: TransferSupport): Boolean { - if (support.component != tree) return false - val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false - val node = dropLocation.path.lastPathComponent as? HostTreeNode ?: return false - if (!support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) return false - val nodes = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as? List<*>) - ?.filterIsInstance() ?: return false - if (nodes.isEmpty()) return false - if (node.host.protocol != Protocol.Folder) return false - - for (e in nodes) { - // 禁止拖拽到自己的子下面 - if (dropLocation.path.equals(TreePath(e.path)) || TreePath(e.path).isDescendant(dropLocation.path)) { - return false - } - - // 文件夹只能拖拽到文件夹的下面 - if (e.host.protocol == Protocol.Folder) { - if (dropLocation.childIndex > node.folderCount) { - return false - } - } else if (dropLocation.childIndex != -1) { - // 非文件夹也不能拖拽到文件夹的上面 - if (dropLocation.childIndex < node.folderCount) { - return false - } - } - - val p = e.parent ?: continue - // 如果是同级目录排序,那么判断是不是自己的上下,如果是的话也禁止 - if (p == node && dropLocation.childIndex != -1) { - val idx = p.getIndex(e) - if (dropLocation.childIndex in idx..idx + 1) { - return false - } - } - } - - support.setShowDropLocation(true) - - return true - } - - override fun importData(support: TransferSupport): Boolean { - val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false - val node = dropLocation.path.lastPathComponent as? HostTreeNode ?: return false - val nodes = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as? List<*>) - ?.filterIsInstance() ?: return false - - // 展开的 host id - val expanded = mutableSetOf(node.host.id) - for (e in nodes) { - e.getAllChildren().filter { isExpanded(TreePath(model.getPathToRoot(it))) } - .map { it.host.id }.forEach { expanded.add(it) } - } - - // 转移 - for (e in nodes) { - model.removeNodeFromParent(e) - e.host = e.host.copy(parentId = node.host.id, updateDate = System.currentTimeMillis()) - hostManager.addHost(e.host) - - if (dropLocation.childIndex == -1) { - if (e.host.protocol == Protocol.Folder) { - model.insertNodeInto(e, node, node.folderCount) - } else { - model.insertNodeInto(e, node, node.childCount) - } - } else { - if (e.host.protocol == Protocol.Folder) { - model.insertNodeInto(e, node, min(node.folderCount, dropLocation.childIndex)) - } else { - model.insertNodeInto(e, node, min(node.childCount, dropLocation.childIndex)) - } - } - - selectionPath = TreePath(model.getPathToRoot(e)) - } - - // 先展开最顶级的 - expandPath(TreePath(model.getPathToRoot(node))) - - for (child in node.getAllChildren()) { - if (expanded.contains(child.host.id)) { - expandPath(TreePath(model.getPathToRoot(child))) - } - } - - - - return true - } - } } - private fun showContextmenu(event: MouseEvent) { + override fun showContextmenu(evt: MouseEvent) { + if (!contextmenu) return val lastNode = lastSelectedPathComponent if (lastNode !is HostTreeNode) return + val nodes = getSelectionSimpleTreeNodes() + val fullNodes = getSelectionSimpleTreeNodes(true) val lastNodeParent = lastNode.parent ?: model.root val lastHost = lastNode.host @@ -443,7 +241,6 @@ class NewHostTree : JXTree() { } remove.addActionListener(object : ActionListener { override fun actionPerformed(e: ActionEvent) { - val nodes = getSelectionHostTreeNodes() if (nodes.isEmpty()) return if (OptionPane.showConfirmDialog( SwingUtilities.getWindowAncestor(tree), @@ -470,7 +267,7 @@ class NewHostTree : JXTree() { } }) copy.addActionListener { - for (c in getSelectionHostTreeNodes()) { + for (c in nodes) { val p = c.parent ?: continue val newNode = copyNode(c, p.host.id) model.insertNodeInto(newNode, p, lastNodeParent.getIndex(c) + 1) @@ -479,12 +276,12 @@ class NewHostTree : JXTree() { } rename.addActionListener { startEditingAtPath(TreePath(model.getPathToRoot(lastNode))) } expandAll.addActionListener { - for (node in getSelectionHostTreeNodes(true)) { + for (node in fullNodes) { expandPath(TreePath(model.getPathToRoot(node))) } } colspanAll.addActionListener { - for (node in getSelectionHostTreeNodes(true).reversed()) { + for (node in fullNodes.reversed()) { collapsePath(TreePath(model.getPathToRoot(node))) } } @@ -512,29 +309,10 @@ class NewHostTree : JXTree() { model.nodeStructureChanged(lastNode) } }) - refresh.addActionListener { - val expanded = mutableSetOf(lastNode.host.id) - for (e in lastNode.getAllChildren()) { - if (e.host.protocol == Protocol.Folder && isExpanded(TreePath(model.getPathToRoot(e)))) { - expanded.add(e.host.id) - } - } - - // 刷新 - model.reload(lastNode) - - // 先展开最顶级的 - expandPath(TreePath(model.getPathToRoot(lastNode))) - - for (child in lastNode.getAllChildren()) { - if (expanded.contains(child.host.id)) { - expandPath(TreePath(model.getPathToRoot(child))) - } - } - } + refresh.addActionListener { refreshNode(lastNode) } newMenu.isEnabled = lastHost.protocol == Protocol.Folder - remove.isEnabled = getSelectionHostTreeNodes().none { it == model.root } + remove.isEnabled = getSelectionSimpleTreeNodes().none { it == model.root } copy.isEnabled = remove.isEnabled rename.isEnabled = remove.isEnabled property.isEnabled = lastHost.protocol != Protocol.Folder @@ -542,28 +320,27 @@ class NewHostTree : JXTree() { importMenu.isEnabled = lastHost.protocol == Protocol.Folder // 如果选中了 SSH 服务器,那么才启用 - openWithSFTP.isEnabled = getSelectionHostTreeNodes(true).map { it.host }.any { it.protocol == Protocol.SSH } + openWithSFTP.isEnabled = fullNodes.map { it.host }.any { it.protocol == Protocol.SSH } openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled } - - popupMenu.addPopupMenuListener(object : PopupMenuListener { - override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { - tree.grabFocus() - } - - override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) { - tree.requestFocusInWindow() - } - - override fun popupMenuCanceled(e: PopupMenuEvent) { - } - - }) - - - popupMenu.show(this, event.x, event.y) + popupMenu.show(this, evt.x, evt.y) } + override fun onRenamed(node: SimpleTreeNode<*>, text: String) { + val lastNode = node as? HostTreeNode ?: return + lastNode.host = lastNode.host.copy(name = text, updateDate = System.currentTimeMillis()) + model.nodeStructureChanged(lastNode) + hostManager.addHost(lastNode.host) + } + + override fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>) { + val nNode = node as? HostTreeNode ?: return + val nParent = parent as? HostTreeNode ?: return + nNode.data = nNode.data.copy(parentId = nParent.id, updateDate = System.currentTimeMillis()) + hostManager.addHost(nNode.host) + } + + private fun copyNode( node: HostTreeNode, parentId: String, @@ -600,30 +377,14 @@ class NewHostTree : JXTree() { } - /** - * 包含孙子 - */ - fun getSelectionHostTreeNodes(include: Boolean = false): List { - val paths = selectionPaths ?: return emptyList() - if (paths.isEmpty()) return emptyList() - val nodes = mutableListOf() - val parents = paths.mapNotNull { it.lastPathComponent } - .filterIsInstance().toMutableList() - - if (include) { - while (parents.isNotEmpty()) { - val node = parents.removeFirst() - nodes.add(node) - parents.addAll(node.children().toList().filterIsInstance()) - } - } - - return if (include) nodes else parents + override fun getSelectionSimpleTreeNodes(include: Boolean): List { + return super.getSelectionSimpleTreeNodes(include).filterIsInstance() } + private fun openHosts(evt: EventObject, openInNewWindow: Boolean) { assertEventDispatchThread() - val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol != Protocol.Folder } + val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol != Protocol.Folder } if (nodes.isEmpty()) return val source = if (openInNewWindow) TermoraFrameManager.getInstance().createWindow().apply { isVisible = true } @@ -632,7 +393,7 @@ class NewHostTree : JXTree() { } private fun openWithSFTP(evt: EventObject) { - val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } + val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } if (nodes.isEmpty()) return val sftpAction = ActionManager.getInstance().getAction(app.termora.Actions.SFTP) as SFTPAction? ?: return @@ -643,7 +404,7 @@ class NewHostTree : JXTree() { } private fun openWithSFTPCommand(evt: EventObject) { - val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } + val nodes = getSelectionSimpleTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } if (nodes.isEmpty()) return for (host in nodes) { openHostAction.actionPerformed(OpenHostActionEvent(this, host.copy(protocol = Protocol.SFTPPty), evt)) @@ -1106,28 +867,5 @@ class NewHostTree : JXTree() { electerm, } - private class MoveHostTransferable(val nodes: List) : Transferable { - companion object { - val dataFlavor = - DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveHostTransferable::class.java.name}") - } - - - override fun getTransferDataFlavors(): Array { - return arrayOf(dataFlavor) - } - - override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean { - return dataFlavor == flavor - } - - override fun getTransferData(flavor: DataFlavor?): Any { - if (flavor == dataFlavor) { - return nodes - } - throw UnsupportedFlavorException(flavor) - } - - } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/NewHostTreeDialog.kt b/src/main/kotlin/app/termora/NewHostTreeDialog.kt index bb9e4cf..31acb68 100644 --- a/src/main/kotlin/app/termora/NewHostTreeDialog.kt +++ b/src/main/kotlin/app/termora/NewHostTreeDialog.kt @@ -60,7 +60,7 @@ class NewHostTreeDialog( } override fun doOKAction() { - hosts = tree.getSelectionHostTreeNodes(true) + hosts = tree.getSelectionSimpleTreeNodes(true) .filter { filter.apply(it) } .map { it.host } diff --git a/src/main/kotlin/app/termora/NewHostTreeModel.kt b/src/main/kotlin/app/termora/NewHostTreeModel.kt index e5c3d65..9e7abd2 100644 --- a/src/main/kotlin/app/termora/NewHostTreeModel.kt +++ b/src/main/kotlin/app/termora/NewHostTreeModel.kt @@ -1,12 +1,11 @@ package app.termora import org.apache.commons.lang3.StringUtils -import javax.swing.tree.DefaultTreeModel import javax.swing.tree.MutableTreeNode import javax.swing.tree.TreeNode -class NewHostTreeModel : DefaultTreeModel( +class NewHostTreeModel : SimpleTreeModel( HostTreeNode( Host( id = "0", diff --git a/src/main/kotlin/app/termora/SettingsOptionsPane.kt b/src/main/kotlin/app/termora/SettingsOptionsPane.kt index c77247c..a92216c 100644 --- a/src/main/kotlin/app/termora/SettingsOptionsPane.kt +++ b/src/main/kotlin/app/termora/SettingsOptionsPane.kt @@ -15,6 +15,8 @@ import app.termora.keymgr.OhKeyPair import app.termora.macro.Macro import app.termora.macro.MacroManager import app.termora.native.FileChooser +import app.termora.snippet.Snippet +import app.termora.snippet.SnippetManager import app.termora.sync.SyncConfig import app.termora.sync.SyncRange import app.termora.sync.SyncType @@ -67,6 +69,7 @@ class SettingsOptionsPane : OptionsPane() { private val owner get() = SwingUtilities.getWindowAncestor(this@SettingsOptionsPane) private val database get() = Database.getDatabase() private val hostManager get() = HostManager.getInstance() + private val snippetManager get() = SnippetManager.getInstance() private val keymapManager get() = KeymapManager.getInstance() private val macroManager get() = MacroManager.getInstance() private val actionManager get() = ActionManager.getInstance() @@ -561,6 +564,7 @@ class SettingsOptionsPane : OptionsPane() { val sync get() = database.sync val hostsCheckBox = JCheckBox(I18n.getString("termora.welcome.my-hosts")) val keysCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keys")) + val snippetsCheckBox = JCheckBox(I18n.getString("termora.snippet.title")) val keywordHighlightsCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keyword-highlights")) val macrosCheckBox = JCheckBox(I18n.getString("termora.macro")) val keymapCheckBox = JCheckBox(I18n.getString("termora.settings.keymap")) @@ -665,6 +669,7 @@ class SettingsOptionsPane : OptionsPane() { keysCheckBox.addActionListener { refreshButtons() } hostsCheckBox.addActionListener { refreshButtons() } + snippetsCheckBox.addActionListener { refreshButtons() } keywordHighlightsCheckBox.addActionListener { refreshButtons() } } @@ -672,6 +677,7 @@ class SettingsOptionsPane : OptionsPane() { private fun refreshButtons() { sync.rangeKeyPairs = keysCheckBox.isSelected sync.rangeHosts = hostsCheckBox.isSelected + sync.rangeSnippets = snippetsCheckBox.isSelected sync.rangeKeywordHighlights = keywordHighlightsCheckBox.isSelected downloadConfigButton.isEnabled = keysCheckBox.isSelected || hostsCheckBox.isSelected @@ -848,6 +854,17 @@ class SettingsOptionsPane : OptionsPane() { } } + if (ranges.contains(SyncRange.Snippets)) { + val snippets = json["snippets"] + if (snippets is JsonArray) { + ohMyJson.runCatching { decodeFromJsonElement>(snippets.jsonArray) }.onSuccess { + for (snippet in it) { + snippetManager.addSnippet(snippet) + } + } + } + } + if (ranges.contains(SyncRange.KeyPairs)) { val keyPairs = json["keyPairs"] if (keyPairs is JsonArray) { @@ -909,6 +926,9 @@ class SettingsOptionsPane : OptionsPane() { if (syncConfig.ranges.contains(SyncRange.Hosts)) { put("hosts", ohMyJson.encodeToJsonElement(hostManager.hosts())) } + if (syncConfig.ranges.contains(SyncRange.Snippets)) { + put("snippets", ohMyJson.encodeToJsonElement(snippetManager.snippets())) + } if (syncConfig.ranges.contains(SyncRange.KeyPairs)) { put("keyPairs", ohMyJson.encodeToJsonElement(keyManager.getOhKeyPairs())) } @@ -978,6 +998,9 @@ class SettingsOptionsPane : OptionsPane() { if (keymapCheckBox.isSelected) { range.add(SyncRange.Keymap) } + if (snippetsCheckBox.isSelected) { + range.add(SyncRange.Snippets) + } return SyncConfig( type = typeComboBox.selectedItem as SyncType, token = String(tokenTextField.password), @@ -1054,6 +1077,7 @@ class SettingsOptionsPane : OptionsPane() { keymapCheckBox.isEnabled = false keywordHighlightsCheckBox.isEnabled = false hostsCheckBox.isEnabled = false + snippetsCheckBox.isEnabled = false domainTextField.isEnabled = false if (push) { @@ -1083,6 +1107,7 @@ class SettingsOptionsPane : OptionsPane() { uploadConfigButton.isEnabled = true keysCheckBox.isEnabled = true hostsCheckBox.isEnabled = true + snippetsCheckBox.isEnabled = true typeComboBox.isEnabled = true macrosCheckBox.isEnabled = true keymapCheckBox.isEnabled = true @@ -1144,12 +1169,14 @@ class SettingsOptionsPane : OptionsPane() { typeComboBox.addItem(SyncType.WebDAV) hostsCheckBox.isFocusable = false + snippetsCheckBox.isFocusable = false keysCheckBox.isFocusable = false keywordHighlightsCheckBox.isFocusable = false macrosCheckBox.isFocusable = false keymapCheckBox.isFocusable = false hostsCheckBox.isSelected = sync.rangeHosts + snippetsCheckBox.isSelected = sync.rangeSnippets keysCheckBox.isSelected = sync.rangeKeyPairs keywordHighlightsCheckBox.isSelected = sync.rangeKeywordHighlights macrosCheckBox.isSelected = sync.rangeMacros @@ -1236,7 +1263,7 @@ class SettingsOptionsPane : OptionsPane() { .layout( FormLayout( "left:pref, $formMargin, left:pref, $formMargin, left:pref", - "pref, $formMargin, pref" + "pref, 2dlu, pref" ) ) .add(hostsCheckBox).xy(1, 1) @@ -1244,6 +1271,7 @@ class SettingsOptionsPane : OptionsPane() { .add(keywordHighlightsCheckBox).xy(5, 1) .add(macrosCheckBox).xy(1, 3) .add(keymapCheckBox).xy(3, 3) + .add(snippetsCheckBox).xy(5, 3) .build() var rows = 1 @@ -1612,13 +1640,15 @@ class SettingsOptionsPane : OptionsPane() { val hosts = hostManager.hosts() val keyPairs = keyManager.getOhKeyPairs() + val snippets = snippetManager.snippets() + // 获取到安全的属性,如果设置密码那表示之前并未加密 // 这里取出来之后重新存储加密 val properties = database.getSafetyProperties().map { Pair(it, it.getProperties()) } - val key = doorman.work(passwordTextField.password) hosts.forEach { hostManager.addHost(it) } + snippets.forEach { snippetManager.addSnippet(it) } keyPairs.forEach { keyManager.addOhKeyPair(it) } for (e in properties) { for ((k, v) in e.second) { diff --git a/src/main/kotlin/app/termora/SimpleTree.kt b/src/main/kotlin/app/termora/SimpleTree.kt new file mode 100644 index 0000000..2d5c709 --- /dev/null +++ b/src/main/kotlin/app/termora/SimpleTree.kt @@ -0,0 +1,343 @@ +package app.termora + +import org.jdesktop.swingx.JXTree +import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer +import java.awt.Component +import java.awt.Dimension +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.util.* +import javax.swing.* +import javax.swing.event.CellEditorListener +import javax.swing.event.ChangeEvent +import javax.swing.tree.TreePath +import kotlin.math.min + +open class SimpleTree : JXTree() { + + protected open val model get() = super.getModel() as SimpleTreeModel<*> + private val editor = OutlineTextField(64) + protected val tree get() = this + + init { + initViews() + initEvents() + } + + + private fun initViews() { + + + // renderer + setCellRenderer(object : DefaultXTreeCellRenderer() { + override fun getTreeCellRendererComponent( + tree: JTree, + value: Any, + sel: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean + ): Component { + val node = value as SimpleTreeNode<*> + val c = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus) + icon = node.getIcon(sel, expanded, hasFocus) + return c + } + }) + + // rename + setCellEditor(object : DefaultCellEditor(editor) { + override fun isCellEditable(e: EventObject?): Boolean { + if (e is MouseEvent || !tree.isCellEditable(e)) { + return false + } + + return super.isCellEditable(e).apply { + if (this) { + editor.preferredSize = Dimension(min(220, width - 64), 0) + } + } + } + + override fun getCellEditorValue(): Any? { + return getLastSelectedPathNode()?.data + } + + + }) + } + + private fun initEvents() { + // 右键选中 + addMouseListener(object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) { + if (!SwingUtilities.isRightMouseButton(e)) { + return + } + + requestFocusInWindow() + + val selectionRows = selectionModel.selectionRows + + val selRow = getClosestRowForLocation(e.x, e.y) + if (selRow < 0) { + selectionModel.clearSelection() + return + } else if (selectionRows != null && selectionRows.contains(selRow)) { + return + } + + selectionPath = getPathForLocation(e.x, e.y) + + setSelectionRow(selRow) + } + + }) + + // contextmenu + addMouseListener(object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) { + if (!(SwingUtilities.isRightMouseButton(e))) { + return + } + + if (Objects.isNull(lastSelectedPathComponent)) { + return + } + + showContextmenu(e) + } + + }) + + // rename + getCellEditor().addCellEditorListener(object : CellEditorListener { + override fun editingStopped(e: ChangeEvent) { + val node = getLastSelectedPathNode() ?: return + if (editor.text.isBlank() || editor.text == node.toString()) { + return + } + onRenamed(node, editor.text) + } + + override fun editingCanceled(e: ChangeEvent) { + } + }) + + // drag + transferHandler = object : TransferHandler() { + + override fun createTransferable(c: JComponent): Transferable? { + val nodes = getSelectionSimpleTreeNodes().toMutableList() + if (nodes.isEmpty()) return null + if (nodes.contains(model.root)) return null + + val iterator = nodes.iterator() + while (iterator.hasNext()) { + val node = iterator.next() + val parents = model.getPathToRoot(node).filter { it != node } + if (parents.any { nodes.contains(it) }) { + iterator.remove() + } + } + + return MoveNodeTransferable(nodes) + } + + override fun getSourceActions(c: JComponent?): Int { + return MOVE + } + + override fun canImport(support: TransferSupport): Boolean { + if (support.component != tree) return false + val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false + val path = dropLocation.path ?: return false + val node = path.lastPathComponent as? SimpleTreeNode<*> ?: return false + if (!support.isDataFlavorSupported(MoveNodeTransferable.dataFlavor)) return false + val nodes = (support.transferable.getTransferData(MoveNodeTransferable.dataFlavor) as? List<*>) + ?.filterIsInstance>() ?: return false + if (nodes.isEmpty()) return false + if (!node.isFolder) return false + + for (e in nodes) { + // 禁止拖拽到自己的子下面 + if (path.equals(TreePath(e.path)) || TreePath(e.path).isDescendant(path)) { + return false + } + + // 文件夹只能拖拽到文件夹的下面 + if (e.isFolder) { + if (dropLocation.childIndex > node.folderCount) { + return false + } + } else if (dropLocation.childIndex != -1) { + // 非文件夹也不能拖拽到文件夹的上面 + if (dropLocation.childIndex < node.folderCount) { + return false + } + } + + val p = e.parent ?: continue + // 如果是同级目录排序,那么判断是不是自己的上下,如果是的话也禁止 + if (p == node && dropLocation.childIndex != -1) { + val idx = p.getIndex(e) + if (dropLocation.childIndex in idx..idx + 1) { + return false + } + } + } + + support.setShowDropLocation(true) + + return true + } + + override fun importData(support: TransferSupport): Boolean { + val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false + val node = dropLocation.path.lastPathComponent as? SimpleTreeNode<*> ?: return false + val nodes = (support.transferable.getTransferData(MoveNodeTransferable.dataFlavor) as? List<*>) + ?.filterIsInstance>() ?: return false + + // 展开的 node + val expanded = mutableSetOf(node.id) + for (e in nodes) { + e.getAllChildren().filter { isExpanded(TreePath(model.getPathToRoot(it))) } + .map { it }.forEach { expanded.add(it.id) } + } + + // 转移 + for (e in nodes) { + model.removeNodeFromParent(e) + rebase(e, node) + + if (dropLocation.childIndex == -1) { + if (e.isFolder) { + model.insertNodeInto(e, node, node.folderCount) + } else { + model.insertNodeInto(e, node, node.childCount) + } + } else { + if (e.isFolder) { + model.insertNodeInto(e, node, min(node.folderCount, dropLocation.childIndex)) + } else { + model.insertNodeInto(e, node, min(node.childCount, dropLocation.childIndex)) + } + } + + selectionPath = TreePath(model.getPathToRoot(e)) + } + + // 先展开最顶级的 + expandPath(TreePath(model.getPathToRoot(node))) + + for (child in node.getAllChildren()) { + if (expanded.contains(child.id)) { + expandPath(TreePath(model.getPathToRoot(child))) + } + } + + return true + } + } + } + + protected open fun newFolder(newNode: SimpleTreeNode<*>): Boolean { + val lastNode = lastSelectedPathComponent + if (lastNode !is SimpleTreeNode<*>) return false + return newNode(newNode, lastNode.folderCount) + } + + protected open fun newFile(newNode: SimpleTreeNode<*>): Boolean { + val lastNode = lastSelectedPathComponent + if (lastNode !is SimpleTreeNode<*>) return false + return newNode(newNode, lastNode.childCount) + } + + 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)) + startEditingAtPath(selectionPath) + return true + } + + open fun getLastSelectedPathNode(): SimpleTreeNode<*>? { + return lastSelectedPathComponent as? SimpleTreeNode<*> + } + + + protected open fun showContextmenu(evt: MouseEvent) { + + } + + protected open fun onRenamed(node: SimpleTreeNode<*>, text: String) {} + + protected open fun refreshNode(node: SimpleTreeNode<*>) { + val state = TreeUtils.saveExpansionState(tree) + val rows = selectionRows + + model.reload(node) + + TreeUtils.loadExpansionState(tree, state) + + super.setSelectionRows(rows) + } + + /** + * 包含孙子 + */ + open fun getSelectionSimpleTreeNodes(include: Boolean = false): List> { + val paths = selectionPaths ?: return emptyList() + if (paths.isEmpty()) return emptyList() + val nodes = mutableListOf>() + val parents = paths.mapNotNull { it.lastPathComponent } + .filterIsInstance>().toMutableList() + + if (include) { + while (parents.isNotEmpty()) { + val node = parents.removeFirst() + nodes.add(node) + parents.addAll(node.children().toList().filterIsInstance>()) + } + } + + return if (include) nodes else parents + } + + protected open fun isCellEditable(e: EventObject?): Boolean { + return getLastSelectedPathNode() != model.root + } + + protected open fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>) { + + } + + private class MoveNodeTransferable(val nodes: List>) : Transferable { + companion object { + val dataFlavor = + DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveNodeTransferable::class.java.name}") + } + + + override fun getTransferDataFlavors(): Array { + return arrayOf(dataFlavor) + } + + override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean { + return dataFlavor == flavor + } + + override fun getTransferData(flavor: DataFlavor?): Any { + if (flavor == dataFlavor) { + return nodes + } + throw UnsupportedFlavorException(flavor) + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SimpleTreeModel.kt b/src/main/kotlin/app/termora/SimpleTreeModel.kt new file mode 100644 index 0000000..14b799a --- /dev/null +++ b/src/main/kotlin/app/termora/SimpleTreeModel.kt @@ -0,0 +1,11 @@ +package app.termora + +import javax.swing.tree.DefaultTreeModel + +abstract class SimpleTreeModel(root: SimpleTreeNode) : DefaultTreeModel(root) { + @Suppress("UNCHECKED_CAST") + override fun getRoot(): SimpleTreeNode { + return super.getRoot() as SimpleTreeNode + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SimpleTreeNode.kt b/src/main/kotlin/app/termora/SimpleTreeNode.kt new file mode 100644 index 0000000..8bf9a03 --- /dev/null +++ b/src/main/kotlin/app/termora/SimpleTreeNode.kt @@ -0,0 +1,37 @@ +package app.termora + +import javax.swing.Icon +import javax.swing.tree.DefaultMutableTreeNode + +abstract class SimpleTreeNode(data: T) : DefaultMutableTreeNode(data) { + @Suppress("UNCHECKED_CAST") + open var data: T + get() = userObject as T + set(value) = setUserObject(value) + + @Suppress("UNCHECKED_CAST") + override fun getParent(): SimpleTreeNode? { + return super.getParent() as SimpleTreeNode? + } + + open val folderCount: Int get() = 0 + + open fun getIcon(selected: Boolean, expanded: Boolean, hasFocus: Boolean): Icon? { + return null + } + + open val isFolder get() = false + + abstract val id: String + + @Suppress("UNCHECKED_CAST") + open fun getAllChildren(): List> { + val children = mutableListOf>() + for (child in children()) { + val c = child as? SimpleTreeNode ?: continue + children.add(c) + children.addAll(c.getAllChildren()) + } + return children + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/TerminalTab.kt b/src/main/kotlin/app/termora/TerminalTab.kt index a623633..fcf86ab 100644 --- a/src/main/kotlin/app/termora/TerminalTab.kt +++ b/src/main/kotlin/app/termora/TerminalTab.kt @@ -1,10 +1,11 @@ package app.termora +import app.termora.actions.DataProvider import java.beans.PropertyChangeListener import javax.swing.Icon import javax.swing.JComponent -interface TerminalTab : Disposable { +interface TerminalTab : Disposable, DataProvider { /** * 标题 diff --git a/src/main/kotlin/app/termora/TermoraToolBar.kt b/src/main/kotlin/app/termora/TermoraToolBar.kt index 725834f..c88c3a4 100644 --- a/src/main/kotlin/app/termora/TermoraToolBar.kt +++ b/src/main/kotlin/app/termora/TermoraToolBar.kt @@ -6,6 +6,7 @@ import app.termora.actions.AnAction import app.termora.actions.AnActionEvent import app.termora.actions.SettingsAction import app.termora.findeverywhere.FindEverywhereAction +import app.termora.snippet.SnippetAction import com.formdev.flatlaf.extras.components.FlatTabbedPane import com.formdev.flatlaf.util.SystemInfo import com.jetbrains.WindowDecorations @@ -42,6 +43,7 @@ class TermoraToolBar( */ fun getAllActions(): List { return listOf( + ToolBarAction(SnippetAction.SNIPPET, true), ToolBarAction(Actions.SFTP, true), ToolBarAction(Actions.TERMINAL_LOGGER, true), ToolBarAction(Actions.MACRO, true), diff --git a/src/main/kotlin/app/termora/TreeUtils.kt b/src/main/kotlin/app/termora/TreeUtils.kt index 429d292..e645b33 100644 --- a/src/main/kotlin/app/termora/TreeUtils.kt +++ b/src/main/kotlin/app/termora/TreeUtils.kt @@ -1,8 +1,8 @@ package app.termora +import org.apache.commons.lang3.StringUtils import javax.swing.JTree import javax.swing.tree.TreeModel -import javax.swing.tree.TreeNode object TreeUtils { /** @@ -31,16 +31,6 @@ object TreeUtils { return nodes } - fun parents(node: TreeNode): List { - val parents = mutableListOf() - var p = node.parent - while (p != null) { - parents.add(p) - p = p.parent - } - return parents - } - fun saveExpansionState(tree: JTree): String { val rows = mutableListOf() for (i in 0 until tree.rowCount) { @@ -63,15 +53,15 @@ object TreeUtils { } } - fun expandAll(tree: JTree) { - var j = tree.rowCount - var i = 0 - while (i < j) { - tree.expandRow(i) - i += 1 - j = tree.rowCount + fun saveSelectionRows(tree: JTree): String { + return tree.selectionRows?.joinToString(",") ?: StringUtils.EMPTY + } + + fun loadSelectionRows(tree: JTree, state: String) { + if (state.isBlank()) return + for (row in state.split(",").mapNotNull { it.toIntOrNull() }) { + tree.addSelectionRow(row) } } - } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/actions/ActionManager.kt b/src/main/kotlin/app/termora/actions/ActionManager.kt index 6d4e0f7..53b93f0 100644 --- a/src/main/kotlin/app/termora/actions/ActionManager.kt +++ b/src/main/kotlin/app/termora/actions/ActionManager.kt @@ -6,6 +6,7 @@ import app.termora.findeverywhere.FindEverywhereAction import app.termora.highlight.KeywordHighlightAction import app.termora.keymgr.KeyManagerAction import app.termora.macro.MacroAction +import app.termora.snippet.SnippetAction import app.termora.tlog.TerminalLoggerAction import app.termora.transport.SFTPAction import javax.swing.Action @@ -34,6 +35,7 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() { addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction()) addAction(Actions.SFTP, SFTPAction()) addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction()) + addAction(SnippetAction.SNIPPET, SnippetAction.getInstance()) addAction(Actions.MACRO, MacroAction()) addAction(Actions.KEY_MANAGER, KeyManagerAction()) diff --git a/src/main/kotlin/app/termora/actions/DataProvider.kt b/src/main/kotlin/app/termora/actions/DataProvider.kt index beb105c..6d4582d 100644 --- a/src/main/kotlin/app/termora/actions/DataProvider.kt +++ b/src/main/kotlin/app/termora/actions/DataProvider.kt @@ -17,5 +17,5 @@ interface DataProvider { /** * 数据提供 */ - fun getData(dataKey: DataKey): T? + fun getData(dataKey: DataKey): T? = null } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/actions/DataProviders.kt b/src/main/kotlin/app/termora/actions/DataProviders.kt index 22ff5e4..d51ff94 100644 --- a/src/main/kotlin/app/termora/actions/DataProviders.kt +++ b/src/main/kotlin/app/termora/actions/DataProviders.kt @@ -5,7 +5,7 @@ import app.termora.terminal.DataKey object DataProviders { val TerminalPanel = DataKey(app.termora.terminal.panel.TerminalPanel::class) val Terminal = DataKey(app.termora.terminal.Terminal::class) - val PtyConnector = DataKey(app.termora.terminal.PtyConnector::class) + val PtyConnector get() = DataKey.PtyConnector val TabbedPane = DataKey(app.termora.MyTabbedPane::class) val TerminalTabbed = DataKey(app.termora.TerminalTabbed::class) diff --git a/src/main/kotlin/app/termora/findeverywhere/ActionFindEverywhereResult.kt b/src/main/kotlin/app/termora/findeverywhere/ActionFindEverywhereResult.kt index 25c1254..48e6f58 100644 --- a/src/main/kotlin/app/termora/findeverywhere/ActionFindEverywhereResult.kt +++ b/src/main/kotlin/app/termora/findeverywhere/ActionFindEverywhereResult.kt @@ -6,6 +6,7 @@ import org.jdesktop.swingx.action.AbstractActionExt import java.awt.event.ActionEvent import javax.swing.Action import javax.swing.Icon +import javax.swing.SwingUtilities open class ActionFindEverywhereResult(private val action: Action) : FindEverywhereResult { private val isState: Boolean @@ -26,7 +27,7 @@ open class ActionFindEverywhereResult(private val action: Action) : FindEverywhe if (isState) { action.putValue(Action.SELECTED_KEY, !isSelected) } - action.actionPerformed(e) + SwingUtilities.invokeLater { action.actionPerformed(e) } } override fun getIcon(isSelected: Boolean): Icon { diff --git a/src/main/kotlin/app/termora/findeverywhere/QuickCommandFindEverywhereProvider.kt b/src/main/kotlin/app/termora/findeverywhere/QuickCommandFindEverywhereProvider.kt index c08dce3..899ec9f 100644 --- a/src/main/kotlin/app/termora/findeverywhere/QuickCommandFindEverywhereProvider.kt +++ b/src/main/kotlin/app/termora/findeverywhere/QuickCommandFindEverywhereProvider.kt @@ -5,6 +5,7 @@ import app.termora.I18n import app.termora.Icons import app.termora.actions.NewHostAction import app.termora.actions.OpenLocalTerminalAction +import app.termora.snippet.SnippetAction import com.formdev.flatlaf.FlatLaf import org.jdesktop.swingx.action.ActionManager import javax.swing.Icon @@ -21,6 +22,11 @@ class QuickCommandFindEverywhereProvider : FindEverywhereProvider { list.add(ActionFindEverywhereResult(it)) } + // Snippet + actionManager.getAction(SnippetAction.SNIPPET)?.let { + list.add(ActionFindEverywhereResult(it)) + } + // SFTP actionManager.getAction(Actions.SFTP)?.let { list.add(ActionFindEverywhereResult(it)) diff --git a/src/main/kotlin/app/termora/snippet/Snippet.kt b/src/main/kotlin/app/termora/snippet/Snippet.kt new file mode 100644 index 0000000..276a16a --- /dev/null +++ b/src/main/kotlin/app/termora/snippet/Snippet.kt @@ -0,0 +1,24 @@ +package app.termora.snippet + +import app.termora.toSimpleString +import kotlinx.serialization.Serializable +import org.apache.commons.lang3.StringUtils +import java.util.* + +enum class SnippetType { + Folder, + Snippet, +} + +@Serializable +data class Snippet( + val id: String = UUID.randomUUID().toSimpleString(), + val name: String, + val snippet: String = StringUtils.EMPTY, + val parentId: String = StringUtils.EMPTY, + val type: SnippetType = SnippetType.Snippet, + val deleted: Boolean = false, + val sort: Long = System.currentTimeMillis(), + val createDate: Long = System.currentTimeMillis(), + val updateDate: Long = System.currentTimeMillis(), +) \ No newline at end of file diff --git a/src/main/kotlin/app/termora/snippet/SnippetAction.kt b/src/main/kotlin/app/termora/snippet/SnippetAction.kt new file mode 100644 index 0000000..a61196d --- /dev/null +++ b/src/main/kotlin/app/termora/snippet/SnippetAction.kt @@ -0,0 +1,47 @@ +package app.termora.snippet + +import app.termora.ApplicationScope +import app.termora.I18n +import app.termora.Icons +import app.termora.actions.AnAction +import app.termora.actions.AnActionEvent +import app.termora.terminal.ControlCharacters +import app.termora.terminal.DataKey +import app.termora.terminal.Terminal + +class SnippetAction private constructor() : AnAction(I18n.getString("termora.snippet.title"), Icons.codeSpan) { + companion object { + fun getInstance(): SnippetAction { + return ApplicationScope.forApplicationScope().getOrCreate(SnippetAction::class) { SnippetAction() } + } + + const val SNIPPET = "SnippetAction" + } + + override fun actionPerformed(evt: AnActionEvent) { + SnippetDialog(evt.window).isVisible = true + } + + + fun runSnippet(snippet: Snippet, terminal: Terminal) { + if (snippet.type != SnippetType.Snippet) return + val terminalModel = terminal.getTerminalModel() + val map = mapOf( + "\\r" to ControlCharacters.CR, + "\\n" to ControlCharacters.LF, + "\\t" to ControlCharacters.TAB, + "\\a" to ControlCharacters.BEL, + "\\e" to ControlCharacters.ESC, + "\\b" to ControlCharacters.BS, + ) + + if (terminalModel.hasData(DataKey.PtyConnector)) { + var text = snippet.snippet + for (e in map.entries) { + text = text.replace(e.key, e.value.toString()) + } + val ptyConnector = terminalModel.getData(DataKey.PtyConnector) + ptyConnector.write(text.toByteArray(ptyConnector.getCharset())) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/snippet/SnippetBannerPanel.kt b/src/main/kotlin/app/termora/snippet/SnippetBannerPanel.kt new file mode 100644 index 0000000..a6d3e94 --- /dev/null +++ b/src/main/kotlin/app/termora/snippet/SnippetBannerPanel.kt @@ -0,0 +1,50 @@ +package app.termora.snippet + +import java.awt.* +import javax.swing.JComponent +import javax.swing.UIManager + +class SnippetBannerPanel(fontSize: Int = 12) : JComponent() { + private val banner = """ + _____ _ __ + / ___/____ (_)___ ____ ___ / /_ + \__ \/ __ \/ / __ \/ __ \/ _ \/ __/ + ___/ / / / / / /_/ / /_/ / __/ /_ +/____/_/ /_/_/ .___/ .___/\___/\__/ + /_/ /_/ +""".trimIndent().lines() + + init { + font = Font("JetBrains Mono", Font.PLAIN, fontSize) + preferredSize = Dimension(width, getFontMetrics(font).height * banner.size) + size = preferredSize + } + + override fun paintComponent(g: Graphics) { + if (g is Graphics2D) { + g.setRenderingHints( + RenderingHints( + RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON + ) + ) + } + + g.font = font + g.color = UIManager.getColor("TextField.placeholderForeground") + + val height = g.fontMetrics.height + val descent = g.fontMetrics.descent + val offset = width / 2 - g.fontMetrics.stringWidth(banner.maxBy { it.length }) / 2 + + for (i in banner.indices) { + var x = offset + val y = height * (i + 1) - descent + val chars = banner[i].toCharArray() + for (j in chars.indices) { + g.drawChars(chars, j, 1, x, y) + x += g.fontMetrics.charWidth(chars[j]) + } + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/snippet/SnippetDialog.kt b/src/main/kotlin/app/termora/snippet/SnippetDialog.kt new file mode 100644 index 0000000..da08c7c --- /dev/null +++ b/src/main/kotlin/app/termora/snippet/SnippetDialog.kt @@ -0,0 +1,59 @@ +package app.termora.snippet + +import app.termora.* +import java.awt.Dimension +import java.awt.Window +import javax.swing.JComponent +import javax.swing.UIManager +import kotlin.math.max + +class SnippetDialog(owner: Window) : DialogWrapper(owner) { + private val properties get() = Database.getDatabase().properties + + init { + initViews() + initEvents() + init() + } + + private fun initViews() { + val w = properties.getString("SnippetDialog.width", "0").toIntOrNull() ?: 0 + val h = properties.getString("SnippetDialog.height", "0").toIntOrNull() ?: 0 + val x = properties.getString("SnippetDialog.x", "-1").toIntOrNull() ?: -1 + val y = properties.getString("SnippetDialog.y", "-1").toIntOrNull() ?: -1 + + size = if (w > 0 && h > 0) { + Dimension(w, h) + } else { + Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height")) + } + isModal = true + isResizable = true + title = I18n.getString("termora.snippet.title") + + if (x != -1 && y != -1) { + setLocation(max(x, 0), max(y, 0)) + } else { + setLocationRelativeTo(owner) + } + } + + private fun initEvents() { + Disposer.register(disposable, object : Disposable { + override fun dispose() { + properties.putString("SnippetDialog.width", width.toString()) + properties.putString("SnippetDialog.height", height.toString()) + properties.putString("SnippetDialog.x", x.toString()) + properties.putString("SnippetDialog.y", y.toString()) + } + }) + } + + override fun createCenterPanel(): JComponent { + return SnippetPanel().apply { Disposer.register(disposable, this) } + } + + override fun createSouthPanel(): JComponent? { + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/snippet/SnippetManager.kt b/src/main/kotlin/app/termora/snippet/SnippetManager.kt new file mode 100644 index 0000000..2b6188f --- /dev/null +++ b/src/main/kotlin/app/termora/snippet/SnippetManager.kt @@ -0,0 +1,44 @@ +package app.termora.snippet + +import app.termora.ApplicationScope +import app.termora.Database +import app.termora.assertEventDispatchThread + + +class SnippetManager private constructor() { + companion object { + fun getInstance(): SnippetManager { + return ApplicationScope.forApplicationScope().getOrCreate(SnippetManager::class) { SnippetManager() } + } + } + + private val database get() = Database.getDatabase() + private var snippets = mutableMapOf() + + /** + * 修改缓存并存入数据库 + */ + fun addSnippet(snippet: Snippet) { + assertEventDispatchThread() + database.addSnippet(snippet) + if (snippet.deleted) { + snippets.entries.removeIf { it.value.id == snippet.id || it.value.parentId == snippet.id } + } else { + snippets[snippet.id] = snippet + } + } + + /** + * 第一次调用从数据库中获取,后续从缓存中获取 + */ + fun snippets(): List { + if (snippets.isEmpty()) { + database.getSnippets().filter { !it.deleted } + .forEach { snippets[it.id] = it } + } + return snippets.values.filter { !it.deleted } + .sortedWith(compareBy { if (it.type == SnippetType.Folder) 0 else 1 }.thenBy { it.sort }) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/snippet/SnippetPanel.kt b/src/main/kotlin/app/termora/snippet/SnippetPanel.kt new file mode 100644 index 0000000..68d863c --- /dev/null +++ b/src/main/kotlin/app/termora/snippet/SnippetPanel.kt @@ -0,0 +1,225 @@ +package app.termora.snippet + +import app.termora.* +import com.formdev.flatlaf.extras.components.FlatTextArea +import com.formdev.flatlaf.ui.FlatRoundBorder +import com.formdev.flatlaf.util.SystemInfo +import com.jgoodies.forms.builder.FormBuilder +import com.jgoodies.forms.layout.FormLayout +import org.apache.commons.lang3.StringUtils +import org.slf4j.LoggerFactory +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.Dimension +import java.awt.KeyboardFocusManager +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.swing.* +import javax.swing.event.DocumentEvent +import javax.swing.undo.UndoManager + + +class SnippetPanel : JPanel(BorderLayout()), Disposable { + companion object { + private val log = LoggerFactory.getLogger(SnippetPanel::class.java) + private val properties get() = Database.getDatabase().properties + private val snippetManager get() = SnippetManager.getInstance() + } + + private val leftPanel = JPanel(BorderLayout()) + private val cardLayout = CardLayout() + private val rightPanel = JPanel(cardLayout) + private val snippetTree = SnippetTree() + private val editor = SnippetEditor() + private val lastNode get() = snippetTree.getLastSelectedPathNode() + + init { + initViews() + initEvents() + } + + private fun initViews() { + val splitPane = JSplitPane() + splitPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor) + + leftPanel.add(snippetTree, BorderLayout.CENTER) + leftPanel.border = BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor), + BorderFactory.createEmptyBorder(4, 4, 4, 4) + ) + leftPanel.preferredSize = Dimension( + properties.getString("SnippetPanel.LeftPanel.width", "180").toIntOrNull() ?: 180, + -1 + ) + + rightPanel.border = BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor), + BorderFactory.createEmptyBorder(6, 6, 6, 6) + ) + + val bannerPanel = JPanel(BorderLayout()) + bannerPanel.add(SnippetBannerPanel(), BorderLayout.CENTER) + bannerPanel.border = BorderFactory.createEmptyBorder(32, 0, 0, 0) + rightPanel.add(bannerPanel, "Banner") + rightPanel.add(editor, "Editor") + + splitPane.leftComponent = leftPanel + splitPane.rightComponent = rightPanel + add(splitPane, BorderLayout.CENTER) + + cardLayout.show(rightPanel, "Banner") + + } + + private fun initEvents() { + snippetTree.addTreeSelectionListener { + val lastNode = this.lastNode + if (lastNode == null || lastNode.isFolder) { + cardLayout.show(rightPanel, "Banner") + } else { + cardLayout.show(rightPanel, "Editor") + editor.textArea.text = lastNode.data.snippet + editor.resetUndo() + } + } + + SwingUtilities.invokeLater { + if (snippetTree.selectionRows?.isEmpty() == true) { + snippetTree.addSelectionRow(0) + } + snippetTree.requestFocusInWindow() + } + + + val expansionState = properties.getString("SnippetPanel.LeftTreePanel.expansionState", StringUtils.EMPTY) + if (expansionState.isNotBlank()) { + TreeUtils.loadExpansionState(snippetTree, expansionState) + } + + val selectionRows = properties.getString("SnippetPanel.LeftTreePanel.selectionRows", StringUtils.EMPTY) + if (selectionRows.isNotBlank()) { + TreeUtils.loadSelectionRows(snippetTree, selectionRows) + } + } + + override fun dispose() { + properties.putString("SnippetPanel.LeftPanel.width", leftPanel.width.toString()) + properties.putString("SnippetPanel.LeftPanel.height", leftPanel.height.toString()) + properties.putString("SnippetPanel.LeftTreePanel.expansionState", TreeUtils.saveExpansionState(snippetTree)) + properties.putString("SnippetPanel.LeftTreePanel.selectionRows", TreeUtils.saveSelectionRows(snippetTree)) + } + + + private inner class SnippetEditor : JPanel(BorderLayout()) { + val textArea = FlatTextArea() + private var undoManager = UndoManager() + + init { + initViews() + initEvents() + } + + private fun initViews() { + val panel = JPanel(BorderLayout()) + panel.add(JScrollPane(textArea).apply { border = BorderFactory.createEmptyBorder() }, BorderLayout.CENTER) + panel.border = FlatRoundBorder() + add(panel, BorderLayout.CENTER) + add(createTip(), BorderLayout.SOUTH) + + textArea.setFocusTraversalKeys( + KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS) + ) + textArea.setFocusTraversalKeys( + KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS, + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS) + ) + } + + private fun initEvents() { + textArea.document.addUndoableEditListener(undoManager) + + textArea.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if ((e.keyCode == KeyEvent.VK_Z || e.keyCode == KeyEvent.VK_Y) && (if (SystemInfo.isMacOS) e.isMetaDown else e.isControlDown)) { + try { + if (e.keyCode == KeyEvent.VK_Z) { + if (undoManager.canUndo()) { + undoManager.undo() + } + } else { + if (undoManager.canRedo()) { + undoManager.redo() + } + } + } catch (cue: Exception) { + if (log.isErrorEnabled) { + log.error(cue.message, cue.message) + } + } + } + } + }) + + textArea.document.addDocumentListener(object : DocumentAdaptor() { + override fun changedUpdate(e: DocumentEvent) { + val lastNode = lastNode ?: return + lastNode.data = lastNode.data.copy(snippet = textArea.text, updateDate = System.currentTimeMillis()) + snippetManager.addSnippet(lastNode.data) + } + }) + } + + fun resetUndo() { + textArea.document.removeUndoableEditListener(undoManager) + undoManager = UndoManager() + textArea.document.addUndoableEditListener(undoManager) + } + + private fun createTip(): JPanel { + val formMargin = "10dlu" + val panel = FormBuilder.create().debug(false) + .border( + BorderFactory.createCompoundBorder( + FlatRoundBorder(), + BorderFactory.createEmptyBorder(2, 4, 4, 4) + ) + ) + .layout( + FormLayout( + "left:pref, left:pref, $formMargin, left:pref, left:pref, $formMargin, left:pref, left:pref", + "pref, $formMargin, pref" + ) + ) + .add(createTipLabel("\\r - ")).xy(1, 1) + .add(createTipLabel("CR")).xy(2, 1) + .add(createTipLabel("\\n - ")).xy(4, 1) + .add(createTipLabel("LF")).xy(5, 1) + .add(createTipLabel("\\t - ")).xy(7, 1) + .add(createTipLabel("Tab")).xy(8, 1) + + .add(createTipLabel("\\a - ")).xy(1, 2) + .add(createTipLabel("Bell")).xy(2, 2) + .add(createTipLabel("\\e - ")).xy(4, 2) + .add(createTipLabel("Escape")).xy(5, 2) + .add(createTipLabel("\\b - ")).xy(7, 2) + .add(createTipLabel("Backspace")).xy(8, 2) + .build() + + return JPanel(BorderLayout()).apply { + add(panel, BorderLayout.CENTER) + border = BorderFactory.createEmptyBorder(4, 0, 0, 0) + } + } + + private fun createTipLabel(text: String): JLabel { + val label = JLabel(text) + label.foreground = UIManager.getColor("textInactiveText") + return label + } + + + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/snippet/SnippetTree.kt b/src/main/kotlin/app/termora/snippet/SnippetTree.kt new file mode 100644 index 0000000..c11e10a --- /dev/null +++ b/src/main/kotlin/app/termora/snippet/SnippetTree.kt @@ -0,0 +1,146 @@ +package app.termora.snippet + +import app.termora.I18n +import app.termora.OptionPane +import app.termora.SimpleTree +import app.termora.SimpleTreeNode +import app.termora.actions.AnAction +import app.termora.actions.AnActionEvent +import com.formdev.flatlaf.extras.components.FlatPopupMenu +import java.awt.event.MouseEvent +import javax.swing.DropMode +import javax.swing.JMenu +import javax.swing.JOptionPane +import javax.swing.SwingUtilities +import javax.swing.tree.TreePath + +class SnippetTree : SimpleTree() { + override val model = SnippetTreeModel() + + private val snippetManager get() = SnippetManager.getInstance() + + init { + initViews() + initEvents() + } + + private fun initViews() { + super.setModel(model) + isEditable = true + dragEnabled = true + dropMode = DropMode.ON_OR_INSERT + } + + private fun initEvents() { + + } + + override fun showContextmenu(evt: MouseEvent) { + val lastNode = getLastSelectedPathNode() ?: return + val popupMenu = FlatPopupMenu() + val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new")) + val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder")) + val newSnippet = newMenu.add(I18n.getString("termora.snippet")) + val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename")) + val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove")) + popupMenu.addSeparator() + val refresh = popupMenu.add(I18n.getString("termora.welcome.contextmenu.refresh")) + val expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all")) + val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all")) + popupMenu.addSeparator() + + newFolder.addActionListener { + val snippet = Snippet( + name = I18n.getString("termora.welcome.contextmenu.new.folder.name"), + type = SnippetType.Folder, + parentId = lastNode.data.id + ) + snippetManager.addSnippet(snippet) + newFolder(SnippetTreeNode(snippet)) + } + newSnippet.addActionListener { + val snippet = Snippet( + name = I18n.getString("termora.snippet"), + type = SnippetType.Snippet, + parentId = lastNode.data.id + ) + snippetManager.addSnippet(snippet) + newFile(SnippetTreeNode(snippet)) + } + + rename.addActionListener { startEditingAtPath(TreePath(model.getPathToRoot(lastNode))) } + refresh.addActionListener { refreshNode(lastNode) } + expandAll.addActionListener { + for (node in getSelectionSimpleTreeNodes(true)) { + expandPath(TreePath(model.getPathToRoot(node))) + } + } + colspanAll.addActionListener { + for (node in getSelectionSimpleTreeNodes(true).reversed()) { + collapsePath(TreePath(model.getPathToRoot(node))) + } + } + remove.addActionListener(object : AnAction() { + override fun actionPerformed(evt: AnActionEvent) { + val nodes = getSelectionSimpleTreeNodes() + if (nodes.isEmpty()) return + if (OptionPane.showConfirmDialog( + SwingUtilities.getWindowAncestor(tree), + I18n.getString("termora.keymgr.delete-warning"), + I18n.getString("termora.remove"), + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE + ) == JOptionPane.YES_OPTION + ) { + for (c in nodes) { + snippetManager.addSnippet(c.data.copy(deleted = true, updateDate = System.currentTimeMillis())) + model.removeNodeFromParent(c) + // 将所有子孙也删除 + for (child in c.getAllChildren()) { + snippetManager.addSnippet( + child.data.copy( + deleted = true, + updateDate = System.currentTimeMillis() + ) + ) + } + } + } + } + }) + + + rename.isEnabled = lastNode != model.root + remove.isEnabled = rename.isEnabled + newFolder.isEnabled = lastNode.data.type == SnippetType.Folder + newSnippet.isEnabled = newFolder.isEnabled + newMenu.isEnabled = newFolder.isEnabled + refresh.isEnabled = newFolder.isEnabled + + popupMenu.add(newMenu) + + popupMenu.show(this, evt.x, evt.y) + } + + public override fun getLastSelectedPathNode(): SnippetTreeNode? { + return super.getLastSelectedPathNode() as? SnippetTreeNode + } + + override fun onRenamed(node: SimpleTreeNode<*>, text: String) { + val n = node as? SnippetTreeNode ?: return + n.data = n.data.copy(name = text, updateDate = System.currentTimeMillis()) + snippetManager.addSnippet(n.data) + model.nodeStructureChanged(n) + } + + override fun rebase(node: SimpleTreeNode<*>, parent: SimpleTreeNode<*>) { + val nNode = node as? SnippetTreeNode ?: return + val nParent = parent as? SnippetTreeNode ?: return + nNode.data = nNode.data.copy(parentId = nParent.data.id, updateDate = System.currentTimeMillis()) + snippetManager.addSnippet(nNode.data) + } + + override fun getSelectionSimpleTreeNodes(include: Boolean): List { + return super.getSelectionSimpleTreeNodes(include).filterIsInstance() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/snippet/SnippetTreeDialog.kt b/src/main/kotlin/app/termora/snippet/SnippetTreeDialog.kt new file mode 100644 index 0000000..c96dd7b --- /dev/null +++ b/src/main/kotlin/app/termora/snippet/SnippetTreeDialog.kt @@ -0,0 +1,65 @@ +package app.termora.snippet + +import app.termora.* +import org.apache.commons.lang3.StringUtils +import java.awt.Dimension +import java.awt.Window +import javax.swing.BorderFactory +import javax.swing.JComponent +import javax.swing.JScrollPane + +class SnippetTreeDialog(owner: Window) : DialogWrapper(owner) { + private val snippetTree = SnippetTree() + private val properties get() = Database.getDatabase().properties + + var lastNode: SnippetTreeNode? = null + + init { + size = Dimension(360, 380) + title = I18n.getString("termora.snippet.title") + isModal = true + isResizable = true + controlsVisible = false + setLocationRelativeTo(null) + init() + + + Disposer.register(disposable, object : Disposable { + override fun dispose() { + properties.putString("SnippetTreeDialog.Tree.expansionState", TreeUtils.saveExpansionState(snippetTree)) + properties.putString("SnippetTreeDialog.Tree.selectionRows", TreeUtils.saveSelectionRows(snippetTree)) + } + }) + + + val expansionState = properties.getString("SnippetTreeDialog.Tree.expansionState", StringUtils.EMPTY) + if (expansionState.isNotBlank()) { + TreeUtils.loadExpansionState(snippetTree, expansionState) + } + + val selectionRows = properties.getString("SnippetTreeDialog.Tree.selectionRows", StringUtils.EMPTY) + if (selectionRows.isNotBlank()) { + TreeUtils.loadSelectionRows(snippetTree, selectionRows) + } + } + + override fun createCenterPanel(): JComponent { + return JScrollPane(snippetTree).apply { border = BorderFactory.createEmptyBorder(0, 6, 6, 6) } + } + + override fun doCancelAction() { + lastNode = null + super.doCancelAction() + } + + override fun doOKAction() { + val node = snippetTree.getLastSelectedPathNode() ?: return + if (node.isFolder) return + lastNode = node + super.doOKAction() + } + + fun getSelectedNode(): SnippetTreeNode? { + return lastNode + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/snippet/SnippetTreeModel.kt b/src/main/kotlin/app/termora/snippet/SnippetTreeModel.kt new file mode 100644 index 0000000..6fc2ae4 --- /dev/null +++ b/src/main/kotlin/app/termora/snippet/SnippetTreeModel.kt @@ -0,0 +1,73 @@ +package app.termora.snippet + +import app.termora.SimpleTreeModel +import javax.swing.tree.MutableTreeNode +import javax.swing.tree.TreeNode + +class SnippetTreeModel : SimpleTreeModel( + SnippetTreeNode( + Snippet( + id = "0", + name = "全部片段", + type = SnippetType.Folder + ) + ) +) { + + private val snippetManager get() = SnippetManager.getInstance() + + init { + reload() + } + + override fun getRoot(): SnippetTreeNode { + return super.getRoot() as SnippetTreeNode + } + + override fun reload(parent: TreeNode?) { + + if (parent !is SnippetTreeNode) { + super.reload(parent) + return + } + + parent.removeAllChildren() + + val hosts = snippetManager.snippets() + val nodes = linkedMapOf() + + // 遍历 Host 列表,构建树节点 + for (host in hosts) { + val node = SnippetTreeNode(host) + nodes[host.id] = node + } + + for (host in hosts) { + val node = nodes[host.id] ?: continue + if (host.parentId.isBlank()) continue + val p = nodes[host.parentId] ?: continue + p.add(node) + } + + for ((_, v) in nodes.entries) { + if (parent.data.id == v.data.parentId) { + parent.add(v) + } + } + + super.reload(parent) + } + + override fun insertNodeInto(newChild: MutableTreeNode, parent: MutableTreeNode, index: Int) { + super.insertNodeInto(newChild, parent, index) + // 重置所有排序 + if (parent is SnippetTreeNode) { + for ((i, c) in parent.children().toList().filterIsInstance().withIndex()) { + val sort = i.toLong() + if (c.data.sort == sort) continue + c.data = c.data.copy(sort = sort, updateDate = System.currentTimeMillis()) + snippetManager.addSnippet(c.data) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/snippet/SnippetTreeNode.kt b/src/main/kotlin/app/termora/snippet/SnippetTreeNode.kt new file mode 100644 index 0000000..27049df --- /dev/null +++ b/src/main/kotlin/app/termora/snippet/SnippetTreeNode.kt @@ -0,0 +1,26 @@ +package app.termora.snippet + +import app.termora.Icons +import app.termora.SimpleTreeNode +import com.formdev.flatlaf.icons.FlatTreeClosedIcon +import com.formdev.flatlaf.icons.FlatTreeOpenIcon +import javax.swing.Icon + +class SnippetTreeNode(snippet: Snippet) : SimpleTreeNode(snippet) { + + override val folderCount: Int + get() = children().toList().count { if (it is SnippetTreeNode) it.data.type == SnippetType.Folder else false } + override val id get() = data.id + override val isFolder get() = data.type == SnippetType.Folder + + override fun toString(): String { + return data.name + } + + override fun getIcon(selected: Boolean, expanded: Boolean, hasFocus: Boolean): Icon { + return when (data.type) { + SnippetType.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() + else -> if (selected && hasFocus) Icons.codeSpan.dark else Icons.codeSpan + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sync/GitSyncer.kt b/src/main/kotlin/app/termora/sync/GitSyncer.kt index 7bab2d2..55d979b 100644 --- a/src/main/kotlin/app/termora/sync/GitSyncer.kt +++ b/src/main/kotlin/app/termora/sync/GitSyncer.kt @@ -62,6 +62,14 @@ abstract class GitSyncer : SafetySyncer() { } } + // decode Snippets + if (config.ranges.contains(SyncRange.Snippets)) { + gistResponse.gists.findLast { it.filename == "Snippets" }?.let { + decodeSnippets(it.content, config) + } + } + + if (log.isInfoEnabled) { log.info("Type: ${config.type} , Gist: ${config.gistId} Pulled") } @@ -84,6 +92,16 @@ abstract class GitSyncer : SafetySyncer() { gistFiles.add(GistFile("Hosts", hostsContent)) } + + // Snippets + if (config.ranges.contains(SyncRange.Snippets)) { + val snippetsContent = encodeSnippets(key) + if (log.isDebugEnabled) { + log.debug("Push encryptedSnippets: {}", snippetsContent) + } + gistFiles.add(GistFile("Snippets", snippetsContent)) + } + // KeyPairs if (config.ranges.contains(SyncRange.KeyPairs)) { val keysContent = encodeKeys(key) diff --git a/src/main/kotlin/app/termora/sync/SafetySyncer.kt b/src/main/kotlin/app/termora/sync/SafetySyncer.kt index deb2fe7..154cf4f 100644 --- a/src/main/kotlin/app/termora/sync/SafetySyncer.kt +++ b/src/main/kotlin/app/termora/sync/SafetySyncer.kt @@ -14,7 +14,8 @@ import app.termora.keymgr.KeyManager import app.termora.keymgr.OhKeyPair import app.termora.macro.Macro import app.termora.macro.MacroManager -import kotlinx.serialization.encodeToString +import app.termora.snippet.Snippet +import app.termora.snippet.SnippetManager import kotlinx.serialization.json.JsonObject import org.apache.commons.lang3.ArrayUtils import org.slf4j.LoggerFactory @@ -32,6 +33,7 @@ abstract class SafetySyncer : Syncer { protected val keywordHighlightManager get() = KeywordHighlightManager.getInstance() protected val macroManager get() = MacroManager.getInstance() protected val keymapManager get() = KeymapManager.getInstance() + protected val snippetManager get() = SnippetManager.getInstance() protected fun decodeHosts(text: String, config: SyncConfig) { // aes key @@ -131,6 +133,61 @@ abstract class SafetySyncer : Syncer { } + protected fun decodeSnippets(text: String, config: SyncConfig) { + // aes key + val key = getKey(config) + val encryptedSnippets = ohMyJson.decodeFromString>(text) + val snippets = snippetManager.snippets().associateBy { it.id } + + for (encryptedSnippet in encryptedSnippets) { + val oldHost = snippets[encryptedSnippet.id] + + // 如果一样,则无需配置 + if (oldHost != null) { + if (oldHost.updateDate == encryptedSnippet.updateDate) { + continue + } + } + + try { + // aes iv + val iv = getIv(encryptedSnippet.id) + val snippet = encryptedSnippet.copy( + name = encryptedSnippet.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(), + parentId = encryptedSnippet.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(), + snippet = encryptedSnippet.snippet.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(), + ) + SwingUtilities.invokeLater { snippetManager.addSnippet(snippet) } + } catch (e: Exception) { + if (log.isWarnEnabled) { + log.warn("Decode snippet: ${encryptedSnippet.id} failed. error: {}", e.message, e) + } + } + } + + + if (log.isDebugEnabled) { + log.debug("Decode hosts: {}", text) + } + } + + protected fun encodeSnippets(key: ByteArray): String { + val snippets = mutableListOf() + for (snippet in snippetManager.snippets()) { + // aes iv + val iv = ArrayUtils.subarray(snippet.id.padEnd(16, '0').toByteArray(), 0, 16) + snippets.add( + snippet.copy( + name = snippet.name.aesCBCEncrypt(key, iv).encodeBase64String(), + snippet = snippet.snippet.aesCBCEncrypt(key, iv).encodeBase64String(), + parentId = snippet.parentId.aesCBCEncrypt(key, iv).encodeBase64String(), + ) + ) + } + return ohMyJson.encodeToString(snippets) + + } + protected fun decodeKeys(text: String, config: SyncConfig) { // aes key val key = getKey(config) diff --git a/src/main/kotlin/app/termora/sync/SyncConfig.kt b/src/main/kotlin/app/termora/sync/SyncConfig.kt index 07ec333..b7e8d7a 100644 --- a/src/main/kotlin/app/termora/sync/SyncConfig.kt +++ b/src/main/kotlin/app/termora/sync/SyncConfig.kt @@ -13,6 +13,7 @@ enum class SyncRange { KeywordHighlights, Macros, Keymap, + Snippets, } data class SyncConfig( diff --git a/src/main/kotlin/app/termora/sync/WebDAVSyncer.kt b/src/main/kotlin/app/termora/sync/WebDAVSyncer.kt index 0a8d356..0f3e672 100644 --- a/src/main/kotlin/app/termora/sync/WebDAVSyncer.kt +++ b/src/main/kotlin/app/termora/sync/WebDAVSyncer.kt @@ -4,7 +4,6 @@ import app.termora.Application.ohMyJson import app.termora.ApplicationScope import app.termora.PBKDF2 import app.termora.ResponseException -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive @@ -37,28 +36,45 @@ class WebDAVSyncer private constructor() : SafetySyncer() { val json = ohMyJson.decodeFromString(text) // decode hosts - json["Hosts"]?.jsonPrimitive?.content?.let { - decodeHosts(it, config) + if (config.ranges.contains(SyncRange.Hosts)) { + json["Hosts"]?.jsonPrimitive?.content?.let { + decodeHosts(it, config) + } } // decode KeyPairs - json["KeyPairs"]?.jsonPrimitive?.content?.let { - decodeKeys(it, config) + if (config.ranges.contains(SyncRange.KeyPairs)) { + json["KeyPairs"]?.jsonPrimitive?.content?.let { + decodeKeys(it, config) + } } // decode Highlights - json["KeywordHighlights"]?.jsonPrimitive?.content?.let { - decodeKeywordHighlights(it, config) + if (config.ranges.contains(SyncRange.KeywordHighlights)) { + json["KeywordHighlights"]?.jsonPrimitive?.content?.let { + decodeKeywordHighlights(it, config) + } } // decode Macros - json["Macros"]?.jsonPrimitive?.content?.let { - decodeMacros(it, config) + if (config.ranges.contains(SyncRange.Macros)) { + json["Macros"]?.jsonPrimitive?.content?.let { + decodeMacros(it, config) + } } // decode Keymaps - json["Keymaps"]?.jsonPrimitive?.content?.let { - decodeKeymaps(it, config) + if (config.ranges.contains(SyncRange.Keymap)) { + json["Keymaps"]?.jsonPrimitive?.content?.let { + decodeKeymaps(it, config) + } + } + + // decode Snippets + if (config.ranges.contains(SyncRange.Snippets)) { + json["Snippets"]?.jsonPrimitive?.content?.let { + decodeSnippets(it, config) + } } return GistResponse(config, emptyList()) @@ -77,6 +93,15 @@ class WebDAVSyncer private constructor() : SafetySyncer() { put("Hosts", hostsContent) } + // Snippets + if (config.ranges.contains(SyncRange.Snippets)) { + val snippetsContent = encodeSnippets(key) + if (log.isDebugEnabled) { + log.debug("Push encryptedSnippets: {}", snippetsContent) + } + put("Snippets", snippetsContent) + } + // KeyPairs if (config.ranges.contains(SyncRange.KeyPairs)) { val keysContent = encodeKeys(key) diff --git a/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt b/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt index e083333..e0ab15b 100644 --- a/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt +++ b/src/main/kotlin/app/termora/terminal/panel/FloatingToolbarPanel.kt @@ -5,6 +5,8 @@ import app.termora.actions.AnAction import app.termora.actions.AnActionEvent import app.termora.actions.DataProvider import app.termora.actions.DataProviders +import app.termora.snippet.SnippetAction +import app.termora.snippet.SnippetTreeDialog import app.termora.terminal.DataKey import app.termora.terminal.panel.vw.NvidiaSMIVisualWindow import app.termora.terminal.panel.vw.SystemInformationVisualWindow @@ -95,7 +97,7 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable { } } - if (isVisible == true) { + if (isVisible) { isVisible = false firePropertyChange("visible", true, false) } @@ -108,6 +110,9 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable { // 服务器信息 add(initServerInfoActionButton()) + // Snippet + add(initSnippetActionButton()) + // Nvidia 显卡信息 add(initNvidiaSMIActionButton()) @@ -146,6 +151,24 @@ class FloatingToolbarPanel : FlatToolBar(), Disposable { return btn } + private fun initSnippetActionButton(): JButton { + val btn = JButton(Icons.codeSpan) + btn.toolTipText = I18n.getString("termora.snippet.title") + btn.addActionListener(object : AnAction() { + override fun actionPerformed(evt: AnActionEvent) { + val tab = evt.getData(DataProviders.TerminalTab) ?: return + val terminal = tab.getData(DataProviders.Terminal) ?: return + val dialog = SnippetTreeDialog(evt.window) + dialog.setLocationRelativeTo(btn) + dialog.setLocation(dialog.x, btn.locationOnScreen.y + height + 2) + dialog.isVisible = true + val node = dialog.getSelectedNode() ?: return + SnippetAction.getInstance().runSnippet(node.data, terminal) + } + }) + return btn + } + private fun initNvidiaSMIActionButton(): JButton { val btn = JButton(Icons.nvidia) btn.toolTipText = I18n.getString("termora.visual-window.nvidia-smi") diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 0867190..c8bf6c1 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -251,6 +251,10 @@ termora.macro.playback=Playback termora.macro.manager=Manage Macros termora.macro.run=Run +# Snippets +termora.snippet=Snippet +termora.snippet.title=Snippets + # Tools termora.tools.multiple=Send commands to multiple sessions diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index b2979a8..12d8a7c 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -245,6 +245,11 @@ termora.macro.manager=管理宏 termora.macro.run=运行 +# Snippets +termora.snippet=片段 +termora.snippet.title=代码片段 + + # Transport termora.transport.local=本机 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 5457354..2d75d22 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -239,6 +239,13 @@ termora.macro.playback=回放 termora.macro.manager=管理宏 termora.macro.run=運行 + +# Snippets +termora.snippet=片段 +termora.snippet.title=程式碼片段 + + + # Transport termora.transport.local=本機 termora.transport.parent-folder=父資料夾 diff --git a/src/main/resources/icons/anyType.svg b/src/main/resources/icons/anyType.svg new file mode 100644 index 0000000..4e2c693 --- /dev/null +++ b/src/main/resources/icons/anyType.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/anyType_dark.svg b/src/main/resources/icons/anyType_dark.svg new file mode 100644 index 0000000..833c55a --- /dev/null +++ b/src/main/resources/icons/anyType_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/codeSpan.svg b/src/main/resources/icons/codeSpan.svg new file mode 100644 index 0000000..cf4caeb --- /dev/null +++ b/src/main/resources/icons/codeSpan.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/codeSpan_dark.svg b/src/main/resources/icons/codeSpan_dark.svg new file mode 100644 index 0000000..139178b --- /dev/null +++ b/src/main/resources/icons/codeSpan_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/toolWindowJsonPath.svg b/src/main/resources/icons/toolWindowJsonPath.svg new file mode 100644 index 0000000..4a96297 --- /dev/null +++ b/src/main/resources/icons/toolWindowJsonPath.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/toolWindowJsonPath_dark.svg b/src/main/resources/icons/toolWindowJsonPath_dark.svg new file mode 100644 index 0000000..522bb8a --- /dev/null +++ b/src/main/resources/icons/toolWindowJsonPath_dark.svg @@ -0,0 +1,5 @@ + + + + +