chore: improve host tree filtering

This commit is contained in:
hstyi
2025-06-14 16:21:00 +08:00
committed by hstyi
parent 3cca89b917
commit 26a06b1c91
11 changed files with 368 additions and 207 deletions

View File

@@ -0,0 +1,276 @@
package app.termora.tree;
import app.termora.Disposable;
import app.termora.DocumentAdaptor;
import com.formdev.flatlaf.extras.components.FlatTextField;
import org.apache.commons.lang3.ArrayUtils;
import org.jetbrains.annotations.NotNull;
import javax.swing.JTree;
import javax.swing.event.DocumentEvent;
import javax.swing.event.EventListenerList;
import javax.swing.event.TreeModelEvent;
import javax.swing.event.TreeModelListener;
import javax.swing.tree.TreeModel;
import javax.swing.tree.TreePath;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class FilterableTreeModel implements TreeModel, Disposable {
private final TreeModel originalModel;
private final EventListenerList eventListener = new EventListenerList();
private Filter[] filters = new Filter[0];
private final TreeModelListener originalModelListener = new TreeModelListener() {
@Override
public void treeNodesChanged(TreeModelEvent e) {
rebuildAndNotify(e);
}
@Override
public void treeNodesInserted(TreeModelEvent e) {
rebuildAndNotify(e);
}
@Override
public void treeNodesRemoved(TreeModelEvent e) {
rebuildAndNotify(e);
}
@Override
public void treeStructureChanged(TreeModelEvent e) {
rebuildAndNotify(e);
}
};
private final Map<Object, FilterNode> filteredTree = new LinkedHashMap<>();
private final JTree tree;
public final FlatTextField filterableTextField = new FlatTextField();
public boolean expand = false;
public FilterableTreeModel(JTree tree) {
this.tree = tree;
this.originalModel = tree.getModel();
this.originalModel.addTreeModelListener(originalModelListener);
filterableTextField.getDocument().addDocumentListener(new DocumentAdaptor() {
@Override
public void changedUpdate(@NotNull DocumentEvent e) {
rebuildAndNotify(null);
}
});
// 初始化构建过滤树
rebuildFilteredTree();
}
@Override
public Object getRoot() {
return originalModel.getRoot();
}
@Override
public Object getChild(Object parent, int index) {
return getFilteredChildren(parent)[index];
}
@Override
public int getChildCount(Object parent) {
return getFilteredChildren(parent).length;
}
@Override
public boolean isLeaf(Object node) {
// 如果原模型中是叶子节点,直接返回
if (originalModel.isLeaf(node)) {
return true;
}
// 如果不是叶子节点,但过滤后没有子节点,也算是叶子节点
return getFilteredChildren(node).length == 0;
}
@Override
public void valueForPathChanged(TreePath path, Object newValue) {
// originalModel.valueForPathChanged(path, newValue);
}
@Override
public int getIndexOfChild(Object parent, Object child) {
return ArrayUtils.indexOf(getFilteredChildren(parent), child);
}
@Override
public void addTreeModelListener(TreeModelListener l) {
eventListener.add(TreeModelListener.class, l);
}
@Override
public void removeTreeModelListener(TreeModelListener l) {
eventListener.remove(TreeModelListener.class, l);
}
/**
* 重建过滤树并通知监听器
*/
private void rebuildAndNotify(TreeModelEvent event) {
rebuildFilteredTree();
notifyTreeStructureChanged(event);
if (expand) {
expandAllNodes(tree, getRoot(), new TreePath(getRoot()));
}
}
/**
* 递归展开所有有子节点的路径
*/
private void expandAllNodes(JTree tree, Object node, TreePath path) {
// 展开当前路径
tree.expandPath(path);
// 递归展开所有子节点
Object[] children = getFilteredChildren(node);
for (Object child : children) {
if (!isLeaf(child)) {
TreePath childPath = path.pathByAddingChild(child);
expandAllNodes(tree, child, childPath);
}
}
}
/**
* 重建过滤后的树结构
*/
private void rebuildFilteredTree() {
filteredTree.clear();
buildFilteredTree(getRoot(), null);
}
/**
* 递归构建过滤后的树结构
*
* @param node 当前节点
* @param parent 父节点
*/
private void buildFilteredTree(Object node, Object parent) {
List<Object> filteredChildren = new ArrayList<>();
// 获取原始子节点
int originalChildCount = originalModel.getChildCount(node);
for (int i = 0; i < originalChildCount; i++) {
Object child = originalModel.getChild(node, i);
if (originalModel.isLeaf(child)) {
// 叶子节点:检查是否通过过滤器
if (passesFilter(child)) {
filteredChildren.add(child);
}
} else {
// 非叶子节点:递归处理子节点
buildFilteredTree(child, node);
// 如果子节点有通过过滤的内容,或者节点本身通过过滤,则包含该节点
FilterNode childFilterNode = filteredTree.get(child);
if ((childFilterNode != null && childFilterNode.children().length > 0) || passesFilter(child)) {
filteredChildren.add(child);
}
}
}
// 将当前节点的过滤结果保存
filteredTree.put(node, new FilterNode(
node,
parent,
filteredChildren.toArray(),
passesFilter(node)
));
}
/**
* 检查节点是否通过所有过滤器
*
* @param node 要检查的节点
* @return true如果通过所有过滤器
*/
private boolean passesFilter(Object node) {
return Arrays.stream(filters).allMatch(filter -> filter.filter(node));
}
/**
* 获取节点的过滤后子节点
*
* @param parent 父节点
* @return 过滤后的子节点数组
*/
private Object[] getFilteredChildren(Object parent) {
FilterNode filterNode = filteredTree.get(parent);
if (filterNode == null) {
return new Object[0];
}
return filterNode.children();
}
/**
* 通知所有监听器树结构已改变
*/
private void notifyTreeStructureChanged(TreeModelEvent event) {
TreeModelListener[] listeners = eventListener.getListeners(TreeModelListener.class);
if (listeners.length > 0) {
TreeModelEvent evt = new TreeModelEvent(this, event == null ? new Object[]{getRoot()} : event.getPath());
for (TreeModelListener listener : listeners) {
listener.treeStructureChanged(evt);
}
}
}
/**
* 添加过滤器
*
* @param filter 要添加的过滤器
*/
public void addFilter(Filter filter) {
filters = ArrayUtils.add(filters, filter);
rebuildAndNotify(null);
}
/**
* 移除过滤器
*
* @param filter 要移除的过滤器
*/
public void removeFilter(Filter filter) {
filters = ArrayUtils.removeElement(filters, filter);
rebuildAndNotify(null);
}
public void filter() {
rebuildAndNotify(null);
}
/**
* 清除所有过滤器
*/
public void clearFilters() {
filters = new Filter[0];
rebuildAndNotify(null);
}
@Override
public void dispose() {
filters = new Filter[0];
filteredTree.clear();
originalModel.removeTreeModelListener(originalModelListener);
}
/**
* 过滤节点的记录类
*
* @param node 节点对象
* @param parent 父节点
* @param children 过滤后的子节点数组
* @param matched 节点本身是否匹配过滤条件
*/
private record FilterNode(Object node, Object parent, Object[] children, boolean matched) {
}
}

View File

@@ -7,13 +7,11 @@ import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereResult
import app.termora.plugin.internal.ssh.SSHProtocolProvider
import app.termora.terminal.DataKey
import app.termora.tree.NewHostTree
import app.termora.tree.NewHostTreeModel
import app.termora.tree.*
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.FlatSVGIcon
import com.formdev.flatlaf.extras.components.FlatButton
import com.formdev.flatlaf.extras.components.FlatTextField
import org.apache.commons.lang3.StringUtils
import org.jdesktop.swingx.action.ActionManager
import java.awt.BorderLayout
@@ -26,19 +24,18 @@ import kotlin.math.max
class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()), Disposable, TerminalTab,
DataProvider {
private val properties get() = DatabaseManager.getInstance().properties
private val rootPanel = JPanel(BorderLayout())
private val searchTextField = FlatTextField()
private val hostTree = NewHostTree()
private val bannerPanel = BannerPanel()
private val toggle = FlatButton()
private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean()
private val dataProviderSupport = DataProviderSupport()
private val hostTreeModel = hostTree.model as NewHostTreeModel
private val filterableTreeModel = FilterableTreeModel(hostTree).apply { expand = true }
private var lastFocused: Component? = null
// private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) {
// searchTextField.text.isBlank()
// }
private val searchTextField = filterableTreeModel.filterableTextField
init {
initView()
@@ -144,7 +141,7 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
panel.add(scrollPane, BorderLayout.CENTER)
panel.border = BorderFactory.createEmptyBorder(10, 0, 0, 0)
// hostTree.model = filterableHostTreeModel
hostTree.model = filterableTreeModel
hostTree.name = "WelcomeHostTree"
hostTree.restoreExpansions()
@@ -155,6 +152,7 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
private fun initEvents() {
Disposer.register(this, hostTree)
Disposer.register(hostTree, filterableTreeModel)
addComponentListener(object : ComponentAdapter() {
override fun componentShown(e: ComponentEvent) {
@@ -173,6 +171,18 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
}
})
filterableTreeModel.addFilter(object : Filter {
override fun filter(node: Any): Boolean {
val text = searchTextField.text
if (text.isBlank()) return true
if (node !is HostTreeNode) return false
if (node is TeamTreeNode || node.id == "0") return true
return node.host.name.contains(text) || node.host.host.contains(text)
|| node.host.username.contains(text)
}
})
FindEverywhereProvider.getFindEverywhereProviders(windowScope).add(object : FindEverywhereProvider {
override fun find(pattern: String): List<FindEverywhereResult> {
@@ -202,26 +212,6 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout()
}
})
/*filterableHostTreeModel.addFilter {
if (it !is HostTreeNode) return@addFilter false
val text = searchTextField.text
val host = it.host
text.isBlank() || host.name.contains(text, true)
|| host.host.contains(text, true)
|| host.username.contains(text, true)
}
searchTextField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
val text = searchTextField.text
filterableHostTreeModel.refresh()
if (text.isNotBlank()) {
hostTree.expandAll()
}
}
})*/
searchTextField.addKeyListener(object : KeyAdapter() {
private val event = ActionEvent(hostTree, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY)
private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST)

View File

@@ -1,7 +1,6 @@
package app.termora.actions
import app.termora.NewHostDialogV2
import app.termora.tree.FilterableHostTreeModel
import app.termora.tree.HostTreeNode
import app.termora.tree.NewHostTreeModel
import javax.swing.tree.TreePath
@@ -39,8 +38,7 @@ class NewHostAction : AnAction() {
)
val newNode = HostTreeNode(host)
val model = if (tree.model is FilterableHostTreeModel) (tree.model as FilterableHostTreeModel).getModel()
else tree.model
val model = tree.model
if (model is NewHostTreeModel) {
model.insertNodeInto(newNode, lastNode, lastNode.childCount)

View File

@@ -6,6 +6,7 @@ import app.termora.actions.AnAction
import app.termora.actions.AnActionEvent
import app.termora.nv.FileChooser
import app.termora.plugin.internal.ssh.SSHProtocolProvider
import app.termora.tree.Filter
import app.termora.tree.HostTreeNode
import app.termora.tree.NewHostTreeDialog
import com.formdev.flatlaf.extras.components.FlatComboBox
@@ -214,8 +215,11 @@ class KeyManagerPanel(private val accountOwner: AccountOwner) : JPanel(BorderLay
}
val owner = SwingUtilities.getWindowAncestor(this) ?: return
val hostTreeDialog = NewHostTreeDialog(owner)
hostTreeDialog.setFilter { it is HostTreeNode && it.host.protocol == SSHProtocolProvider.PROTOCOL }
val hostTreeDialog = NewHostTreeDialog(owner, filter = object : Filter {
override fun filter(node: Any): Boolean {
return node is HostTreeNode && node.host.protocol == SSHProtocolProvider.PROTOCOL
}
})
hostTreeDialog.setTreeName("KeyManagerPanel.SSHCopyIdTree")
hostTreeDialog.isVisible = true
val hosts = hostTreeDialog.hosts

View File

@@ -4,6 +4,7 @@ import app.termora.*
import app.termora.keymgr.KeyManager
import app.termora.keymgr.KeyManagerDialog
import app.termora.plugin.internal.BasicProxyOption
import app.termora.tree.Filter
import app.termora.tree.HostTreeNode
import app.termora.tree.NewHostTreeDialog
import com.formdev.flatlaf.FlatClientProperties
@@ -978,8 +979,13 @@ open class SSHHostOptionsPane : OptionsPane() {
private fun initEvents() {
addBtn.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent?) {
val dialog = NewHostTreeDialog(owner)
dialog.setFilter { node -> node is HostTreeNode && jumpHosts.none { it.id == node.host.id } && filter.invoke(node.host) }
val dialog = NewHostTreeDialog(owner, object : Filter {
override fun filter(node: Any): Boolean {
return node is HostTreeNode && jumpHosts.none { it.id == node.host.id } && filter.invoke(
node.host
)
}
})
dialog.setTreeName("HostOptionsPane.JumpHostsOption.Tree")
dialog.setLocationRelativeTo(owner)
dialog.isVisible = true

View File

@@ -10,8 +10,7 @@ import app.termora.protocol.FileObjectHandler
import app.termora.protocol.FileObjectRequest
import app.termora.protocol.TransferProtocolProvider
import app.termora.terminal.DataKey
import app.termora.tree.NewHostTree
import app.termora.tree.TreeUtils
import app.termora.tree.*
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
@@ -275,12 +274,25 @@ class SFTPFileSystemViewPanel(
private fun initView() {
tree.contextmenu = false
tree.dragEnabled = false
tree.isRootVisible = false
tree.doubleClickConnection = false
tree.showsRootHandles = true
val scrollPane = JScrollPane(tree)
scrollPane.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
add(scrollPane, BorderLayout.CENTER)
val filterableTreeModel = FilterableTreeModel(tree)
filterableTreeModel.addFilter(object : Filter {
override fun filter(node: Any): Boolean {
if (node !is HostTreeNode) return false
return TransferProtocolProvider.valueOf(node.host.protocol) != null
}
})
filterableTreeModel.filter()
tree.model = filterableTreeModel
Disposer.register(tree, filterableTreeModel)
TreeUtils.loadExpansionState(tree, properties.getString("SFTPTabbed.Tree.state", StringUtils.EMPTY))
}
@@ -310,9 +322,6 @@ class SFTPFileSystemViewPanel(
})
}
override fun dispose() {
properties.putString("SFTPTabbed.Tree.state", TreeUtils.saveExpansionState(tree))
}
}
@Suppress("UNCHECKED_CAST")

View File

@@ -0,0 +1,6 @@
package app.termora.tree
interface Filter {
fun filter(node: Any): Boolean
}

View File

@@ -1,156 +0,0 @@
package app.termora.tree
import org.apache.commons.lang3.ArrayUtils
import java.util.function.Function
import javax.swing.JTree
import javax.swing.SwingUtilities
import javax.swing.event.TreeModelEvent
import javax.swing.event.TreeModelListener
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.TreeModel
import javax.swing.tree.TreeNode
import javax.swing.tree.TreePath
class FilterableHostTreeModel(
private val tree: JTree,
/**
* 如果返回 true 则空文件夹也展示
*/
private val showEmptyFolder: () -> Boolean = { true }
) : TreeModel {
private val model = tree.model
private val root = ReferenceTreeNode(model.root)
private var listeners = emptyArray<TreeModelListener>()
private var filters = emptyArray<Function<SimpleTreeNode<*>, Boolean>>()
private val mapping = mutableMapOf<TreeNode, ReferenceTreeNode>()
init {
refresh()
initEvents()
}
/**
* @param a 旧的
* @param b 新的
*/
private fun cloneTree(a: SimpleTreeNode<*>, b: DefaultMutableTreeNode) {
b.removeAllChildren()
for (c in a.children()) {
if (c !is SimpleTreeNode<*>) {
continue
}
if (c.isFolder.not()) {
if (filters.isNotEmpty() && filters.none { it.apply(c) }) {
continue
}
}
val n = ReferenceTreeNode(c).apply { mapping[c] = this }.apply { b.add(this) }
// 文件夹递归复制
if (c.isFolder) {
cloneTree(c, n)
}
// 如果是文件夹
if (c.isFolder) {
if (n.childCount == 0) {
if (showEmptyFolder.invoke()) {
continue
}
n.removeFromParent()
}
}
}
}
private fun initEvents() {
model.addTreeModelListener(object : TreeModelListener {
override fun treeNodesChanged(e: TreeModelEvent) {
refresh()
}
override fun treeNodesInserted(e: TreeModelEvent) {
refresh()
}
override fun treeNodesRemoved(e: TreeModelEvent) {
refresh()
}
override fun treeStructureChanged(e: TreeModelEvent) {
refresh()
}
})
}
override fun getRoot(): Any {
return root.userObject
}
override fun getChild(parent: Any, index: Int): Any {
val c = map(parent)?.getChildAt(index)
if (c is ReferenceTreeNode) {
return c.userObject
}
throw IndexOutOfBoundsException("Index out of bounds")
}
override fun getChildCount(parent: Any): Int {
return map(parent)?.childCount ?: 0
}
private fun map(parent: Any): ReferenceTreeNode? {
if (parent is TreeNode) {
return mapping[parent]
}
return null
}
override fun isLeaf(node: Any?): Boolean {
return (node as TreeNode).isLeaf
}
override fun valueForPathChanged(path: TreePath, newValue: Any) {
}
override fun getIndexOfChild(parent: Any, child: Any): Int {
val c = map(parent) ?: return -1
for (i in 0 until c.childCount) {
val e = c.getChildAt(i)
if (e is ReferenceTreeNode && e.userObject == child) {
return i
}
}
return -1
}
override fun addTreeModelListener(l: TreeModelListener) {
listeners = ArrayUtils.addAll(listeners, l)
}
override fun removeTreeModelListener(l: TreeModelListener) {
listeners = ArrayUtils.removeElement(listeners, l)
}
fun addFilter(f: Function<SimpleTreeNode<*>, Boolean>) {
filters = ArrayUtils.add(filters, f)
}
fun refresh() {
mapping.clear()
mapping[model.root as TreeNode] = root
cloneTree(model.root as HostTreeNode, root)
SwingUtilities.updateComponentTreeUI(tree)
}
fun getModel(): TreeModel {
return model
}
private class ReferenceTreeNode(any: Any) : DefaultMutableTreeNode(any)
}

View File

@@ -39,6 +39,7 @@ import javax.swing.event.PopupMenuListener
import javax.swing.event.TreeModelEvent
import javax.swing.event.TreeModelListener
import javax.swing.filechooser.FileNameExtensionFilter
import javax.swing.tree.TreeModel
import javax.swing.tree.TreePath
import javax.swing.tree.TreeSelectionModel
import javax.xml.parsers.DocumentBuilderFactory
@@ -88,6 +89,9 @@ class NewHostTree : SimpleTree(), Disposable {
initEvents()
}
fun getSuperModel(): TreeModel {
return super.getModel()
}
private fun initViews() {
super.setModel(model)
@@ -390,6 +394,7 @@ class NewHostTree : SimpleTree(), Disposable {
override fun treeStructureChanged(e: TreeModelEvent) {
SwingUtilities.updateComponentTreeUI(tree)
}
}
}

View File

@@ -7,16 +7,19 @@ import java.awt.Dimension
import java.awt.Window
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.function.Function
import javax.swing.*
class NewHostTreeDialog(
owner: Window,
filter: Filter = object : Filter {
override fun filter(node: Any): Boolean {
return true
}
}
) : DialogWrapper(owner) {
var hosts = emptyList<Host>()
var allowMulti = true
private var filter: Function<HostTreeNode, Boolean> = Function<HostTreeNode, Boolean> { true }
private val tree = NewHostTree()
init {
@@ -29,6 +32,12 @@ class NewHostTreeDialog(
tree.contextmenu = false
tree.doubleClickConnection = false
tree.dragEnabled = false
tree.showsRootHandles = true
val model = FilterableTreeModel(tree)
model.addFilter(filter)
tree.model = model
tree.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
@@ -41,19 +50,13 @@ class NewHostTreeDialog(
})
Disposer.register(disposable, tree)
Disposer.register(tree, model)
init()
setLocationRelativeTo(owner)
}
fun setFilter(filter: Function<SimpleTreeNode<*>, Boolean>) {
tree.model = FilterableHostTreeModel(tree) { false }.apply {
addFilter(filter)
refresh()
}
}
override fun createCenterPanel(): JComponent {
val scrollPane = JScrollPane(tree)
scrollPane.border = BorderFactory.createCompoundBorder(
@@ -72,7 +75,6 @@ class NewHostTreeDialog(
override fun doOKAction() {
hosts = tree.getSelectionSimpleTreeNodes(true)
.filter { filter.apply(it) }
.map { it.host }
if (hosts.isEmpty()) return

View File

@@ -47,7 +47,7 @@ class ShowMoreInfoSimpleTreeCellRendererExtension private constructor() : Simple
var text = StringUtils.EMPTY
if (node.isFolder) {
text = "(${node.getAllChildren().size})"
text = "(${getChildrenCount(tree, node)})"
} else if (node is HostTreeNode) {
val host = node.host
if (host.protocol == SSHProtocolProvider.PROTOCOL || host.protocol == RDPProtocolProvider.PROTOCOL) {
@@ -64,6 +64,27 @@ class ShowMoreInfoSimpleTreeCellRendererExtension private constructor() : Simple
return listOf(MyMarkerSimpleTreeCellAnnotation(text))
}
private fun getChildrenCount(tree: JTree, node: SimpleTreeNode<*>): Int {
if (tree is NewHostTree) {
val model = tree.getSuperModel()
var count = 0
val queue = ArrayDeque<Any>()
queue.add(node)
while (queue.isNotEmpty()) {
val e = queue.removeFirst()
val childrenCount = model.getChildCount(e)
for (i in 0 until childrenCount) {
queue.addLast(model.getChild(e, i))
}
count++
}
return count - 1
}
return node.getAllChildren().size
}
/**
* 优先级最高
*/