From e1f52c23cde71ff61727aca680050f0db762f9d2 Mon Sep 17 00:00:00 2001 From: f0-tim21 Date: Tue, 25 Aug 2026 17:48:37 +0100 Subject: [PATCH 1/5] feat(ui): add class tree filter --- .../java/jadx/gui/settings/JadxSettings.java | 8 + .../jadx/gui/settings/JadxSettingsData.java | 9 + .../gui/settings/ui/JadxSettingsWindow.java | 9 + .../java/jadx/gui/ui/FilterableTreeModel.java | 271 ++++++++++++++++++ .../src/main/java/jadx/gui/ui/MainWindow.java | 88 +++++- .../resources/i18n/Messages_de_DE.properties | 1 + .../resources/i18n/Messages_en_US.properties | 1 + .../resources/i18n/Messages_es_ES.properties | 1 + .../resources/i18n/Messages_id_ID.properties | 1 + .../resources/i18n/Messages_ko_KR.properties | 1 + .../resources/i18n/Messages_pt_BR.properties | 1 + .../resources/i18n/Messages_ru_RU.properties | 1 + .../resources/i18n/Messages_zh_CN.properties | 1 + .../resources/i18n/Messages_zh_TW.properties | 1 + 14 files changed, 390 insertions(+), 4 deletions(-) create mode 100644 jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java diff --git a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java index edce073e1fb..1f9e40d8110 100644 --- a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java +++ b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java @@ -420,6 +420,14 @@ public void setSearchResultsPerPage(int searchResultsPerPage) { settingsData.setSearchResultsPerPage(searchResultsPerPage); } + public int getClassTreeFilterExpansionLimit() { + return settingsData.getClassTreeFilterExpansionLimit(); + } + + public void setClassTreeFilterExpansionLimit(int classTreeFilterExpansionLimit) { + settingsData.setClassTreeFilterExpansionLimit(classTreeFilterExpansionLimit); + } + public boolean isUseAutoSearch() { return settingsData.isUseAutoSearch(); } diff --git a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java index 9e71d433ce6..b51ef722afc 100644 --- a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java +++ b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java @@ -78,6 +78,7 @@ public class JadxSettingsData extends JadxGUIArgs { private boolean useAlternativeFileDialog = false; private boolean codeAreaLineWrap = false; private int searchResultsPerPage = 50; + private int classTreeFilterExpansionLimit = 500; private boolean useAutoSearch = true; private boolean keepCommonDialogOpen = false; private LineNumbersMode lineNumbersMode = LineNumbersMode.AUTO; @@ -373,6 +374,14 @@ public void setSearchResultsPerPage(int searchResultsPerPage) { this.searchResultsPerPage = searchResultsPerPage; } + public int getClassTreeFilterExpansionLimit() { + return classTreeFilterExpansionLimit; + } + + public void setClassTreeFilterExpansionLimit(int classTreeFilterExpansionLimit) { + this.classTreeFilterExpansionLimit = classTreeFilterExpansionLimit; + } + public int getSettingsVersion() { return settingsVersion; } diff --git a/jadx-gui/src/main/java/jadx/gui/settings/ui/JadxSettingsWindow.java b/jadx-gui/src/main/java/jadx/gui/settings/ui/JadxSettingsWindow.java index 3e96a779e79..fc939c93a95 100644 --- a/jadx-gui/src/main/java/jadx/gui/settings/ui/JadxSettingsWindow.java +++ b/jadx-gui/src/main/java/jadx/gui/settings/ui/JadxSettingsWindow.java @@ -742,6 +742,14 @@ private SettingsGroup makeOtherGroup() { mainWindow.loadSettings(); }); + JSpinner classTreeFilterExpansionLimit = new JSpinner( + new SpinnerNumberModel(settings.getClassTreeFilterExpansionLimit(), 0, 20000, 1)); + classTreeFilterExpansionLimit + .addChangeListener(ev -> { + settings.setClassTreeFilterExpansionLimit((Integer) classTreeFilterExpansionLimit.getValue()); + mainWindow.loadSettings(); + }); + SettingsGroup group = new SettingsGroup(NLS.str("preferences.other")); group.addRow(NLS.str("preferences.lineNumbersMode"), lineNumbersMode); group.addRow(NLS.str("preferences.jumpOnDoubleClick"), jumpOnDoubleClick); @@ -753,6 +761,7 @@ private SettingsGroup makeOtherGroup() { group.addRow(NLS.str("preferences.xposed_codegen_language"), xposedCodegenLanguage); group.addRow(NLS.str("preferences.check_for_updates"), update); group.addRow(NLS.str("preferences.update_channel"), updateChannel); + group.addRow(NLS.str("preferences.class_tree_filter_expansion_limit"), classTreeFilterExpansionLimit); return group; } diff --git a/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java new file mode 100644 index 00000000000..0bfe1bcbaa7 --- /dev/null +++ b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java @@ -0,0 +1,271 @@ +package jadx.gui.ui; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import javax.swing.JTree; +import javax.swing.SwingUtilities; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreeNode; +import javax.swing.tree.TreePath; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jadx.api.JavaNode; +import jadx.gui.treemodel.JClass; +import jadx.gui.treemodel.JNode; +import jadx.gui.treemodel.TextNode; + +/** + * A FilterableTreeModel provides dynamic filtering over the decompilation package tree. + * This filtering is done at the model level, to prevent oddities such as zero-width rows disrupting + * a keyboard user experience or otherwise causing problems. + * + * This class is specialised to filter the main UI pane displaying the decompilation tree. If the + * fullly qualified path of a tree element is obtainable, filtering will check against that, falling + * back to the standard name if the former is unavailable. + */ +class FilterableTreeModel extends DefaultTreeModel { + private static final Logger LOG = LoggerFactory.getLogger(FilterableTreeModel.class); + + // The UI locks up when trying to expand many results. + // The compromise here is to cap the number of results we are willing to expand by default when + // setting a filter. + private int filterExpansionThreshold; + + // the filter string + private String filter; + + // Pre-computed tree paths matching the filter + // Filtering happens in a two phase process + // 1. Filter set (in background) pre computes the results + // 2. UI update (in UI thread) with results + private final List filteredTreePaths; + + // filterLock ensures no race conditions between the two phase filtering process + // and any other accesses to tree elements depending on the filter by other tree listeners + private final ReentrantLock filterLock; + + /** + * Constructs a FilterableTreeModel with given root node, to be used as the root of the unfiltered + * tree. + * + * @param root the root node. Passed directly to the constructor for DefaultTreeModel. + */ + public FilterableTreeModel(TreeNode root, int filterExpansionThreshold) { + super(root); + this.filterExpansionThreshold = filterExpansionThreshold; + this.filter = ""; + this.filteredTreePaths = new ArrayList<>(); + this.filterLock = new ReentrantLock(true); // Fair reentrant lock guarantees FIFO + } + + /** + * Sets the filter of the tree by pre-computing tree paths matching the filter and refreshing the + * tree's node structure. + * + * This calls `nodeStructureChanged` on the root of the tree, implying a complete refresh of the + * tree data. Unfortunately the declarative nature of the filtering makes it infeasible to give a + * more specific refresh, which would potentially preserve open more of the current state of the + * tree. + * + * @param newFilter the new filter string, or "" to unset the filter. + */ + public void setFilter(String newFilter) { + this.filterLock.lock(); + try { + this.filter = newFilter; + LOG.debug("New class filter '{}'", this.filter); + collectFilteredPaths(); + // setFilter may be invoked from a Timer or background thread; this means we may not be in the swing + // UI thread, but nodeStructureChanged must be run on the swing thread! + SwingUtilities.invokeLater(() -> this.nodeStructureChanged((TreeNode) getRoot())); + } finally { + this.filterLock.unlock(); + } + } + + /** + * Filter thread safe nodeStructureChanged event. + * This should ensure that all treeListeners get the same filter value per event. + */ + @Override + public void nodeStructureChanged(TreeNode node) { + this.filterLock.lock(); + try { + super.nodeStructureChanged(node); + } finally { + this.filterLock.unlock(); + } + } + + /** + * Expands the filtered tree paths in the UI, should be called from a treeStructureChanged listener + * after a filter has been set. + * + * @param tree - tree ui component on which to make the filtered paths visible + */ + public void makeFilteredPathsVisible(JTree tree) { + this.filterLock.lock(); + try { + int count = 0; + for (TreePath path : this.filteredTreePaths) { + tree.makeVisible(path); + ++count; + if (count >= this.filterExpansionThreshold) { + LOG.warn("Capping displayed results for filter '{}' to {}", this.filter, + this.filterExpansionThreshold); + break; + } + } + } finally { + this.filterLock.unlock(); + } + } + + public void setFilterExpansionThreshold(int newThreshold) { + this.filterExpansionThreshold = newThreshold; + } + + private void collectFilteredPaths() { + this.filteredTreePaths.clear(); + if (this.filter.isEmpty()) { + return; + } + + TreeNode rootNode = (TreeNode) this.getRoot(); + + if (rootNode == null) { + return; + } + + TreePath rootPath = new TreePath(this.getPathToRoot(rootNode)); + + collectFilteredPaths(rootPath); + } + + private void collectFilteredPaths(TreePath path) { + if (path.getLastPathComponent() instanceof JClass) { + filteredTreePaths.add(path); + } else { + int childrenCount = this.getChildCount(path.getLastPathComponent()); + + if (childrenCount == 0) { + // this is a leaf node that has not passed through a class + // e.g. a resource + filteredTreePaths.add(path); + } + + for (int i = 0; i < childrenCount; i++) { + Object child = this.getChild(path.getLastPathComponent(), i); + collectFilteredPaths(path.pathByAddingChild(child)); + } + } + } + + /** + * Determines if a given node matches the current filter. + * + * @param node the node in question + * @return true if the filter is considered matched and the node should be displayed in the tree. + */ + private boolean matchesFilter(Object node) { + // `JNode`s are elements in the tree that correspond to a Jadx structure e.g. a JClass or JMethod. + // Most elements in the tree should be these; top level elements such as the root 'source code' node + // are not, so we unconditionally show non-JNodes. + if (node instanceof JNode) { + JavaNode javaNode = ((JNode) node).getJavaNode(); + + // if possible, retrieve the fully qualified name (i.e. including full pacakge path) for filtering, + // else rely on the default name. + String name = ((JNode) node).getName(); + if (javaNode != null) { + name = javaNode.getFullName(); + } + + // there are still some cases where the default name is still null. In this case it's better to show + // these than hide. + if (name == null) { + return true; + } + + // if we match the filter, non-case-sensitively, display the node. + if (name.toLowerCase().contains(filter.toLowerCase())) { + return true; + } + + // if we do not match the filter but any of our children do, display the node. + for (TreeNode x : (Iterable) ((JNode) node).children()::asIterator) { + if (x instanceof TextNode) { + continue; + } + if (matchesFilter(x)) { + return true; + } + + } + + // otherwise, hide the node. + return false; + } + + return false; + } + + @Override + public Object getChild(Object parent, int index) { + // It is worth noting that both this method and the one below it can be called from the UI thread + // so may cause UI lock up for users trying to expand the tree during filter execution. + // This has not been observed to be too much of an issue during testing since most filters will + // be quicker than said user interactions. + this.filterLock.lock(); + try { + // whilst getFilteredChildren acts the same as just going to the backing directly when no filter is + // set, it must construct the entire list, which requires N calls to getChild on backing (in + // getUnfilteredChildren) rather than 1 if we short-circuit it like this. + if (filter.equals("")) { + return super.getChild(parent, index); + } + + return getFilteredChildren(parent).get(index); + } finally { + this.filterLock.unlock(); + } + } + + @Override + public int getChildCount(Object parent) { + this.filterLock.lock(); + try { + if (filter.equals("")) { + return super.getChildCount(parent); + } + + return getFilteredChildren(parent).size(); + } finally { + this.filterLock.unlock(); + } + } + + private List getUnfilteredChildren(Object parent) { + + int numChildren = super.getChildCount(parent); + List results = new ArrayList<>(); + + for (int i = 0; i < numChildren; i++) { + results.add(super.getChild(parent, i)); + } + + return results; + } + + private List getFilteredChildren(Object parent) { + List unfiltered = getUnfilteredChildren(parent); + + return unfiltered.stream().filter(obj -> matchesFilter(obj)).collect(Collectors.toList()); + } + +} diff --git a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java index e609d4089ad..87266cdc8ce 100644 --- a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java +++ b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java @@ -37,6 +37,7 @@ import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.Box; +import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JCheckBoxMenuItem; import javax.swing.JFrame; @@ -47,19 +48,24 @@ import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JScrollPane; +import javax.swing.JSpinner; import javax.swing.JSplitPane; +import javax.swing.JTextField; import javax.swing.JToggleButton; import javax.swing.JToolBar; import javax.swing.JTree; +import javax.swing.SpinnerNumberModel; import javax.swing.SwingUtilities; import javax.swing.ToolTipManager; import javax.swing.UIManager; import javax.swing.WindowConstants; +import javax.swing.border.EmptyBorder; import javax.swing.event.TreeExpansionEvent; +import javax.swing.event.TreeModelEvent; +import javax.swing.event.TreeModelListener; import javax.swing.event.TreeWillExpandListener; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; -import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreeNode; import javax.swing.tree.TreePath; import javax.swing.tree.TreeSelectionModel; @@ -76,7 +82,6 @@ import com.formdev.flatlaf.util.UIScale; import ch.qos.logback.classic.Level; - import jadx.api.JadxArgs; import jadx.api.JavaClass; import jadx.api.JavaNode; @@ -177,6 +182,7 @@ import jadx.gui.utils.fileswatcher.LiveReloadWorker; import jadx.gui.utils.shortcut.ShortcutsController; import jadx.gui.utils.ui.ActionHandler; +import jadx.gui.utils.ui.DocumentUpdateListener; import jadx.gui.utils.ui.FileOpenerHelper; import jadx.gui.utils.ui.NodeLabel; @@ -214,7 +220,7 @@ public class MainWindow extends JFrame { private transient JSplitPane quickTabsAndCodeSplitPane; private JTree tree; - private DefaultTreeModel treeModel; + private FilterableTreeModel treeModel; private JRoot treeRoot; private TabbedPane tabbedPane; private HeapUsageBar heapUsageBar; @@ -251,6 +257,11 @@ public class MainWindow extends JFrame { private final transient RenameMappingsGui renameMappings; + /** + * This stores the delay timer before applying the filter. + */ + private TimerTask filterTimerTask; + public MainWindow(JadxSettings settings) { this.settings = settings; this.project = new JadxProject(this); @@ -1370,9 +1381,11 @@ private void initUI() { mainPanel.add(treeSplitPane); DefaultMutableTreeNode treeRootNode = new DefaultMutableTreeNode(NLS.str("msg.open_file")); - treeModel = new DefaultTreeModel(treeRootNode); + + treeModel = new FilterableTreeModel(treeRootNode, getSettings().getClassTreeFilterExpansionLimit()); tree = new JTree(treeModel); ToolTipManager.sharedInstance().registerComponent(tree); + tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); tree.setFocusable(false); tree.addFocusListener(new FocusAdapter() { @@ -1464,6 +1477,72 @@ public void treeWillCollapse(TreeExpansionEvent event) { leftPane.add(treeScrollPane, BorderLayout.CENTER); leftPane.add(bottomPane, BorderLayout.PAGE_END); + + JPanel filterPanel = new JPanel(new BorderLayout(5, 5)); + filterPanel.setBorder(new EmptyBorder(0, 0, 5, 0)); + + JTextField filterField = new JTextField(); + filterField.setToolTipText("Filter classes"); + filterField.getDocument().addDocumentListener( + new DocumentUpdateListener(ev -> { + /* + * Wait for 400ms of no keystrokes before actually applying the filter, + * to prevent multiple unneeded UI lockups during typing. + */ + + if (filterTimerTask != null) { + // new keystroke means cancel the old filter task + filterTimerTask.cancel(); + } + + filterTimerTask = new TimerTask() { + @Override + public void run() { + backgroundExecutor.execute("Preparing class filter " + filterField.getText(), + () -> { + treeModel.setFilter(filterField.getText()); + }); + } + }; + + new Timer().schedule(filterTimerTask, 400); + })); + + JButton filterClearButton = new JButton(Icons.ICON_CLOSE); + filterClearButton.setToolTipText("Clear filter"); + + filterPanel.add(filterField, BorderLayout.CENTER); + filterPanel.add(filterClearButton, BorderLayout.LINE_END); + + filterClearButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + filterField.setText(""); + filterField.repaint(); + } + }); + + treeModel.addTreeModelListener(new TreeModelListener() { + @Override + public void treeNodesChanged(TreeModelEvent e) { + } + + @Override + public void treeNodesInserted(TreeModelEvent e) { + } + + @Override + public void treeNodesRemoved(TreeModelEvent e) { + } + + @Override + public void treeStructureChanged(TreeModelEvent e) { + treeModel.makeFilteredPathsVisible(tree); + } + }); + + leftPane.add(filterPanel, BorderLayout.PAGE_START); + treeSplitPane.setLeftComponent(leftPane); tabbedPane = new TabbedPane(this, tabsController); @@ -1570,6 +1649,7 @@ private void updateUiSettings() { } tree.setFont(settings.getCodeFont()); tree.setRowHeight(-1); + treeModel.setFilterExpansionThreshold(settings.getClassTreeFilterExpansionLimit()); tabbedPane.loadSettings(); if (logPanel != null) { diff --git a/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties b/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties index 3de943da065..939bc9a3f41 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties @@ -299,6 +299,7 @@ preferences.cfg=CFG-Grafiken für Methoden generieren (im 'dot'-Format) preferences.raw_cfg=RAW CFG-Grafiken generieren preferences.xposed_codegen_language=Xposed-Code-Generierungssprache preferences.update_channel=Jadx-Updatekanal +#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover preferences.integerFormat=Ganzzahlformat #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_en_US.properties b/jadx-gui/src/main/resources/i18n/Messages_en_US.properties index 12384ae8d38..6ceab9d51da 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_en_US.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_en_US.properties @@ -299,6 +299,7 @@ preferences.cfg=Generate methods CFG graphs (in 'dot' format) preferences.raw_cfg=Generate RAW CFG graphs preferences.xposed_codegen_language=Xposed code generation language preferences.update_channel=Jadx update channel +preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit preferences.disable_tooltip_on_hover=Disable tooltip on hover preferences.integerFormat=Integer format preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties b/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties index 5a1cca87bf0..ae6bc73ab83 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties @@ -299,6 +299,7 @@ preferences.cfg=Generar methods CFG graphs (in 'dot' format) preferences.raw_cfg=Generate RAW CFG graphs #preferences.xposed_codegen_language=Xposed code generation language #preferences.update_channel=Jadx update channel +#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover #preferences.integerFormat=Integer format #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties b/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties index 26278394fe1..c796a03b07a 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties @@ -299,6 +299,7 @@ preferences.cfg=Hasilkan grafik CFG metode (dalam format 'dot') preferences.raw_cfg=Hasilkan grafik CFG mentah #preferences.xposed_codegen_language=Xposed code generation language #preferences.update_channel=Jadx update channel +preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover preferences.integerFormat=Format bilangan bulat #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties b/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties index d289cbb340d..49180cb5332 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties @@ -299,6 +299,7 @@ preferences.cfg=메소드 CFG 그래프 생성 ('dot' 포맷) preferences.raw_cfg=RAW CFG 그래프 생성 #preferences.xposed_codegen_language=Xposed code generation language #preferences.update_channel=Jadx update channel +#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover #preferences.integerFormat=Integer format #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties b/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties index feeae7290e9..2a0175995f4 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties @@ -299,6 +299,7 @@ preferences.cfg=Gera gráficos de métodos CFG no formato de pontos ('dot') preferences.raw_cfg=Gera gráficos CFG no formato RAW #preferences.xposed_codegen_language=Xposed code generation language #preferences.update_channel=Jadx update channel +#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover #preferences.integerFormat=Integer format #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties b/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties index 3827ff350a8..447fc093ab9 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties @@ -299,6 +299,7 @@ preferences.cfg=Методы генерации графиков CFG (в "dot" preferences.raw_cfg=Генерировать необработанные графики CFG preferences.xposed_codegen_language=Язык генерации Xposed хуков preferences.update_channel=Канал обновления Jadx +#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover preferences.integerFormat=Формат чисел #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties b/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties index b153850a73c..0ba5749c172 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties @@ -299,6 +299,7 @@ preferences.cfg=生成方法的 CFG 图(‘.dot’) preferences.raw_cfg=生成原始的 CFG 图 preferences.xposed_codegen_language=Xposed代码生成语言 preferences.update_channel=Jadx 更新通道 +#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit preferences.disable_tooltip_on_hover=禁用悬停提示 preferences.integerFormat=数值格式化 preferences.typeUpdatesCountLimit=类型更新次数上限 diff --git a/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties b/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties index e260f80511d..1e99ae46839 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties @@ -299,6 +299,7 @@ preferences.cfg=產生方法 CFG 圖表 ('dot' 格式) preferences.raw_cfg=產生 RAW CFG 圖表 preferences.xposed_codegen_language=Xposed 程式碼產生語言 preferences.update_channel=Jadx 更新頻道 +#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover preferences.integerFormat=整數模式 #preferences.typeUpdatesCountLimit=Update type limit count From cef490e057f6dea89f9971bfe5b407f4c657c5b4 Mon Sep 17 00:00:00 2001 From: Skylot <118523+skylot@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:43:01 +0100 Subject: [PATCH 2/5] perform tree filter once without recursion and save to cache, various UI improvements --- .../jadx/gui/settings/JadxSettingsData.java | 2 +- .../java/jadx/gui/ui/FilterableTreeModel.java | 298 ++++++++---------- .../src/main/java/jadx/gui/ui/MainWindow.java | 102 +++--- .../resources/i18n/Messages_de_DE.properties | 1 + .../resources/i18n/Messages_en_US.properties | 1 + .../resources/i18n/Messages_es_ES.properties | 1 + .../resources/i18n/Messages_id_ID.properties | 1 + .../resources/i18n/Messages_ko_KR.properties | 1 + .../resources/i18n/Messages_pt_BR.properties | 1 + .../resources/i18n/Messages_ru_RU.properties | 1 + .../resources/i18n/Messages_zh_CN.properties | 1 + .../resources/i18n/Messages_zh_TW.properties | 1 + 12 files changed, 188 insertions(+), 223 deletions(-) diff --git a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java index b51ef722afc..3f87e042b3d 100644 --- a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java +++ b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java @@ -78,7 +78,7 @@ public class JadxSettingsData extends JadxGUIArgs { private boolean useAlternativeFileDialog = false; private boolean codeAreaLineWrap = false; private int searchResultsPerPage = 50; - private int classTreeFilterExpansionLimit = 500; + private int classTreeFilterExpansionLimit = 100; private boolean useAutoSearch = true; private boolean keepCommonDialogOpen = false; private LineNumbersMode lineNumbersMode = LineNumbersMode.AUTO; diff --git a/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java index 0bfe1bcbaa7..e5c39eebc3c 100644 --- a/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java +++ b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java @@ -1,9 +1,11 @@ package jadx.gui.ui; import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashSet; import java.util.List; -import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; +import java.util.Set; +import java.util.concurrent.Future; import javax.swing.JTree; import javax.swing.SwingUtilities; @@ -14,59 +16,67 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jadx.api.JavaNode; -import jadx.gui.treemodel.JClass; +import jadx.api.ResourceType; +import jadx.gui.jobs.SimpleTask; +import jadx.gui.jobs.TaskStatus; import jadx.gui.treemodel.JNode; +import jadx.gui.treemodel.JResource; +import jadx.gui.treemodel.JRoot; import jadx.gui.treemodel.TextNode; +import jadx.gui.utils.UiUtils; /** * A FilterableTreeModel provides dynamic filtering over the decompilation package tree. * This filtering is done at the model level, to prevent oddities such as zero-width rows disrupting * a keyboard user experience or otherwise causing problems. - * - * This class is specialised to filter the main UI pane displaying the decompilation tree. If the - * fullly qualified path of a tree element is obtainable, filtering will check against that, falling + *

+ * This class is specialized to filter the main UI pane displaying the decompilation tree. If the + * fully qualified path of a tree element is obtainable, filtering will check against that, falling * back to the standard name if the former is unavailable. */ class FilterableTreeModel extends DefaultTreeModel { private static final Logger LOG = LoggerFactory.getLogger(FilterableTreeModel.class); - // The UI locks up when trying to expand many results. - // The compromise here is to cap the number of results we are willing to expand by default when - // setting a filter. + private final MainWindow mainWindow; + + /** + * The UI locks up when trying to expand many results. + * The compromise here is to cap the number of results we are willing to expand by default when + * setting a filter. + */ private int filterExpansionThreshold; - // the filter string + /** + * The filter string + */ private String filter; - // Pre-computed tree paths matching the filter - // Filtering happens in a two phase process - // 1. Filter set (in background) pre computes the results - // 2. UI update (in UI thread) with results + /** + * Pre-computed tree paths matching the filter + * Filtering happens in a two phase process + * 1. Filter set (in background) pre-computes the results + * 2. UI update (in UI thread) with results + */ private final List filteredTreePaths; - // filterLock ensures no race conditions between the two phase filtering process - // and any other accesses to tree elements depending on the filter by other tree listeners - private final ReentrantLock filterLock; - /** - * Constructs a FilterableTreeModel with given root node, to be used as the root of the unfiltered - * tree. - * - * @param root the root node. Passed directly to the constructor for DefaultTreeModel. + * All nodes to filtered paths (including middle nodes) */ - public FilterableTreeModel(TreeNode root, int filterExpansionThreshold) { + private final Set filteredTreeNodes; + + public FilterableTreeModel(MainWindow mainWindow, TreeNode root, int filterExpansionThreshold) { super(root); + this.mainWindow = mainWindow; this.filterExpansionThreshold = filterExpansionThreshold; this.filter = ""; this.filteredTreePaths = new ArrayList<>(); - this.filterLock = new ReentrantLock(true); // Fair reentrant lock guarantees FIFO + this.filteredTreeNodes = new HashSet<>(); } /** * Sets the filter of the tree by pre-computing tree paths matching the filter and refreshing the * tree's node structure. - * + *

* This calls `nodeStructureChanged` on the root of the tree, implying a complete refresh of the * tree data. Unfortunately the declarative nature of the filtering makes it infeasible to give a * more specific refresh, which would potentially preserve open more of the current state of the @@ -74,18 +84,12 @@ public FilterableTreeModel(TreeNode root, int filterExpansionThreshold) { * * @param newFilter the new filter string, or "" to unset the filter. */ - public void setFilter(String newFilter) { - this.filterLock.lock(); - try { - this.filter = newFilter; - LOG.debug("New class filter '{}'", this.filter); - collectFilteredPaths(); - // setFilter may be invoked from a Timer or background thread; this means we may not be in the swing - // UI thread, but nodeStructureChanged must be run on the swing thread! - SwingUtilities.invokeLater(() -> this.nodeStructureChanged((TreeNode) getRoot())); - } finally { - this.filterLock.unlock(); - } + public synchronized void setFilter(String newFilter) { + this.filter = newFilter; + LOG.debug("New class filter '{}'", newFilter); + applyFilterFieldOutline(""); + collectFilteredPaths(); + SwingUtilities.invokeLater(() -> this.nodeStructureChanged((TreeNode) getRoot())); } /** @@ -93,13 +97,8 @@ public void setFilter(String newFilter) { * This should ensure that all treeListeners get the same filter value per event. */ @Override - public void nodeStructureChanged(TreeNode node) { - this.filterLock.lock(); - try { - super.nodeStructureChanged(node); - } finally { - this.filterLock.unlock(); - } + public synchronized void nodeStructureChanged(TreeNode node) { + super.nodeStructureChanged(node); } /** @@ -108,61 +107,65 @@ public void nodeStructureChanged(TreeNode node) { * * @param tree - tree ui component on which to make the filtered paths visible */ - public void makeFilteredPathsVisible(JTree tree) { - this.filterLock.lock(); - try { - int count = 0; - for (TreePath path : this.filteredTreePaths) { - tree.makeVisible(path); - ++count; - if (count >= this.filterExpansionThreshold) { - LOG.warn("Capping displayed results for filter '{}' to {}", this.filter, - this.filterExpansionThreshold); - break; - } + public synchronized void makeFilteredPathsVisible(JTree tree) { + int limit = Math.max(0, filterExpansionThreshold); + int count = 0; + for (TreePath path : filteredTreePaths) { + tree.makeVisible(path); + if (limit != 0 && count++ > limit) { + LOG.warn("Capping displayed results for filter '{}' to {}", filter, limit); + applyFilterFieldOutline("warning"); + break; } - } finally { - this.filterLock.unlock(); } } + private void applyFilterFieldOutline(String outlineType) { + UiUtils.uiRun(() -> mainWindow.getTreeFilterField().putClientProperty("JComponent.outline", outlineType)); + } + public void setFilterExpansionThreshold(int newThreshold) { this.filterExpansionThreshold = newThreshold; } private void collectFilteredPaths() { - this.filteredTreePaths.clear(); - if (this.filter.isEmpty()) { + UiUtils.notUiThreadGuard(); + filteredTreePaths.clear(); + filteredTreeNodes.clear(); + if (filter.isEmpty()) { return; } - - TreeNode rootNode = (TreeNode) this.getRoot(); - + JRoot rootNode = (JRoot) this.getRoot(); if (rootNode == null) { return; } - - TreePath rootPath = new TreePath(this.getPathToRoot(rootNode)); - - collectFilteredPaths(rootPath); - } - - private void collectFilteredPaths(TreePath path) { - if (path.getLastPathComponent() instanceof JClass) { - filteredTreePaths.add(path); - } else { - int childrenCount = this.getChildCount(path.getLastPathComponent()); - - if (childrenCount == 0) { - // this is a leaf node that has not passed through a class - // e.g. a resource + Enumeration en = rootNode.breadthFirstEnumeration(); + while (en.hasMoreElements()) { + TreeNode node = en.nextElement(); + if (matchesFilter(node)) { + TreePath path = new TreePath(this.getPathToRoot(node)); filteredTreePaths.add(path); + addPathNodes(node); } + } + if (LOG.isDebugEnabled()) { + LOG.debug("Filtered tree paths: {}, nodes: {}", filteredTreePaths.size(), filteredTreeNodes.size()); + } + if (filteredTreePaths.isEmpty()) { + applyFilterFieldOutline("error"); + } + } - for (int i = 0; i < childrenCount; i++) { - Object child = this.getChild(path.getLastPathComponent(), i); - collectFilteredPaths(path.pathByAddingChild(child)); + private void addPathNodes(TreeNode node) { + if (!filteredTreeNodes.add(node)) { + return; + } + TreeNode parent = node.getParent(); + while (parent != null) { + if (!filteredTreeNodes.add(parent)) { + break; } + parent = parent.getParent(); } } @@ -173,99 +176,76 @@ private void collectFilteredPaths(TreePath path) { * @return true if the filter is considered matched and the node should be displayed in the tree. */ private boolean matchesFilter(Object node) { - // `JNode`s are elements in the tree that correspond to a Jadx structure e.g. a JClass or JMethod. - // Most elements in the tree should be these; top level elements such as the root 'source code' node - // are not, so we unconditionally show non-JNodes. - if (node instanceof JNode) { - JavaNode javaNode = ((JNode) node).getJavaNode(); - - // if possible, retrieve the fully qualified name (i.e. including full pacakge path) for filtering, - // else rely on the default name. - String name = ((JNode) node).getName(); - if (javaNode != null) { - name = javaNode.getFullName(); + if (node instanceof TextNode) { + return false; + } + if (node instanceof JResource) { + JResource res = (JResource) node; + if (res.getType() == JResource.JResType.FILE && res.getResFile().getType() == ResourceType.ARSC) { + loadInnerResources(res); } - - // there are still some cases where the default name is still null. In this case it's better to show - // these than hide. + } + if (node instanceof JNode) { + JNode jNode = (JNode) node; + String name = jNode.makeString(); if (name == null) { - return true; - } - - // if we match the filter, non-case-sensitively, display the node. - if (name.toLowerCase().contains(filter.toLowerCase())) { - return true; - } - - // if we do not match the filter but any of our children do, display the node. - for (TreeNode x : (Iterable) ((JNode) node).children()::asIterator) { - if (x instanceof TextNode) { - continue; - } - if (matchesFilter(x)) { - return true; - } - + LOG.warn("Node {} has null UI string", node); + return false; } - - // otherwise, hide the node. - return false; + return name.toLowerCase().contains(filter.toLowerCase()); } - return false; } - @Override - public Object getChild(Object parent, int index) { - // It is worth noting that both this method and the one below it can be called from the UI thread - // so may cause UI lock up for users trying to expand the tree during filter execution. - // This has not been observed to be too much of an issue during testing since most filters will - // be quicker than said user interactions. - this.filterLock.lock(); - try { - // whilst getFilteredChildren acts the same as just going to the backing directly when no filter is - // set, it must construct the entire list, which requires N calls to getChild on backing (in - // getUnfilteredChildren) rather than 1 if we short-circuit it like this. - if (filter.equals("")) { - return super.getChild(parent, index); + private void loadInnerResources(JResource res) { + // load inner resource of resource.arsc + SimpleTask loadTask = res.getLoadTask(); + if (loadTask != null) { + try { + Future load = mainWindow.getBackgroundExecutor().executeWithFuture(loadTask); + load.get(); // wait for completion + } catch (Exception e) { + LOG.warn("Failed to load resource", e); } - - return getFilteredChildren(parent).get(index); - } finally { - this.filterLock.unlock(); } } @Override - public int getChildCount(Object parent) { - this.filterLock.lock(); - try { - if (filter.equals("")) { - return super.getChildCount(parent); + public synchronized Object getChild(Object parent, int index) { + if (filter.isEmpty()) { + return super.getChild(parent, index); + } + int i = 0; + Enumeration en = ((TreeNode) parent).children(); + while (en.hasMoreElements()) { + TreeNode child = en.nextElement(); + if (filteredTreeNodes.contains(child)) { + if (i == index) { + return child; + } + i++; } - - return getFilteredChildren(parent).size(); - } finally { - this.filterLock.unlock(); } + throw new IllegalArgumentException("No child at index " + index); } - private List getUnfilteredChildren(Object parent) { - - int numChildren = super.getChildCount(parent); - List results = new ArrayList<>(); - - for (int i = 0; i < numChildren; i++) { - results.add(super.getChild(parent, i)); + @Override + public synchronized int getChildCount(Object parent) { + if (filter.isEmpty()) { + return super.getChildCount(parent); } - - return results; - } - - private List getFilteredChildren(Object parent) { - List unfiltered = getUnfilteredChildren(parent); - - return unfiltered.stream().filter(obj -> matchesFilter(obj)).collect(Collectors.toList()); + TreeNode parentNode = (TreeNode) parent; + if (!filteredTreeNodes.contains(parentNode)) { + return 0; + } + int count = 0; + Enumeration en = parentNode.children(); + while (en.hasMoreElements()) { + TreeNode child = en.nextElement(); + if (filteredTreeNodes.contains(child)) { + count++; + } + } + return count; } - } diff --git a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java index 87266cdc8ce..b8a0b3a0cef 100644 --- a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java +++ b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java @@ -32,12 +32,13 @@ import java.util.Locale; import java.util.Timer; import java.util.TimerTask; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import javax.swing.AbstractAction; import javax.swing.Action; +import javax.swing.BorderFactory; import javax.swing.Box; -import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JCheckBoxMenuItem; import javax.swing.JFrame; @@ -48,18 +49,15 @@ import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JScrollPane; -import javax.swing.JSpinner; import javax.swing.JSplitPane; import javax.swing.JTextField; import javax.swing.JToggleButton; import javax.swing.JToolBar; import javax.swing.JTree; -import javax.swing.SpinnerNumberModel; import javax.swing.SwingUtilities; import javax.swing.ToolTipManager; import javax.swing.UIManager; import javax.swing.WindowConstants; -import javax.swing.border.EmptyBorder; import javax.swing.event.TreeExpansionEvent; import javax.swing.event.TreeModelEvent; import javax.swing.event.TreeModelListener; @@ -76,12 +74,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.formdev.flatlaf.FlatClientProperties; import com.formdev.flatlaf.FlatLaf; import com.formdev.flatlaf.extras.FlatInspector; import com.formdev.flatlaf.extras.FlatUIDefaultsInspector; import com.formdev.flatlaf.util.UIScale; import ch.qos.logback.classic.Level; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + import jadx.api.JadxArgs; import jadx.api.JavaClass; import jadx.api.JavaNode; @@ -177,12 +179,13 @@ import jadx.gui.utils.LafManager; import jadx.gui.utils.Link; import jadx.gui.utils.NLS; +import jadx.gui.utils.TextStandardActions; import jadx.gui.utils.UiUtils; import jadx.gui.utils.dbg.UIWatchDog; import jadx.gui.utils.fileswatcher.LiveReloadWorker; +import jadx.gui.utils.rx.RxUtils; import jadx.gui.utils.shortcut.ShortcutsController; import jadx.gui.utils.ui.ActionHandler; -import jadx.gui.utils.ui.DocumentUpdateListener; import jadx.gui.utils.ui.FileOpenerHelper; import jadx.gui.utils.ui.NodeLabel; @@ -257,10 +260,8 @@ public class MainWindow extends JFrame { private final transient RenameMappingsGui renameMappings; - /** - * This stores the delay timer before applying the filter. - */ - private TimerTask filterTimerTask; + private JTextField treeFilterField; + private Disposable treeFilterDisposable; public MainWindow(JadxSettings settings) { this.settings = settings; @@ -1382,7 +1383,7 @@ private void initUI() { DefaultMutableTreeNode treeRootNode = new DefaultMutableTreeNode(NLS.str("msg.open_file")); - treeModel = new FilterableTreeModel(treeRootNode, getSettings().getClassTreeFilterExpansionLimit()); + treeModel = new FilterableTreeModel(this, treeRootNode, getSettings().getClassTreeFilterExpansionLimit()); tree = new JTree(treeModel); ToolTipManager.sharedInstance().registerComponent(tree); @@ -1467,60 +1468,16 @@ public void treeWillCollapse(TreeExpansionEvent event) { progressPane = new ProgressPanel(this, true); issuesPanel = new IssuesPanel(this); - JPanel leftPane = new JPanel(new BorderLayout()); - JScrollPane treeScrollPane = new JScrollPane(tree); - treeScrollPane.setMinimumSize(new Dimension(100, 150)); - - JPanel bottomPane = new JPanel(new BorderLayout()); - bottomPane.add(issuesPanel, BorderLayout.PAGE_START); - bottomPane.add(progressPane, BorderLayout.PAGE_END); - - leftPane.add(treeScrollPane, BorderLayout.CENTER); - leftPane.add(bottomPane, BorderLayout.PAGE_END); - - JPanel filterPanel = new JPanel(new BorderLayout(5, 5)); - filterPanel.setBorder(new EmptyBorder(0, 0, 5, 0)); - - JTextField filterField = new JTextField(); - filterField.setToolTipText("Filter classes"); - filterField.getDocument().addDocumentListener( - new DocumentUpdateListener(ev -> { - /* - * Wait for 400ms of no keystrokes before actually applying the filter, - * to prevent multiple unneeded UI lockups during typing. - */ - - if (filterTimerTask != null) { - // new keystroke means cancel the old filter task - filterTimerTask.cancel(); - } - - filterTimerTask = new TimerTask() { - @Override - public void run() { - backgroundExecutor.execute("Preparing class filter " + filterField.getText(), - () -> { - treeModel.setFilter(filterField.getText()); - }); - } - }; - - new Timer().schedule(filterTimerTask, 400); - })); - - JButton filterClearButton = new JButton(Icons.ICON_CLOSE); - filterClearButton.setToolTipText("Clear filter"); + treeFilterField = new JTextField(); + TextStandardActions.attach(treeFilterField); + treeFilterField.setToolTipText(NLS.str("tree.filter")); + treeFilterField.putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true); + treeFilterField.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, NLS.str("tree.filter")); - filterPanel.add(filterField, BorderLayout.CENTER); - filterPanel.add(filterClearButton, BorderLayout.LINE_END); - - filterClearButton.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - filterField.setText(""); - filterField.repaint(); - } - }); + treeFilterDisposable = RxUtils.textFieldChanges(treeFilterField) + .debounce(300, TimeUnit.MILLISECONDS) + .observeOn(Schedulers.newThread()) + .subscribe(t -> treeModel.setFilter(treeFilterField.getText())); treeModel.addTreeModelListener(new TreeModelListener() { @Override @@ -1541,7 +1498,21 @@ public void treeStructureChanged(TreeModelEvent e) { } }); + JPanel filterPanel = new JPanel(new BorderLayout()); + filterPanel.setBorder(BorderFactory.createEmptyBorder(5, 2, 2, 2)); + filterPanel.add(treeFilterField, BorderLayout.CENTER); + + JPanel leftPane = new JPanel(new BorderLayout()); + JScrollPane treeScrollPane = new JScrollPane(tree); + treeScrollPane.setMinimumSize(new Dimension(100, 150)); + + JPanel bottomPane = new JPanel(new BorderLayout()); + bottomPane.add(issuesPanel, BorderLayout.PAGE_START); + bottomPane.add(progressPane, BorderLayout.PAGE_END); + leftPane.add(filterPanel, BorderLayout.PAGE_START); + leftPane.add(treeScrollPane, BorderLayout.CENTER); + leftPane.add(bottomPane, BorderLayout.PAGE_END); treeSplitPane.setLeftComponent(leftPane); @@ -1685,6 +1656,7 @@ private void closeWindow() { UiUtils.uiRunAndWait(() -> { heapUsageBar.reset(); editorThemeManager.unload(); + treeFilterDisposable.dispose(); dispose(); }); } catch (Exception e) { @@ -1784,6 +1756,10 @@ public JRoot getTreeRoot() { return treeRoot; } + public JTextField getTreeFilterField() { + return treeFilterField; + } + public JDebuggerPanel getDebuggerPanel() { initDebuggerPanel(); return debuggerPanel; diff --git a/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties b/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties index 939bc9a3f41..7e1ef5e6dca 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties @@ -68,6 +68,7 @@ tree.loading=Laden… tree.pinned_tabs=Angepinnte Registerkarten tree.open_tabs=Offene Registerkarten tree.bookmarked_tabs=Mit Lesezeichen versehene Registerkarten +#tree.filter=Tree filter progress.load=Laden progress.save_mappings=Zuordnungen werden gespeichert diff --git a/jadx-gui/src/main/resources/i18n/Messages_en_US.properties b/jadx-gui/src/main/resources/i18n/Messages_en_US.properties index 6ceab9d51da..44aea8ebe3f 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_en_US.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_en_US.properties @@ -68,6 +68,7 @@ tree.loading=Loading... tree.pinned_tabs=Pinned Tabs tree.open_tabs=Open Tabs tree.bookmarked_tabs=Bookmarked Tabs +tree.filter=Tree filter progress.load=Loading progress.save_mappings=Saving mappings diff --git a/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties b/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties index ae6bc73ab83..1dc27b9210e 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties @@ -68,6 +68,7 @@ tree.loading=Cargando... #tree.pinned_tabs=Pinned Tabs #tree.open_tabs=Open Tabs #tree.bookmarked_tabs=Bookmarked Tabs +#tree.filter=Tree filter progress.load=Cargando #progress.save_mappings=Saving mappings diff --git a/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties b/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties index c796a03b07a..5f86f3b3509 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties @@ -68,6 +68,7 @@ tree.loading=Memuat... #tree.pinned_tabs=Pinned Tabs #tree.open_tabs=Open Tabs #tree.bookmarked_tabs=Bookmarked Tabs +#tree.filter=Tree filter progress.load=Memuat progress.save_mappings=Menyimpan pemetaan diff --git a/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties b/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties index 49180cb5332..ccfa7cf3297 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties @@ -68,6 +68,7 @@ tree.loading=로딩중... #tree.pinned_tabs=Pinned Tabs #tree.open_tabs=Open Tabs #tree.bookmarked_tabs=Bookmarked Tabs +#tree.filter=Tree filter progress.load=로딩중 progress.save_mappings=매핑 내보내는 중 diff --git a/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties b/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties index 2a0175995f4..e8ea826437d 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties @@ -68,6 +68,7 @@ tree.loading=Carregando... #tree.pinned_tabs=Pinned Tabs #tree.open_tabs=Open Tabs #tree.bookmarked_tabs=Bookmarked Tabs +#tree.filter=Tree filter progress.load=Carregando #progress.save_mappings=Saving mappings diff --git a/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties b/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties index 447fc093ab9..9371964d8bd 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties @@ -68,6 +68,7 @@ tree.loading=Загрузка... #tree.pinned_tabs=Pinned Tabs #tree.open_tabs=Open Tabs #tree.bookmarked_tabs=Bookmarked Tabs +#tree.filter=Tree filter progress.load=Загрузка progress.save_mappings=Сохранить маппинги diff --git a/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties b/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties index 0ba5749c172..f13535f469c 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties @@ -68,6 +68,7 @@ tree.loading=加载中… tree.pinned_tabs=固定标签 tree.open_tabs=打开标签 tree.bookmarked_tabs=收藏标签 +#tree.filter=Tree filter progress.load=正在加载 progress.save_mappings=导出映射 diff --git a/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties b/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties index 1e99ae46839..96b461923b2 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties @@ -68,6 +68,7 @@ tree.loading=載入中... tree.pinned_tabs=已釘選的分頁 tree.open_tabs=已開啟的分頁 tree.bookmarked_tabs=已加入書籤的分頁 +#tree.filter=Tree filter progress.load=載入中 progress.save_mappings=正在匯出對應 From d0153a437162a002a6490537eefb49ec7a45bf01 Mon Sep 17 00:00:00 2001 From: Skylot <118523+skylot@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:21:17 +0100 Subject: [PATCH 3/5] implement lazy expand of only visible filtered nodes --- .../java/jadx/gui/settings/JadxSettings.java | 8 -- .../jadx/gui/settings/JadxSettingsData.java | 9 -- .../gui/settings/ui/JadxSettingsWindow.java | 9 -- .../java/jadx/gui/ui/FilterableTreeModel.java | 89 +++++++++---------- .../src/main/java/jadx/gui/ui/MainWindow.java | 30 ++----- .../resources/i18n/Messages_de_DE.properties | 1 - .../resources/i18n/Messages_en_US.properties | 1 - .../resources/i18n/Messages_es_ES.properties | 1 - .../resources/i18n/Messages_id_ID.properties | 1 - .../resources/i18n/Messages_ko_KR.properties | 1 - .../resources/i18n/Messages_pt_BR.properties | 1 - .../resources/i18n/Messages_ru_RU.properties | 1 - .../resources/i18n/Messages_zh_CN.properties | 1 - .../resources/i18n/Messages_zh_TW.properties | 1 - 14 files changed, 48 insertions(+), 106 deletions(-) diff --git a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java index 1f9e40d8110..edce073e1fb 100644 --- a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java +++ b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java @@ -420,14 +420,6 @@ public void setSearchResultsPerPage(int searchResultsPerPage) { settingsData.setSearchResultsPerPage(searchResultsPerPage); } - public int getClassTreeFilterExpansionLimit() { - return settingsData.getClassTreeFilterExpansionLimit(); - } - - public void setClassTreeFilterExpansionLimit(int classTreeFilterExpansionLimit) { - settingsData.setClassTreeFilterExpansionLimit(classTreeFilterExpansionLimit); - } - public boolean isUseAutoSearch() { return settingsData.isUseAutoSearch(); } diff --git a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java index 3f87e042b3d..9e71d433ce6 100644 --- a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java +++ b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettingsData.java @@ -78,7 +78,6 @@ public class JadxSettingsData extends JadxGUIArgs { private boolean useAlternativeFileDialog = false; private boolean codeAreaLineWrap = false; private int searchResultsPerPage = 50; - private int classTreeFilterExpansionLimit = 100; private boolean useAutoSearch = true; private boolean keepCommonDialogOpen = false; private LineNumbersMode lineNumbersMode = LineNumbersMode.AUTO; @@ -374,14 +373,6 @@ public void setSearchResultsPerPage(int searchResultsPerPage) { this.searchResultsPerPage = searchResultsPerPage; } - public int getClassTreeFilterExpansionLimit() { - return classTreeFilterExpansionLimit; - } - - public void setClassTreeFilterExpansionLimit(int classTreeFilterExpansionLimit) { - this.classTreeFilterExpansionLimit = classTreeFilterExpansionLimit; - } - public int getSettingsVersion() { return settingsVersion; } diff --git a/jadx-gui/src/main/java/jadx/gui/settings/ui/JadxSettingsWindow.java b/jadx-gui/src/main/java/jadx/gui/settings/ui/JadxSettingsWindow.java index fc939c93a95..3e96a779e79 100644 --- a/jadx-gui/src/main/java/jadx/gui/settings/ui/JadxSettingsWindow.java +++ b/jadx-gui/src/main/java/jadx/gui/settings/ui/JadxSettingsWindow.java @@ -742,14 +742,6 @@ private SettingsGroup makeOtherGroup() { mainWindow.loadSettings(); }); - JSpinner classTreeFilterExpansionLimit = new JSpinner( - new SpinnerNumberModel(settings.getClassTreeFilterExpansionLimit(), 0, 20000, 1)); - classTreeFilterExpansionLimit - .addChangeListener(ev -> { - settings.setClassTreeFilterExpansionLimit((Integer) classTreeFilterExpansionLimit.getValue()); - mainWindow.loadSettings(); - }); - SettingsGroup group = new SettingsGroup(NLS.str("preferences.other")); group.addRow(NLS.str("preferences.lineNumbersMode"), lineNumbersMode); group.addRow(NLS.str("preferences.jumpOnDoubleClick"), jumpOnDoubleClick); @@ -761,7 +753,6 @@ private SettingsGroup makeOtherGroup() { group.addRow(NLS.str("preferences.xposed_codegen_language"), xposedCodegenLanguage); group.addRow(NLS.str("preferences.check_for_updates"), update); group.addRow(NLS.str("preferences.update_channel"), updateChannel); - group.addRow(NLS.str("preferences.class_tree_filter_expansion_limit"), classTreeFilterExpansionLimit); return group; } diff --git a/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java index e5c39eebc3c..5d8b2aa9341 100644 --- a/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java +++ b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java @@ -1,9 +1,8 @@ package jadx.gui.ui; -import java.util.ArrayList; +import java.awt.Rectangle; import java.util.Enumeration; import java.util.HashSet; -import java.util.List; import java.util.Set; import java.util.concurrent.Future; @@ -19,6 +18,8 @@ import jadx.api.ResourceType; import jadx.gui.jobs.SimpleTask; import jadx.gui.jobs.TaskStatus; +import jadx.gui.treemodel.JClass; +import jadx.gui.treemodel.JMethod; import jadx.gui.treemodel.JNode; import jadx.gui.treemodel.JResource; import jadx.gui.treemodel.JRoot; @@ -39,37 +40,20 @@ class FilterableTreeModel extends DefaultTreeModel { private final MainWindow mainWindow; - /** - * The UI locks up when trying to expand many results. - * The compromise here is to cap the number of results we are willing to expand by default when - * setting a filter. - */ - private int filterExpansionThreshold; - /** * The filter string */ - private String filter; - - /** - * Pre-computed tree paths matching the filter - * Filtering happens in a two phase process - * 1. Filter set (in background) pre-computes the results - * 2. UI update (in UI thread) with results - */ - private final List filteredTreePaths; + private volatile String filter; /** * All nodes to filtered paths (including middle nodes) */ private final Set filteredTreeNodes; - public FilterableTreeModel(MainWindow mainWindow, TreeNode root, int filterExpansionThreshold) { + public FilterableTreeModel(MainWindow mainWindow, TreeNode root) { super(root); this.mainWindow = mainWindow; - this.filterExpansionThreshold = filterExpansionThreshold; this.filter = ""; - this.filteredTreePaths = new ArrayList<>(); this.filteredTreeNodes = new HashSet<>(); } @@ -90,6 +74,7 @@ public synchronized void setFilter(String newFilter) { applyFilterFieldOutline(""); collectFilteredPaths(); SwingUtilities.invokeLater(() -> this.nodeStructureChanged((TreeNode) getRoot())); + SwingUtilities.invokeLater(() -> expandVisibleFilteredNodes(mainWindow.getTree())); } /** @@ -97,26 +82,37 @@ public synchronized void setFilter(String newFilter) { * This should ensure that all treeListeners get the same filter value per event. */ @Override - public synchronized void nodeStructureChanged(TreeNode node) { + public void nodeStructureChanged(TreeNode node) { super.nodeStructureChanged(node); } /** - * Expands the filtered tree paths in the UI, should be called from a treeStructureChanged listener - * after a filter has been set. - * - * @param tree - tree ui component on which to make the filtered paths visible + * If filter enabled, expand all visible nodes. */ - public synchronized void makeFilteredPathsVisible(JTree tree) { - int limit = Math.max(0, filterExpansionThreshold); - int count = 0; - for (TreePath path : filteredTreePaths) { - tree.makeVisible(path); - if (limit != 0 && count++ > limit) { - LOG.warn("Capping displayed results for filter '{}' to {}", filter, limit); - applyFilterFieldOutline("warning"); + public void expandVisibleFilteredNodes(JTree tree) { + if (filter.isEmpty()) { + return; + } + Rectangle rect = tree.getVisibleRect(); + int startRow = tree.getClosestRowForLocation(0, rect.y); + int bottom = rect.y + rect.height - 1; + while (true) { + int lastRow = tree.getClosestRowForLocation(0, bottom); + if (lastRow <= startRow) { break; } + // limit updates for one iteration + int last = Math.min(startRow + 20, lastRow); + for (int i = startRow; i < last; i++) { + TreePath path = tree.getPathForRow(i); + Object node = path.getLastPathComponent(); + if (node instanceof JClass) { + // don't auto expand methods + } else { + tree.expandPath(path); + } + } + startRow = last; } } @@ -124,13 +120,8 @@ private void applyFilterFieldOutline(String outlineType) { UiUtils.uiRun(() -> mainWindow.getTreeFilterField().putClientProperty("JComponent.outline", outlineType)); } - public void setFilterExpansionThreshold(int newThreshold) { - this.filterExpansionThreshold = newThreshold; - } - private void collectFilteredPaths() { UiUtils.notUiThreadGuard(); - filteredTreePaths.clear(); filteredTreeNodes.clear(); if (filter.isEmpty()) { return; @@ -139,19 +130,21 @@ private void collectFilteredPaths() { if (rootNode == null) { return; } - Enumeration en = rootNode.breadthFirstEnumeration(); + int nodesCount = 0; + int filteredCount = 0; + Enumeration en = rootNode.depthFirstEnumeration(); while (en.hasMoreElements()) { TreeNode node = en.nextElement(); + nodesCount++; if (matchesFilter(node)) { - TreePath path = new TreePath(this.getPathToRoot(node)); - filteredTreePaths.add(path); addPathNodes(node); + filteredCount++; } } if (LOG.isDebugEnabled()) { - LOG.debug("Filtered tree paths: {}, nodes: {}", filteredTreePaths.size(), filteredTreeNodes.size()); + LOG.debug("Total nodes: {}, filtered: {}", nodesCount, filteredCount); } - if (filteredTreePaths.isEmpty()) { + if (filteredTreeNodes.isEmpty()) { applyFilterFieldOutline("error"); } } @@ -176,7 +169,7 @@ private void addPathNodes(TreeNode node) { * @return true if the filter is considered matched and the node should be displayed in the tree. */ private boolean matchesFilter(Object node) { - if (node instanceof TextNode) { + if (node instanceof TextNode || node instanceof JMethod) { return false; } if (node instanceof JResource) { @@ -211,8 +204,8 @@ private void loadInnerResources(JResource res) { } @Override - public synchronized Object getChild(Object parent, int index) { - if (filter.isEmpty()) { + public Object getChild(Object parent, int index) { + if (filter.isEmpty() || parent instanceof JClass /* allow to expand and view all methods */) { return super.getChild(parent, index); } int i = 0; @@ -230,7 +223,7 @@ public synchronized Object getChild(Object parent, int index) { } @Override - public synchronized int getChildCount(Object parent) { + public int getChildCount(Object parent) { if (filter.isEmpty()) { return super.getChildCount(parent); } diff --git a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java index b8a0b3a0cef..54e4bd7853f 100644 --- a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java +++ b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java @@ -59,8 +59,6 @@ import javax.swing.UIManager; import javax.swing.WindowConstants; import javax.swing.event.TreeExpansionEvent; -import javax.swing.event.TreeModelEvent; -import javax.swing.event.TreeModelListener; import javax.swing.event.TreeWillExpandListener; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; @@ -1383,8 +1381,9 @@ private void initUI() { DefaultMutableTreeNode treeRootNode = new DefaultMutableTreeNode(NLS.str("msg.open_file")); - treeModel = new FilterableTreeModel(this, treeRootNode, getSettings().getClassTreeFilterExpansionLimit()); + treeModel = new FilterableTreeModel(this, treeRootNode); tree = new JTree(treeModel); + tree.setLargeModel(true); ToolTipManager.sharedInstance().registerComponent(tree); tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); @@ -1479,25 +1478,6 @@ public void treeWillCollapse(TreeExpansionEvent event) { .observeOn(Schedulers.newThread()) .subscribe(t -> treeModel.setFilter(treeFilterField.getText())); - treeModel.addTreeModelListener(new TreeModelListener() { - @Override - public void treeNodesChanged(TreeModelEvent e) { - } - - @Override - public void treeNodesInserted(TreeModelEvent e) { - } - - @Override - public void treeNodesRemoved(TreeModelEvent e) { - } - - @Override - public void treeStructureChanged(TreeModelEvent e) { - treeModel.makeFilteredPathsVisible(tree); - } - }); - JPanel filterPanel = new JPanel(new BorderLayout()); filterPanel.setBorder(BorderFactory.createEmptyBorder(5, 2, 2, 2)); filterPanel.add(treeFilterField, BorderLayout.CENTER); @@ -1505,6 +1485,7 @@ public void treeStructureChanged(TreeModelEvent e) { JPanel leftPane = new JPanel(new BorderLayout()); JScrollPane treeScrollPane = new JScrollPane(tree); treeScrollPane.setMinimumSize(new Dimension(100, 150)); + treeScrollPane.getVerticalScrollBar().addAdjustmentListener(ev -> treeModel.expandVisibleFilteredNodes(tree)); JPanel bottomPane = new JPanel(new BorderLayout()); bottomPane.add(issuesPanel, BorderLayout.PAGE_START); @@ -1620,7 +1601,6 @@ private void updateUiSettings() { } tree.setFont(settings.getCodeFont()); tree.setRowHeight(-1); - treeModel.setFilterExpansionThreshold(settings.getClassTreeFilterExpansionLimit()); tabbedPane.loadSettings(); if (logPanel != null) { @@ -1752,6 +1732,10 @@ public BackgroundExecutor getBackgroundExecutor() { return backgroundExecutor; } + public JTree getTree() { + return tree; + } + public JRoot getTreeRoot() { return treeRoot; } diff --git a/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties b/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties index 7e1ef5e6dca..8befb72d06f 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_de_DE.properties @@ -300,7 +300,6 @@ preferences.cfg=CFG-Grafiken für Methoden generieren (im 'dot'-Format) preferences.raw_cfg=RAW CFG-Grafiken generieren preferences.xposed_codegen_language=Xposed-Code-Generierungssprache preferences.update_channel=Jadx-Updatekanal -#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover preferences.integerFormat=Ganzzahlformat #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_en_US.properties b/jadx-gui/src/main/resources/i18n/Messages_en_US.properties index 44aea8ebe3f..1e97f0763d5 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_en_US.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_en_US.properties @@ -300,7 +300,6 @@ preferences.cfg=Generate methods CFG graphs (in 'dot' format) preferences.raw_cfg=Generate RAW CFG graphs preferences.xposed_codegen_language=Xposed code generation language preferences.update_channel=Jadx update channel -preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit preferences.disable_tooltip_on_hover=Disable tooltip on hover preferences.integerFormat=Integer format preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties b/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties index 1dc27b9210e..47ff3c0fcd1 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_es_ES.properties @@ -300,7 +300,6 @@ preferences.cfg=Generar methods CFG graphs (in 'dot' format) preferences.raw_cfg=Generate RAW CFG graphs #preferences.xposed_codegen_language=Xposed code generation language #preferences.update_channel=Jadx update channel -#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover #preferences.integerFormat=Integer format #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties b/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties index 5f86f3b3509..23f4b81df05 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_id_ID.properties @@ -300,7 +300,6 @@ preferences.cfg=Hasilkan grafik CFG metode (dalam format 'dot') preferences.raw_cfg=Hasilkan grafik CFG mentah #preferences.xposed_codegen_language=Xposed code generation language #preferences.update_channel=Jadx update channel -preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover preferences.integerFormat=Format bilangan bulat #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties b/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties index ccfa7cf3297..9ecc31aba88 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_ko_KR.properties @@ -300,7 +300,6 @@ preferences.cfg=메소드 CFG 그래프 생성 ('dot' 포맷) preferences.raw_cfg=RAW CFG 그래프 생성 #preferences.xposed_codegen_language=Xposed code generation language #preferences.update_channel=Jadx update channel -#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover #preferences.integerFormat=Integer format #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties b/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties index e8ea826437d..e578cbce23c 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_pt_BR.properties @@ -300,7 +300,6 @@ preferences.cfg=Gera gráficos de métodos CFG no formato de pontos ('dot') preferences.raw_cfg=Gera gráficos CFG no formato RAW #preferences.xposed_codegen_language=Xposed code generation language #preferences.update_channel=Jadx update channel -#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover #preferences.integerFormat=Integer format #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties b/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties index 9371964d8bd..2b0c3b8d0b8 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_ru_RU.properties @@ -300,7 +300,6 @@ preferences.cfg=Методы генерации графиков CFG (в "dot" preferences.raw_cfg=Генерировать необработанные графики CFG preferences.xposed_codegen_language=Язык генерации Xposed хуков preferences.update_channel=Канал обновления Jadx -#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover preferences.integerFormat=Формат чисел #preferences.typeUpdatesCountLimit=Update type limit count diff --git a/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties b/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties index f13535f469c..8db6b95248f 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_zh_CN.properties @@ -300,7 +300,6 @@ preferences.cfg=生成方法的 CFG 图(‘.dot’) preferences.raw_cfg=生成原始的 CFG 图 preferences.xposed_codegen_language=Xposed代码生成语言 preferences.update_channel=Jadx 更新通道 -#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit preferences.disable_tooltip_on_hover=禁用悬停提示 preferences.integerFormat=数值格式化 preferences.typeUpdatesCountLimit=类型更新次数上限 diff --git a/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties b/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties index 96b461923b2..196859217f8 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_zh_TW.properties @@ -300,7 +300,6 @@ preferences.cfg=產生方法 CFG 圖表 ('dot' 格式) preferences.raw_cfg=產生 RAW CFG 圖表 preferences.xposed_codegen_language=Xposed 程式碼產生語言 preferences.update_channel=Jadx 更新頻道 -#preferences.class_tree_filter_expansion_limit=Class tree filter expansion limit #preferences.disable_tooltip_on_hover=Disable tooltip on hover preferences.integerFormat=整數模式 #preferences.typeUpdatesCountLimit=Update type limit count From 80366037d94e0b513ea1d7eea9fe62d65163a759 Mon Sep 17 00:00:00 2001 From: "Jan S." Date: Sun, 6 Sep 2026 18:10:07 +0200 Subject: [PATCH 4/5] Prevent ClassCastException if no project is loaded --- .../src/main/java/jadx/gui/ui/FilterableTreeModel.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java index 5d8b2aa9341..990862b9a2d 100644 --- a/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java +++ b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java @@ -126,13 +126,14 @@ private void collectFilteredPaths() { if (filter.isEmpty()) { return; } - JRoot rootNode = (JRoot) this.getRoot(); - if (rootNode == null) { - return; + + Object rootNode = this.getRoot(); + if (!(rootNode instanceof JRoot)) { + return; // root node is null or of a different type } int nodesCount = 0; int filteredCount = 0; - Enumeration en = rootNode.depthFirstEnumeration(); + Enumeration en = ((JRoot) rootNode).depthFirstEnumeration(); while (en.hasMoreElements()) { TreeNode node = en.nextElement(); nodesCount++; From 757af92dee49daca7f42a53a7629b88db9a8022d Mon Sep 17 00:00:00 2001 From: Skylot <118523+skylot@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:58:13 +0100 Subject: [PATCH 5/5] reduce scroll update count, cache one node children, clear filter field on escape --- .../java/jadx/gui/ui/FilterableTreeModel.java | 45 +++++++++++++++++-- .../src/main/java/jadx/gui/ui/MainWindow.java | 23 +++++++--- .../main/java/jadx/gui/utils/rx/RxUtils.java | 12 +++++ 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java index 990862b9a2d..81f7ed53adb 100644 --- a/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java +++ b/jadx-gui/src/main/java/jadx/gui/ui/FilterableTreeModel.java @@ -1,8 +1,10 @@ package jadx.gui.ui; import java.awt.Rectangle; +import java.util.ArrayList; import java.util.Enumeration; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.concurrent.Future; @@ -12,6 +14,7 @@ import javax.swing.tree.TreeNode; import javax.swing.tree.TreePath; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,11 +53,23 @@ class FilterableTreeModel extends DefaultTreeModel { */ private final Set filteredTreeNodes; + /** + * Store nodes expanded automatically to prevent re-expand for nodes collapsed by user + */ + private final Set autoExpandedNodes; + + /** + * Very often 'getChild' method called for all children after 'getChildCount' call. + * Save children list for fast child return. + */ + private final CacheNode cacheNode = new CacheNode(); + public FilterableTreeModel(MainWindow mainWindow, TreeNode root) { super(root); this.mainWindow = mainWindow; this.filter = ""; this.filteredTreeNodes = new HashSet<>(); + this.autoExpandedNodes = new HashSet<>(); } /** @@ -70,7 +85,9 @@ public FilterableTreeModel(MainWindow mainWindow, TreeNode root) { */ public synchronized void setFilter(String newFilter) { this.filter = newFilter; - LOG.debug("New class filter '{}'", newFilter); + this.autoExpandedNodes.clear(); + this.cacheNode.clear(); + LOG.debug("New tree filter '{}'", newFilter); applyFilterFieldOutline(""); collectFilteredPaths(); SwingUtilities.invokeLater(() -> this.nodeStructureChanged((TreeNode) getRoot())); @@ -103,13 +120,15 @@ public void expandVisibleFilteredNodes(JTree tree) { } // limit updates for one iteration int last = Math.min(startRow + 20, lastRow); - for (int i = startRow; i < last; i++) { + for (int i = startRow; i <= lastRow; i++) { TreePath path = tree.getPathForRow(i); - Object node = path.getLastPathComponent(); + JNode node = (JNode) path.getLastPathComponent(); if (node instanceof JClass) { // don't auto expand methods } else { - tree.expandPath(path); + if (autoExpandedNodes.add(node)) { + tree.expandPath(path); + } } } startRow = last; @@ -204,8 +223,21 @@ private void loadInnerResources(JResource res) { } } + private static final class CacheNode { + private @Nullable TreeNode parent; + private final List children = new ArrayList<>(); + + public void clear() { + parent = null; + children.clear(); + } + } + @Override public Object getChild(Object parent, int index) { + if (cacheNode.parent == parent) { + return cacheNode.children.get(index); + } if (filter.isEmpty() || parent instanceof JClass /* allow to expand and view all methods */) { return super.getChild(parent, index); } @@ -232,11 +264,16 @@ public int getChildCount(Object parent) { if (!filteredTreeNodes.contains(parentNode)) { return 0; } + cacheNode.parent = parentNode; + List children = cacheNode.children; + children.clear(); + int count = 0; Enumeration en = parentNode.children(); while (en.hasMoreElements()) { TreeNode child = en.nextElement(); if (filteredTreeNodes.contains(child)) { + children.add(child); count++; } } diff --git a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java index 54e4bd7853f..de69f94a8a1 100644 --- a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java +++ b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java @@ -41,6 +41,7 @@ import javax.swing.Box; import javax.swing.JCheckBox; import javax.swing.JCheckBoxMenuItem; +import javax.swing.JComponent; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JMenu; @@ -54,6 +55,7 @@ import javax.swing.JToggleButton; import javax.swing.JToolBar; import javax.swing.JTree; +import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import javax.swing.ToolTipManager; import javax.swing.UIManager; @@ -79,6 +81,7 @@ import com.formdev.flatlaf.util.UIScale; import ch.qos.logback.classic.Level; +import hu.akarnokd.rxjava3.swing.SwingSchedulers; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -260,6 +263,7 @@ public class MainWindow extends JFrame { private JTextField treeFilterField; private Disposable treeFilterDisposable; + private Disposable treeScrollDisposable; public MainWindow(JadxSettings settings) { this.settings = settings; @@ -823,6 +827,7 @@ public void initTree() { treeRoot = new JRoot(this); treeRoot.setFlatPackages(isFlattenPackage); treeModel.setRoot(treeRoot); + treeFilterField.setText(""); addTreeCustomNodes(); treeRoot.update(); reloadTree(); @@ -1472,6 +1477,8 @@ public void treeWillCollapse(TreeExpansionEvent event) { treeFilterField.setToolTipText(NLS.str("tree.filter")); treeFilterField.putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true); treeFilterField.putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, NLS.str("tree.filter")); + treeFilterField.registerKeyboardAction(ev -> treeFilterField.setText(""), + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), JComponent.WHEN_FOCUSED); treeFilterDisposable = RxUtils.textFieldChanges(treeFilterField) .debounce(300, TimeUnit.MILLISECONDS) @@ -1485,7 +1492,9 @@ public void treeWillCollapse(TreeExpansionEvent event) { JPanel leftPane = new JPanel(new BorderLayout()); JScrollPane treeScrollPane = new JScrollPane(tree); treeScrollPane.setMinimumSize(new Dimension(100, 150)); - treeScrollPane.getVerticalScrollBar().addAdjustmentListener(ev -> treeModel.expandVisibleFilteredNodes(tree)); + treeScrollDisposable = RxUtils.scrollBarEvents(treeScrollPane.getVerticalScrollBar()) + .observeOn(SwingSchedulers.edt()) + .subscribe(v -> treeModel.expandVisibleFilteredNodes(tree)); JPanel bottomPane = new JPanel(new BorderLayout()); bottomPane.add(issuesPanel, BorderLayout.PAGE_START); @@ -1634,10 +1643,14 @@ private void closeWindow() { closeAll(); UiUtils.uiRunAndWait(() -> { - heapUsageBar.reset(); - editorThemeManager.unload(); - treeFilterDisposable.dispose(); - dispose(); + try { + heapUsageBar.reset(); + editorThemeManager.unload(); + treeFilterDisposable.dispose(); + treeScrollDisposable.dispose(); + } finally { + dispose(); + } }); } catch (Exception e) { LOG.error("Close window error", e); diff --git a/jadx-gui/src/main/java/jadx/gui/utils/rx/RxUtils.java b/jadx-gui/src/main/java/jadx/gui/utils/rx/RxUtils.java index f602350898f..67f7f03e9c0 100644 --- a/jadx-gui/src/main/java/jadx/gui/utils/rx/RxUtils.java +++ b/jadx-gui/src/main/java/jadx/gui/utils/rx/RxUtils.java @@ -1,10 +1,12 @@ package jadx.gui.utils.rx; +import java.awt.event.AdjustmentListener; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; import java.util.function.Supplier; +import javax.swing.JScrollBar; import javax.swing.JSpinner; import javax.swing.JTextField; import javax.swing.event.ChangeListener; @@ -57,6 +59,16 @@ public static Flowable spinnerEnterPress(final JSpinner spinner) { return Flowable.create(source, BackpressureStrategy.LATEST).distinctUntilChanged(); } + public static Flowable scrollBarEvents(JScrollBar scrollBar) { + FlowableOnSubscribe source = emitter -> { + AdjustmentListener listener = e -> emitter.onNext(e.getValue()); + scrollBar.addAdjustmentListener(listener); + emitter.setDisposable(new CustomDisposable(() -> scrollBar.removeAdjustmentListener(listener))); + }; + return Flowable.create(source, BackpressureStrategy.LATEST) + .distinctUntilChanged(Integer::equals); + } + private static @NotNull KeyListener enterKeyListener(FlowableEmitter emitter, Supplier supplier) { return new KeyAdapter() { @Override