From 2acd33aad355c708d0b7fac71f1cf721d453f774 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 12 Jun 2024 07:41:55 -0400 Subject: [PATCH] Add call graph display in method view menu --- .../recaf/services/callgraph/ClassLookup.java | 1 + .../recaf/services/callgraph/LinkedClass.java | 7 +- ...BasicMethodContextMenuProviderFactory.java | 27 +- .../recaf/services/navigation/Actions.java | 44 ++- .../ui/control/graph/MethodCallGraphPane.java | 310 ++++++++++++++++++ .../control/graph/MethodCallGraphsPane.java | 108 ++++++ recaf-ui/src/main/resources/style/tweaks.css | 12 + .../main/resources/translations/en_US.lang | 4 + 8 files changed, 501 insertions(+), 12 deletions(-) create mode 100644 recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphPane.java create mode 100644 recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphsPane.java diff --git a/recaf-core/src/main/java/software/coley/recaf/services/callgraph/ClassLookup.java b/recaf-core/src/main/java/software/coley/recaf/services/callgraph/ClassLookup.java index ae6987826..1f774988a 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/callgraph/ClassLookup.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/callgraph/ClassLookup.java @@ -25,6 +25,7 @@ public ClassLookup(@Nonnull Workspace workspace) { @Override public JvmClassInfo apply(String name) { + if (name == null) return null; ClassPathNode classPath = workspace.findJvmClass(name); if (classPath == null) classPath = workspace.findLatestVersionedJvmClass(name); if (classPath == null) return null; diff --git a/recaf-core/src/main/java/software/coley/recaf/services/callgraph/LinkedClass.java b/recaf-core/src/main/java/software/coley/recaf/services/callgraph/LinkedClass.java index 80d5d243a..0faf3453b 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/callgraph/LinkedClass.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/callgraph/LinkedClass.java @@ -3,6 +3,7 @@ import dev.xdark.jlinker.ClassInfo; import dev.xdark.jlinker.MemberInfo; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import software.coley.recaf.analytics.logging.DebuggingLogger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.JvmClassInfo; @@ -111,10 +112,12 @@ public int accessFlags() { return info.getAccess(); } - @Nonnull + @Nullable @Override public ClassInfo superClass() { - return superClassLookup.apply(info.getSuperName()); + String superName = info.getSuperName(); + if (superName == null) return null; + return superClassLookup.apply(superName); } @Nonnull diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/cell/context/BasicMethodContextMenuProviderFactory.java b/recaf-ui/src/main/java/software/coley/recaf/services/cell/context/BasicMethodContextMenuProviderFactory.java index c63cb49f6..49a38d913 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/services/cell/context/BasicMethodContextMenuProviderFactory.java +++ b/recaf-ui/src/main/java/software/coley/recaf/services/cell/context/BasicMethodContextMenuProviderFactory.java @@ -93,6 +93,24 @@ public ContextMenuProvider getMethodContextMenuProvider(@Nonnull ContextSource s // - Add annotation } + // TODO: implement additional operations + // - View + // - Control flow graph + // - Application flow graph + var view = builder.submenu("menu.view", VIEW); + if (declaringClass.isJvmClass()) { + JvmClassBundle jvmBundle = (JvmClassBundle) bundle; + JvmClassInfo declaringJvmClass = declaringClass.asJvmClass(); + view.item("menu.view.methodcallgraph", FLOW, () -> actions.openMethodCallGraph(workspace, resource, jvmBundle,declaringJvmClass, method)); + } + + // TODO: implement additional operations + // - Deobfuscate + // - Regenerate variable names + // - Optimize with pattern matchers + // - Optimize with SSVM + // - Simulate with SSVM (Virtualize > Run) + // Search actions builder.item("menu.search.method-references", CODE_REFERENCE, () -> { MemberReferenceSearchPane pane = actions.openNewMemberReferenceSearch(); @@ -113,15 +131,6 @@ public ContextMenuProvider getMethodContextMenuProvider(@Nonnull ContextSource s // Refactor actions builder.memberItem("menu.refactor.rename", TAG_EDIT, actions::renameMethod); - // TODO: implement additional operations - // - View - // - Control flow graph - // - Application flow graph - // - Deobfuscate - // - Regenerate variable names - // - Optimize with pattern matchers - // - Optimize with SSVM - // - Simulate with SSVM (Virtualize > Run) return menu; }; } diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Actions.java b/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Actions.java index 5ea3b7f5f..e91d13dd1 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Actions.java +++ b/recaf-ui/src/main/java/software/coley/recaf/services/navigation/Actions.java @@ -34,6 +34,7 @@ import software.coley.recaf.services.mapping.MappingResults; import software.coley.recaf.services.window.WindowFactory; import software.coley.recaf.ui.control.FontIconView; +import software.coley.recaf.ui.control.graph.MethodCallGraphsPane; import software.coley.recaf.ui.control.popup.AddMemberPopup; import software.coley.recaf.ui.control.popup.ItemListSelectionPopup; import software.coley.recaf.ui.control.popup.ItemTreeSelectionPopup; @@ -96,11 +97,12 @@ public class Actions implements Service { private final Instance videoPaneProvider; private final Instance assemblerPaneProvider; private final Instance documentationPaneProvider; - private final ActionsConfig config; + private final Instance callGraphsPaneProvider; private final Instance stringSearchPaneProvider; private final Instance numberSearchPaneProvider; private final Instance classReferenceSearchPaneProvider; private final Instance memberReferenceSearchPaneProvider; + private final ActionsConfig config; @Inject public Actions(@Nonnull ActionsConfig config, @@ -122,6 +124,7 @@ public Actions(@Nonnull ActionsConfig config, @Nonnull Instance documentationPaneProvider, @Nonnull Instance stringSearchPaneProvider, @Nonnull Instance numberSearchPaneProvider, + @Nonnull Instance callGraphsPaneProvider, @Nonnull Instance classReferenceSearchPaneProvider, @Nonnull Instance memberReferenceSearchPaneProvider) { this.config = config; @@ -143,6 +146,7 @@ public Actions(@Nonnull ActionsConfig config, this.documentationPaneProvider = documentationPaneProvider; this.stringSearchPaneProvider = stringSearchPaneProvider; this.numberSearchPaneProvider = numberSearchPaneProvider; + this.callGraphsPaneProvider = callGraphsPaneProvider; this.classReferenceSearchPaneProvider = classReferenceSearchPaneProvider; this.memberReferenceSearchPaneProvider = memberReferenceSearchPaneProvider; } @@ -1512,6 +1516,44 @@ else if (path instanceof ClassMemberPathNode classMemberPathNode) }); } + + /** + * Exports a class, prompting the user to select a location to save the class to. + * + * @param workspace + * Containing workspace. + * @param resource + * Containing resource. + * @param bundle + * Containing bundle. + * @param declaringClass + * Class declaring the method + * @param method + * Method to show the incoming/outgoing calls of. + * + * @return Navigable reference to the call graph pane. + */ + @Nonnull + public Navigable openMethodCallGraph(@Nonnull Workspace workspace, + @Nonnull WorkspaceResource resource, + @Nonnull JvmClassBundle bundle, + @Nonnull JvmClassInfo declaringClass, + @Nonnull MethodMember method) { + return createContent(() -> { + // Create text/graphic for the tab to create. + String title = Lang.get("menu.view.methodcallgraph") + ": " + method.getName(); + Node graphic = new FontIconView(CarbonIcons.FLOW); + + // Create content for the tab. + MethodCallGraphsPane content = callGraphsPaneProvider.get(); + content.onUpdatePath(PathNodes.memberPath(workspace, resource, bundle, declaringClass, method)); + + // Build the tab. + return createTab(dockingManager.getPrimaryRegion(), title, graphic, content); + }); + } + + /** * Exports a class, prompting the user to select a location to save the class to. * diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphPane.java new file mode 100644 index 000000000..2f4552e65 --- /dev/null +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphPane.java @@ -0,0 +1,310 @@ +package software.coley.recaf.ui.control.graph; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.event.EventHandler; +import javafx.scene.control.*; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.paint.Color; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator; +import org.kordamp.ikonli.carbonicons.CarbonIcons; +import software.coley.collections.Unchecked; +import software.coley.recaf.info.ClassInfo; +import software.coley.recaf.info.member.ClassMember; +import software.coley.recaf.info.member.MethodMember; +import software.coley.recaf.path.ClassMemberPathNode; +import software.coley.recaf.path.ClassPathNode; +import software.coley.recaf.path.IncompletePathException; +import software.coley.recaf.path.PathNode; +import software.coley.recaf.services.callgraph.CallGraph; +import software.coley.recaf.services.callgraph.MethodVertex; +import software.coley.recaf.services.cell.CellConfigurationService; +import software.coley.recaf.services.cell.context.ContextSource; +import software.coley.recaf.services.navigation.Actions; +import software.coley.recaf.services.navigation.ClassNavigable; +import software.coley.recaf.services.navigation.Navigable; +import software.coley.recaf.services.navigation.UpdatableNavigable; +import software.coley.recaf.services.text.TextFormatConfig; +import software.coley.recaf.ui.control.FontIconView; +import software.coley.recaf.util.CollectionUtil; +import software.coley.recaf.util.FxThreadUtil; +import software.coley.recaf.util.Lang; +import software.coley.recaf.workspace.model.Workspace; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Tree display of method calls. + * + * @author Amejonah + */ +public class MethodCallGraphPane extends BorderPane implements ClassNavigable, UpdatableNavigable { + public static final int MAX_TREE_DEPTH = 20; + private final ObjectProperty currentMethod = new SimpleObjectProperty<>(); + private final CallGraphTreeView graphTreeView = new CallGraphTreeView(); + private final CellConfigurationService configurationService; + private final TextFormatConfig format; + private final CallGraph callGraph; + private final CallGraphMode mode; + private final Workspace workspace; + private final Actions actions; + private ClassPathNode path; + + public MethodCallGraphPane(@Nonnull Workspace workspace, @Nonnull CallGraph callGraph, @Nonnull CellConfigurationService configurationService, + @Nonnull TextFormatConfig format, @Nonnull Actions actions, @Nonnull CallGraphMode mode, + @Nullable ObjectProperty methodInfoObservable) { + this.configurationService = configurationService; + this.workspace = workspace; + this.callGraph = callGraph; + this.actions = actions; + this.format = format; + this.mode = mode; + + currentMethod.addListener((ob, old, cur) -> graphTreeView.onUpdate()); + graphTreeView.onUpdate(); + + setCenter(graphTreeView); + + if (methodInfoObservable != null) currentMethod.bindBidirectional(methodInfoObservable); + } + + @Nullable + @Override + public PathNode getPath() { + return getClassPath(); + } + + @Nonnull + @Override + public ClassPathNode getClassPath() { + return path; + } + + @Override + public void onUpdatePath(@Nonnull PathNode path) { + if (path instanceof ClassPathNode classPath) + this.path = classPath; + } + + @Nonnull + @Override + public Collection getNavigableChildren() { + return Collections.emptyList(); + } + + @Override + public void disable() { + graphTreeView.setRoot(null); + } + + @Override + public void requestFocus(@Nonnull ClassMember member) { + // no-op + } + + public enum CallGraphMode { + CALLS(MethodVertex::getCalls), + CALLERS(MethodVertex::getCallers); + + private final Function> childrenGetter; + + CallGraphMode(@Nonnull Function> getCallers) { + childrenGetter = getCallers; + } + } + + /** + * Item of a class in the hierarchy. + */ + private static class CallGraphItem extends TreeItem implements Comparable { + private static final Comparator comparator = CaseInsensitiveSimpleNaturalComparator.getInstance(); + boolean recursive; + + private CallGraphItem(@Nonnull MethodMember method, boolean recursive) { + super(method); + this.recursive = recursive; + } + + @Nullable + private ClassInfo getDeclaringClass() { + return getValue().getDeclaringClass(); + } + + @Override + public int compareTo(CallGraphItem o) { + // We want the tree display to have items in sorted order by + // package > class > method-name > method-args + int cmp = 0; + MethodMember method = getValue(); + MethodMember otherMethod = o.getValue(); + ClassInfo declaringClass = getDeclaringClass(); + ClassInfo otherDeclaringClass = o.getDeclaringClass(); + if (declaringClass != null) + cmp = comparator.compare(declaringClass.getName(), otherDeclaringClass.getName()); + if (cmp == 0) + cmp = comparator.compare(method.getName(), otherMethod.getName()); + if (cmp == 0) + cmp = comparator.compare(method.getDescriptor(), otherMethod.getDescriptor()); + return cmp; + } + } + + /** + * Cell of a class in the hierarchy. + */ + class CallGraphCell extends TreeCell { + private EventHandler onClickFilter; + + private CallGraphCell() { + getStyleClass().addAll("code-area", "transparent-cell"); + } + + @Override + protected void updateItem(MethodMember method, boolean empty) { + super.updateItem(method, empty); + if (empty || method == null) { + setText(null); + setGraphic(null); + setOnMouseClicked(null); + setContextMenu(null); + if (onClickFilter != null) + removeEventFilter(MouseEvent.MOUSE_PRESSED, onClickFilter); + setOpacity(1); + } else { + onClickFilter = null; + + ClassInfo declaringClass = method.getDeclaringClass(); + if (declaringClass == null) + return; + + ClassPathNode ownerPath = workspace.findClass(declaringClass.getName()); + if (ownerPath == null) + return; + + ClassMemberPathNode methodPath = ownerPath.child(method); + + String methodOwnerName = declaringClass.getName(); + Text classText = new Text(format.filter(methodOwnerName, false, true, true)); + classText.setFill(Color.CADETBLUE); + + Text methodText = new Text(method.getName()); + if (method.hasStaticModifier()) methodText.setFill(Color.LIGHTGREEN); + else methodText.setFill(Color.YELLOW); + + // Layout + TextFlow textFlow = new TextFlow(classText, new Label("#"), methodText, new Label(method.getDescriptor())); + HBox box = new HBox(configurationService.graphicOf(methodPath), textFlow); + box.setSpacing(5); + if (getTreeItem() instanceof CallGraphItem i && i.recursive) { + box.getChildren().add(new FontIconView(CarbonIcons.CODE_REFERENCE)); + box.setOpacity(0.4); + } + setGraphic(box); + + // Context menu support + ContextMenu contextMenu = configurationService.contextMenuOf(ContextSource.REFERENCE, methodPath); + MenuItem focusItem = new MenuItem(); + focusItem.setGraphic(new FontIconView(CarbonIcons.CI_3D_CURSOR_ALT)); + focusItem.textProperty().bind(Lang.getBinding("menu.view.methodcallgraph.focus")); + focusItem.setOnAction(e -> currentMethod.set(method)); + contextMenu.getItems().add(1, focusItem); + setContextMenu(contextMenu); + + // Override the double click behavior to open the class. Doesn't work using the "setOn..." methods. + onClickFilter = e -> { + if (e.getButton().equals(MouseButton.PRIMARY) && e.getClickCount() >= 2) { + e.consume(); + try { + actions.gotoDeclaration(ownerPath).requestFocus(method); + } catch (IncompletePathException ex) { + // TODO: Log error + } + } + }; + addEventFilter(MouseEvent.MOUSE_PRESSED, onClickFilter); + } + } + + public MethodMember getCurrentMethod() { + return currentMethod.get(); + } + + public ObjectProperty currentMethodProperty() { + return currentMethod; + } + } + + private class CallGraphTreeView extends TreeView { + public CallGraphTreeView() { + getStyleClass().add("transparent-tree"); + setCellFactory(param -> new CallGraphCell()); + } + + public void onUpdate() { + final MethodMember methodInfo = currentMethod.get(); + if (methodInfo == null) { + setRoot(null); + } else { + CompletableFuture.supplyAsync(() -> { + while (!callGraph.isReady().getValue()) Unchecked.run(() -> Thread.sleep(100)); + return buildCallGraph(methodInfo, mode.childrenGetter); + }).thenAcceptAsync(root -> { + root.setExpanded(true); + setRoot(root); + }, FxThreadUtil.executor()); + } + } + + @Nonnull + private CallGraphItem buildCallGraph(@Nonnull MethodMember rootMethod, @Nonnull Function> childrenGetter) { + ArrayDeque visitedMethods = new ArrayDeque<>(); + ArrayDeque> workingStack = new ArrayDeque<>(); + CallGraphItem root = new CallGraphItem(rootMethod, false); + workingStack.push(new ArrayList<>(Set.of(root))); + int depth = 0; + while (!workingStack.isEmpty()) { + List todo = workingStack.peek(); + if (!todo.isEmpty()) { + final CallGraphItem item = todo.removeLast(); + if (item.recursive) + continue; + visitedMethods.push(item.getValue()); + depth++; + final MethodVertex vertex = callGraph.getVertex(item.getValue()); + if (vertex != null) { + final List newTodo = childrenGetter.apply(vertex).stream() + .filter(c -> c.getResolvedMethod() != null) + .map(c -> { + MethodMember cm = c.getResolvedMethod(); + return new CallGraphItem(cm, visitedMethods.contains(cm)); + }) + .filter(i -> { + if (i.getValue() == null) return false; + int insert = CollectionUtil.sortedInsertIndex(Unchecked.cast(item.getChildren()), i); + item.getChildren().add(insert, i); + return !i.recursive; + }).collect(Collectors.toList()); + if (!newTodo.isEmpty() && depth < MAX_TREE_DEPTH) { + workingStack.push(newTodo); + } else visitedMethods.pop(); + } + continue; + } + workingStack.pop(); + if (!visitedMethods.isEmpty()) visitedMethods.pop(); + depth--; + } + return root; + } + } +} diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphsPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphsPane.java new file mode 100644 index 000000000..82de63148 --- /dev/null +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/control/graph/MethodCallGraphsPane.java @@ -0,0 +1,108 @@ +package software.coley.recaf.ui.control.graph; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import software.coley.recaf.info.member.ClassMember; +import software.coley.recaf.info.member.MethodMember; +import software.coley.recaf.path.ClassMemberPathNode; +import software.coley.recaf.path.ClassPathNode; +import software.coley.recaf.path.PathNode; +import software.coley.recaf.services.callgraph.CallGraph; +import software.coley.recaf.services.cell.CellConfigurationService; +import software.coley.recaf.services.navigation.Actions; +import software.coley.recaf.services.navigation.ClassNavigable; +import software.coley.recaf.services.navigation.Navigable; +import software.coley.recaf.services.navigation.UpdatableNavigable; +import software.coley.recaf.services.text.TextFormatConfig; +import software.coley.recaf.util.Lang; +import software.coley.recaf.workspace.model.Workspace; + +import java.util.Collection; +import java.util.Collections; + +/** + * Container pane for two {@link MethodCallGraphPane} for inbound and outbound calls. + * + * @author Amejonah + */ +@Dependent +public class MethodCallGraphsPane extends TabPane implements ClassNavigable, UpdatableNavigable { + private final ObjectProperty currentMethodInfo; + private ClassPathNode path; + + @Inject + public MethodCallGraphsPane(@Nonnull Workspace workspace, @Nonnull CallGraph callGraph, + @Nonnull TextFormatConfig format, @Nonnull Actions actions, + @Nonnull CellConfigurationService configurationService) { + currentMethodInfo = new SimpleObjectProperty<>(); + + getTabs().add(creatTab(workspace, callGraph, configurationService, format, actions, MethodCallGraphPane.CallGraphMode.CALLS, currentMethodInfo)); + getTabs().add(creatTab(workspace, callGraph, configurationService, format, actions, MethodCallGraphPane.CallGraphMode.CALLERS, currentMethodInfo)); + } + + @Nonnull + private Tab creatTab(@Nonnull Workspace workspace, @Nonnull CallGraph callGraph, @Nonnull CellConfigurationService configurationService, + @Nonnull TextFormatConfig format, @Nonnull Actions actions, @Nonnull MethodCallGraphPane.CallGraphMode mode, + @Nullable ObjectProperty methodInfoObservable) { + Tab tab = new Tab(); + tab.setContent(new MethodCallGraphPane(workspace, callGraph, configurationService, format, actions, mode, methodInfoObservable)); + tab.textProperty().bind(Lang.getBinding("menu.view.methodcallgraph." + mode.name().toLowerCase())); + tab.setClosable(false); + return tab; + } + + @Nonnull + public ObjectProperty currentMethodInfoProperty() { + return currentMethodInfo; + } + + @Override + public void onUpdatePath(@Nonnull PathNode path) { + if (path instanceof ClassMemberPathNode memberPathNode) { + this.path = memberPathNode.getParent(); + ClassMember member = memberPathNode.getValue(); + if (member instanceof MethodMember method) + currentMethodInfo.setValue(method); + } + } + + @Nonnull + @Override + public ClassPathNode getClassPath() { + return path; + } + + @Nullable + @Override + public PathNode getPath() { + return getClassPath(); + } + + @Override + public boolean isTrackable() { + // Disabling tracking allows other panels with the same path-node to be opened. + return false; + } + + @Nonnull + @Override + public Collection getNavigableChildren() { + return Collections.emptyList(); + } + + @Override + public void disable() { + getTabs().clear(); + } + + @Override + public void requestFocus(@Nonnull ClassMember member) { + // no-op + } +} diff --git a/recaf-ui/src/main/resources/style/tweaks.css b/recaf-ui/src/main/resources/style/tweaks.css index ea02bf017..4554ac042 100644 --- a/recaf-ui/src/main/resources/style/tweaks.css +++ b/recaf-ui/src/main/resources/style/tweaks.css @@ -13,6 +13,7 @@ -fx-background-color: -color-bg-inset; } +/* For the file menu items with nested nodes. We don't want any padding so the whole menu is clickable */ .closable-menu-item { -fx-padding: 0; } @@ -156,4 +157,15 @@ } .analysis-value-changed { -fx-background-color: -color-accent-muted; +} + +.transparent-tree { + -fx-background-color: transparent; + -fx-border-width: 0 0 1 0; +} +.transparent-cell { + -fx-background-color: transparent; +} +.transparent-cell:selected { + -fx-background-color: rgb(54, 58, 65); } \ No newline at end of file diff --git a/recaf-ui/src/main/resources/translations/en_US.lang b/recaf-ui/src/main/resources/translations/en_US.lang index 6436373b1..6c1cbb510 100644 --- a/recaf-ui/src/main/resources/translations/en_US.lang +++ b/recaf-ui/src/main/resources/translations/en_US.lang @@ -92,6 +92,10 @@ menu.view.hierarchy=Class hierarchy menu.view.hierarchy.children=Children menu.view.hierarchy.parents=Parents menu.view.methodcfg=Control flow graph +menu.view.methodcallgraph=Call Graph +menu.view.methodcallgraph.calls=Calls +menu.view.methodcallgraph.callers=Callers +menu.view.methodcallgraph.focus=Focus on Method menu.tab.close=Close menu.tab.closeothers=Close others menu.tab.closeall=Close all