+ * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
+ *)
+
+cytoscape/dist/cytoscape.esm.mjs:
+ (*!
+ Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable
+ Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com)
+ Licensed under The MIT License (http://opensource.org/licenses/MIT)
+ *)
+ (*!
+ Event object based on jQuery events, MIT license
+
+ https://jquery.org/license/
+ https://tldrlegal.com/license/mit-license
+ https://github.com/jquery/jquery/blob/master/src/event.js
+ *)
+ (*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License *)
+ (*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License *)
+*/
+globalThis["mermaid"] = globalThis.__esbuild_esm_mermaid_nm["mermaid"].default;
diff --git a/bndtools.core/src/bndtools/command/ShowBundleGraphHandler.java b/bndtools.core/src/bndtools/command/ShowBundleGraphHandler.java
new file mode 100644
index 0000000000..864e7d4478
--- /dev/null
+++ b/bndtools.core/src/bndtools/command/ShowBundleGraphHandler.java
@@ -0,0 +1,129 @@
+package bndtools.command;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+import org.eclipse.core.commands.AbstractHandler;
+import org.eclipse.core.commands.ExecutionEvent;
+import org.eclipse.core.commands.ExecutionException;
+import org.eclipse.core.resources.IFile;
+import org.eclipse.core.resources.IProject;
+import org.eclipse.jface.viewers.ISelection;
+import org.eclipse.jface.viewers.IStructuredSelection;
+import org.eclipse.ui.IWorkbenchPage;
+import org.eclipse.ui.PartInitException;
+import org.eclipse.ui.PlatformUI;
+import org.eclipse.ui.handlers.HandlerUtil;
+
+import bndtools.views.bundlegraph.BndrunUniverseProvider;
+import bndtools.views.bundlegraph.BundleGraphView;
+import bndtools.views.bundlegraph.ProjectUniverseProvider;
+import bndtools.views.bundlegraph.model.BundleGraphModel;
+import bndtools.views.bundlegraph.model.BundleNode;
+
+/**
+ * Command handler that opens the {@link BundleGraphView} and populates it based on the current workbench selection.
+ *
+ * - If a {@code .bndrun} file is selected: universe = bundles from {@code -runbundles}, seeded as selected.
+ * - If projects are selected: universe = bundles found in those projects, no initial selection.
+ *
+ *
+ * Each invocation opens a new view instance (the view has {@code allowMultiple="true"} in plugin.xml).
+ */
+public class ShowBundleGraphHandler extends AbstractHandler {
+
+ /**
+ * Counter used to generate unique secondary IDs for each new Bundle Graph view instance.
+ * {@link AtomicInteger#getAndIncrement()} is atomic, so concurrent handler executions produce distinct IDs without
+ * any additional synchronization.
+ */
+ private static final AtomicInteger instanceCounter = new AtomicInteger(1);
+
+ @Override
+ public Object execute(ExecutionEvent event) throws ExecutionException {
+ ISelection selection = HandlerUtil.getCurrentSelection(event);
+ if (!(selection instanceof IStructuredSelection)) {
+ return null;
+ }
+ IStructuredSelection structured = (IStructuredSelection) selection;
+
+ IWorkbenchPage page = PlatformUI.getWorkbench()
+ .getActiveWorkbenchWindow()
+ .getActivePage();
+
+ BundleGraphView view = openView(page);
+ if (view == null) {
+ return null;
+ }
+
+ // Check for a .bndrun file in the selection
+ IFile bndrunFile = findBndrunFile(structured);
+ if (bndrunFile != null) {
+ Supplier modelBuilder = () -> new BndrunUniverseProvider().createModel(bndrunFile);
+ BundleGraphModel model = modelBuilder.get();
+ // Seed selected with all nodes from the .bndrun
+ Set initialSelected = new LinkedHashSet<>(model.nodes());
+ view.setInput(model, initialSelected, modelBuilder);
+ return null;
+ }
+
+ // Check for projects
+ List projects = findProjects(structured);
+ if (!projects.isEmpty()) {
+ Supplier modelBuilder = () -> new ProjectUniverseProvider().createModel(projects);
+ BundleGraphModel model = modelBuilder.get();
+ view.setInput(model, new LinkedHashSet<>(model.nodes()), modelBuilder);
+ return null;
+ }
+
+ return null;
+ }
+
+ private BundleGraphView openView(IWorkbenchPage page) {
+ try {
+ String secondaryId = String.valueOf(instanceCounter.getAndIncrement());
+ return (BundleGraphView) page.showView(BundleGraphView.VIEW_ID, secondaryId,
+ IWorkbenchPage.VIEW_ACTIVATE);
+ } catch (PartInitException e) {
+ return null;
+ }
+ }
+
+ private IFile findBndrunFile(IStructuredSelection selection) {
+ for (Iterator> it = selection.iterator(); it.hasNext();) {
+ Object element = it.next();
+ IFile file = null;
+ if (element instanceof IFile) {
+ file = (IFile) element;
+ } else if (element instanceof org.eclipse.core.runtime.IAdaptable) {
+ file = ((org.eclipse.core.runtime.IAdaptable) element).getAdapter(IFile.class);
+ }
+ if (file != null && "bndrun".equals(file.getFileExtension())) {
+ return file;
+ }
+ }
+ return null;
+ }
+
+ private List findProjects(IStructuredSelection selection) {
+ List projects = new ArrayList<>();
+ for (Iterator> it = selection.iterator(); it.hasNext();) {
+ Object element = it.next();
+ IProject project = null;
+ if (element instanceof IProject) {
+ project = (IProject) element;
+ } else if (element instanceof org.eclipse.core.runtime.IAdaptable) {
+ project = ((org.eclipse.core.runtime.IAdaptable) element).getAdapter(IProject.class);
+ }
+ if (project != null && !projects.contains(project)) {
+ projects.add(project);
+ }
+ }
+ return projects;
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/BndrunUniverseProvider.java b/bndtools.core/src/bndtools/views/bundlegraph/BndrunUniverseProvider.java
new file mode 100644
index 0000000000..69213922d8
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/BndrunUniverseProvider.java
@@ -0,0 +1,113 @@
+package bndtools.views.bundlegraph;
+
+import java.io.File;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.core.resources.IFile;
+
+import aQute.bnd.build.Project;
+import aQute.bnd.build.Workspace;
+import aQute.bnd.build.model.BndEditModel;
+import aQute.bnd.build.model.clauses.VersionedClause;
+import aQute.bnd.version.Version;
+import bndtools.central.Central;
+import bndtools.views.bundlegraph.model.BundleEdge;
+import bndtools.views.bundlegraph.model.BundleGraphModel;
+import bndtools.views.bundlegraph.model.BundleNode;
+import bndtools.views.bundlegraph.model.SimpleBundleGraphModel;
+
+/**
+ * Builds a {@link BundleGraphModel} from a {@code .bndrun} file by reading its {@code -runbundles} header.
+ * Dependency edges are derived from the {@code Import-Package} / {@code Export-Package} headers in the generated JAR
+ * manifests of workspace projects.
+ */
+public class BndrunUniverseProvider {
+
+
+ /**
+ * Creates a {@link BundleGraphModel} for the given {@code .bndrun} file. The model contains one node per
+ * {@code -runbundles} entry. Dependency edges are derived by matching Import-Package / Export-Package from the
+ * generated JARs of workspace projects. Each edge records whether it is optional via
+ * {@link bndtools.views.bundlegraph.model.BundleEdge#optional()}.
+ *
+ * @param bndrunFile the .bndrun IFile
+ * @return a BundleGraphModel (never null)
+ */
+ public BundleGraphModel createModel(IFile bndrunFile) {
+ try {
+ File file = bndrunFile.getLocation()
+ .toFile();
+ BndEditModel editModel = new BndEditModel(Central.getWorkspace());
+ editModel.loadFrom(file);
+
+ List runBundles = editModel.getRunBundles();
+ if (runBundles == null || runBundles.isEmpty()) {
+ return new SimpleBundleGraphModel(java.util.Collections.emptySet(),
+ java.util.Collections.emptyMap());
+ }
+
+ // Build a BSN → JAR file map by searching workspace projects
+ Map bsnToJar = buildBsnToJarMap();
+
+ Set nodes = new HashSet<>();
+ Map nodeToJar = new HashMap<>();
+
+ for (VersionedClause vc : runBundles) {
+ String bsn = vc.getName();
+ String version = vc.getVersionRange();
+ BundleNode node = new BundleNode(bsn, version, "");
+ nodes.add(node);
+ File jar = bsnToJar.get(bsn);
+ if (jar != null) {
+ nodeToJar.put(node, jar);
+ }
+ }
+
+ Set edges = ManifestDependencyCalculator.calculateEdges(nodeToJar);
+ return new SimpleBundleGraphModel(nodes, edges, "Bndrun", List.of(bndrunFile.getName()));
+ } catch (Exception e) {
+ return new SimpleBundleGraphModel(java.util.Collections.emptySet(), java.util.Collections.emptyMap());
+ }
+ }
+
+ /**
+ * Builds a map from BSN to JAR file by scanning all workspace projects.
+ */
+ private Map buildBsnToJarMap() {
+ Map bsnToJar = new HashMap<>();
+ try {
+ Workspace ws = Central.getWorkspace();
+ if (ws == null) {
+ return bsnToJar;
+ }
+ Collection allProjects = ws.getAllProjects();
+ for (Project project : allProjects) {
+ try {
+ Map versions = project.getVersions();
+ if (versions == null) {
+ continue;
+ }
+ for (Map.Entry entry : versions.entrySet()) {
+ String bsn = entry.getKey();
+ Version version = entry.getValue();
+ String versionStr = version != null ? version.toString() : "0.0.0";
+ File jar = project.getOutputFile(bsn, versionStr);
+ if (jar != null) {
+ bsnToJar.put(bsn, jar);
+ }
+ }
+ } catch (Exception ignored) {
+ // Skip projects whose metadata or output file cannot be resolved
+ }
+ }
+ } catch (Exception ignored) {
+ // Workspace access failed (e.g., not yet initialised); return empty map
+ }
+ return bsnToJar;
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/BundleGraphDropAdapter.java b/bndtools.core/src/bndtools/views/bundlegraph/BundleGraphDropAdapter.java
new file mode 100644
index 0000000000..44d4ddca09
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/BundleGraphDropAdapter.java
@@ -0,0 +1,244 @@
+package bndtools.views.bundlegraph;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
+
+import org.bndtools.api.ILogger;
+import org.bndtools.api.Logger;
+import org.eclipse.core.resources.IFile;
+import org.eclipse.core.resources.IProject;
+import org.eclipse.core.resources.IResource;
+import org.eclipse.core.runtime.IAdaptable;
+import org.eclipse.jface.util.LocalSelectionTransfer;
+import org.eclipse.jface.viewers.IStructuredSelection;
+import org.eclipse.jface.viewers.Viewer;
+import org.eclipse.jface.viewers.ViewerDropAdapter;
+import org.eclipse.swt.dnd.DND;
+import org.eclipse.swt.dnd.DropTargetEvent;
+import org.eclipse.swt.dnd.TransferData;
+import org.eclipse.ui.part.ResourceTransfer;
+
+import aQute.bnd.service.RepositoryPlugin;
+import aQute.bnd.version.Version;
+import bndtools.model.repo.RepositoryBundle;
+import bndtools.model.repo.RepositoryBundleVersion;
+import bndtools.views.bundlegraph.model.BundleEdge;
+import bndtools.views.bundlegraph.model.BundleGraphModel;
+import bndtools.views.bundlegraph.model.BundleNode;
+
+/**
+ * Drop adapter for the Bundle Graph view's Available bundles and Selected input bundles lists.
+ *
+ * Accepted transfer types:
+ *
+ * - {@link LocalSelectionTransfer} – dragging resources from Eclipse Package Explorer / Project Explorer, and from
+ * the bndtools Repositories view
+ * - {@link ResourceTransfer} – also used by Eclipse resource drag-and-drop
+ *
+ * Supported drop payloads:
+ *
+ * - {@code .bndrun} files → bundles resolved via {@link BndrunUniverseProvider}
+ * - {@link IProject} resources → bundles resolved via {@link ProjectUniverseProvider}
+ * - {@link RepositoryBundleVersion} → exact BSN + version from the Repositories view
+ * - {@link RepositoryBundle} → BSN with its latest version from the Repositories view
+ * - {@link RepositoryPlugin} → all bundles in that repository (delegated to
+ * {@link RepositoryUniverseProvider})
+ *
+ * When dropped on Available bundles, the resolved nodes are merged into the universe. When dropped on
+ * Selected input bundles, the resolved nodes are merged into the universe and added to the selected
+ * set.
+ */
+class BundleGraphDropAdapter extends ViewerDropAdapter {
+
+ private static final ILogger logger = Logger.getLogger(BundleGraphDropAdapter.class);
+
+ private final BundleGraphView view;
+ private final boolean addToSelected;
+
+ /**
+ * @param viewer the viewer this adapter is registered on
+ * @param view the host view
+ * @param addToSelected {@code true} if drops should populate the "Selected input bundles" list; {@code false} to
+ * populate the "Available bundles" universe only
+ */
+ BundleGraphDropAdapter(Viewer viewer, BundleGraphView view, boolean addToSelected) {
+ super(viewer);
+ this.view = view;
+ this.addToSelected = addToSelected;
+ }
+
+ @Override
+ public boolean validateDrop(Object target, int operation, TransferData transferType) {
+ return LocalSelectionTransfer.getTransfer()
+ .isSupportedType(transferType)
+ || ResourceTransfer.getInstance()
+ .isSupportedType(transferType);
+ }
+
+ @Override
+ public void dragEnter(DropTargetEvent event) {
+ super.dragEnter(event);
+ event.detail = DND.DROP_COPY;
+ }
+
+ @Override
+ public boolean performDrop(Object data) {
+ List bndrunFiles = new ArrayList<>();
+ List projects = new ArrayList<>();
+ List wholeRepos = new ArrayList<>();
+ List repoVersions = new ArrayList<>();
+ List repoBundles = new ArrayList<>();
+
+ // Extract from the transfer data (ResourceTransfer or LocalSelectionTransfer)
+ if (data instanceof IResource[]) {
+ for (IResource resource : (IResource[]) data) {
+ categorize(resource, bndrunFiles, projects);
+ }
+ } else if (data instanceof IStructuredSelection) {
+ extractFromSelection((IStructuredSelection) data, bndrunFiles, projects, wholeRepos, repoVersions,
+ repoBundles);
+ }
+
+ // Fallback: also check the LocalSelectionTransfer directly (some DnD paths set it there)
+ if (bndrunFiles.isEmpty() && projects.isEmpty() && wholeRepos.isEmpty() && repoVersions.isEmpty()
+ && repoBundles.isEmpty()) {
+ Object localSel = LocalSelectionTransfer.getTransfer()
+ .getSelection();
+ if (localSel instanceof IStructuredSelection) {
+ extractFromSelection((IStructuredSelection) localSel, bndrunFiles, projects, wholeRepos, repoVersions,
+ repoBundles);
+ }
+ }
+
+ if (bndrunFiles.isEmpty() && projects.isEmpty() && wholeRepos.isEmpty() && repoVersions.isEmpty()
+ && repoBundles.isEmpty()) {
+ return false;
+ }
+
+ // Build the merged node/edge set from all dropped resources
+ Set newNodes = new LinkedHashSet<>();
+ Set newEdges = new LinkedHashSet<>();
+
+ for (IFile bndrunFile : bndrunFiles) {
+ BundleGraphModel m = new BndrunUniverseProvider().createModel(bndrunFile);
+ if (m != null) {
+ newNodes.addAll(m.nodes());
+ newEdges.addAll(m.edges());
+ }
+ }
+
+ if (!projects.isEmpty()) {
+ BundleGraphModel m = new ProjectUniverseProvider().createModel(projects);
+ if (m != null) {
+ newNodes.addAll(m.nodes());
+ newEdges.addAll(m.edges());
+ }
+ }
+
+ // Whole-repository drops: delegate to RepositoryUniverseProvider (downloads all JARs, computes edges)
+ if (!wholeRepos.isEmpty()) {
+ BundleGraphModel m = new RepositoryUniverseProvider().createModel(wholeRepos);
+ newNodes.addAll(m.nodes());
+ newEdges.addAll(m.edges());
+ }
+
+ // Individual bundle/version drops from the Repositories view
+ if (!repoVersions.isEmpty() || !repoBundles.isEmpty()) {
+ Set repoNodes = new LinkedHashSet<>();
+ Map nodeToJar = new HashMap<>();
+
+ for (RepositoryBundleVersion rbv : repoVersions) {
+ addRepoEntry(rbv.getRepo(), rbv.getBsn(), rbv.getVersion(), repoNodes, nodeToJar);
+ }
+ for (RepositoryBundle rb : repoBundles) {
+ try {
+ SortedSet vs = rb.getRepo()
+ .versions(rb.getBsn());
+ if (vs != null && !vs.isEmpty()) {
+ addRepoEntry(rb.getRepo(), rb.getBsn(), vs.last(), repoNodes, nodeToJar);
+ }
+ } catch (Exception e) {
+ logger.logWarning("Failed to determine latest version for " + rb.getBsn(), e);
+ }
+ }
+
+ if (!repoNodes.isEmpty()) {
+ Set edges = ManifestDependencyCalculator.calculateEdges(nodeToJar);
+ newNodes.addAll(repoNodes);
+ newEdges.addAll(edges);
+ }
+ }
+
+ if (newNodes.isEmpty()) {
+ return false;
+ }
+
+ if (addToSelected) {
+ view.addNodesToSelected(newNodes, newEdges);
+ } else {
+ view.mergeIntoUniverse(newNodes, newEdges);
+ }
+
+ return true;
+ }
+
+ /**
+ * Downloads the JAR for the given BSN/version from the repository and registers it for manifest analysis.
+ */
+ private void addRepoEntry(RepositoryPlugin repo, String bsn, Version version, Set nodes,
+ Map nodeToJar) {
+ BundleNode node = new BundleNode(bsn, version.toString(), "");
+ if (nodes.add(node)) {
+ try {
+ File jar = repo.get(bsn, version, Collections.emptyMap());
+ if (jar != null) {
+ nodeToJar.put(node, jar);
+ }
+ } catch (Exception e) {
+ logger.logWarning("Failed to retrieve JAR for " + bsn + " " + version, e);
+ }
+ }
+ }
+
+ private void extractFromSelection(IStructuredSelection sel, List bndrunFiles, List projects,
+ List wholeRepos, List repoVersions,
+ List repoBundles) {
+ for (Iterator> it = sel.iterator(); it.hasNext();) {
+ Object element = it.next();
+ // Repository view items – checked before IAdaptable to avoid masking by an IProject adapter
+ if (element instanceof RepositoryBundleVersion) {
+ repoVersions.add((RepositoryBundleVersion) element);
+ } else if (element instanceof RepositoryBundle) {
+ repoBundles.add((RepositoryBundle) element);
+ } else if (element instanceof RepositoryPlugin) {
+ wholeRepos.add((RepositoryPlugin) element);
+ } else if (element instanceof IResource) {
+ categorize((IResource) element, bndrunFiles, projects);
+ } else if (element instanceof IAdaptable) {
+ IResource resource = ((IAdaptable) element).getAdapter(IResource.class);
+ if (resource != null) {
+ categorize(resource, bndrunFiles, projects);
+ }
+ }
+ }
+ }
+
+ private void categorize(IResource resource, List bndrunFiles, List projects) {
+ if (resource instanceof IFile) {
+ IFile file = (IFile) resource;
+ if ("bndrun".equals(file.getFileExtension())) {
+ bndrunFiles.add(file);
+ }
+ } else if (resource instanceof IProject) {
+ projects.add((IProject) resource);
+ }
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/BundleGraphView.java b/bndtools.core/src/bndtools/views/bundlegraph/BundleGraphView.java
new file mode 100644
index 0000000000..773a01e1fe
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/BundleGraphView.java
@@ -0,0 +1,984 @@
+package bndtools.views.bundlegraph;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import org.bndtools.api.ILogger;
+import org.bndtools.api.Logger;
+import org.bndtools.core.ui.icons.Icons;
+import org.eclipse.core.runtime.FileLocator;
+import org.eclipse.jface.action.Action;
+import org.eclipse.jface.action.IToolBarManager;
+import org.eclipse.jface.util.LocalSelectionTransfer;
+import org.eclipse.jface.viewers.ArrayContentProvider;
+import org.eclipse.jface.viewers.DoubleClickEvent;
+import org.eclipse.jface.viewers.IDoubleClickListener;
+import org.eclipse.jface.viewers.IStructuredSelection;
+import org.eclipse.jface.viewers.LabelProvider;
+import org.eclipse.jface.viewers.TableViewer;
+import org.eclipse.jface.window.Window;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.browser.Browser;
+import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.dnd.Clipboard;
+import org.eclipse.swt.dnd.DND;
+import org.eclipse.swt.dnd.TextTransfer;
+import org.eclipse.swt.dnd.Transfer;
+import org.eclipse.ui.part.ResourceTransfer;
+import org.eclipse.swt.events.KeyAdapter;
+import org.eclipse.swt.events.KeyEvent;
+import org.eclipse.swt.events.ModifyEvent;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.ui.part.ViewPart;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+
+import aQute.bnd.service.RepositoryPlugin;
+import bndtools.central.RepositoryUtils;
+import bndtools.views.bundlegraph.model.BundleEdge;
+import bndtools.views.bundlegraph.model.BundleGraphModel;
+import bndtools.views.bundlegraph.model.BundleNode;
+import bndtools.views.bundlegraph.model.GraphClosures;
+import bndtools.views.bundlegraph.model.SimpleBundleGraphModel;
+import bndtools.views.bundlegraph.render.EdgeFilter;
+import bndtools.views.bundlegraph.render.MermaidRenderer;
+
+/**
+ * Eclipse ViewPart that shows a Mermaid graph of bundle dependencies.
+ *
+ * The view has two halves:
+ *
+ * - Top: a dual-list selection builder (Available | Selected) with filter, mode dropdown, and auto-render
+ * checkbox.
+ * - Bottom: an SWT {@link Browser} rendering the Mermaid graph.
+ *
+ */
+public class BundleGraphView extends ViewPart {
+
+ public static final String VIEW_ID = "bndtools.bundleGraphView";
+
+ private static final ILogger logger = Logger.getLogger(BundleGraphView.class);
+
+ /** Expansion mode for the graph. */
+ public enum ExpansionMode {
+ ONLY_SELECTED("Only selected"),
+ SELECTED_AND_DEPENDENCIES("Selected + dependencies"),
+ SELECTED_AND_DEPENDANTS("Selected + dependants");
+
+ private final String label;
+
+ ExpansionMode(String label) {
+ this.label = label;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+ }
+
+ // Model
+ private BundleGraphModel model = new SimpleBundleGraphModel(Collections.emptySet(),
+ Collections.emptyMap());
+ private Supplier modelSupplier = null;
+ private final Set selected = new LinkedHashSet<>();
+ private ExpansionMode mode = ExpansionMode.ONLY_SELECTED;
+ private boolean autoRender = true;
+ private EdgeFilter edgeFilter = EdgeFilter.ALL;
+
+ // UI
+ private TableViewer availableViewer;
+ private TableViewer selectedViewer;
+ private Text filterText;
+ private Combo modeCombo;
+ private Button autoRenderCheck;
+ private Browser browser;
+ private boolean browserReady;
+ private SashForm sash;
+ private Composite browserPanelComposite;
+ private boolean graphMaximized = false;
+
+ // Last rendered Mermaid definition (used by "Copy Mermaid" action)
+ private String lastMermaidDef = "";
+
+ // Filtered available list
+ private String filterString = "";
+
+ // Origin label (source of the current universe)
+ private Label originLabel;
+
+ @Override
+ public void createPartControl(Composite parent) {
+ parent.setLayout(new FillLayout());
+
+ sash = new SashForm(parent, SWT.VERTICAL);
+
+ // ---- Top panel: selection builder ----
+ createSelectionPanel(sash);
+
+ // ---- Bottom panel: browser ----
+ createBrowserPanel(sash);
+
+ sash.setWeights(new int[] {
+ 45, 55
+ });
+
+ // Toolbar actions
+ IToolBarManager toolbar = getViewSite().getActionBars()
+ .getToolBarManager();
+
+ Action loadRepoAction = new Action("Add from Repositories...") {
+ @Override
+ public void run() {
+ loadFromRepositories();
+ }
+ };
+ loadRepoAction.setImageDescriptor(Icons.desc("icons/database.png"));
+ toolbar.add(loadRepoAction);
+
+ Action copyMermaidToClipboard = new Action("Copy Mermaid to Clipboard") {
+ @Override
+ public void run() {
+ copyToClipboard(lastMermaidDef);
+ }
+ };
+ copyMermaidToClipboard.setImageDescriptor(Icons.desc("icons/page_copy.png"));
+ toolbar.add(copyMermaidToClipboard);
+
+ Action refreshUniverseAction = new Action("Refresh Universe") {
+ @Override
+ public void run() {
+ refreshUniverse();
+ }
+ };
+ refreshUniverseAction.setImageDescriptor(Icons.desc("icons/arrow_refresh_d.png"));
+ toolbar.add(refreshUniverseAction);
+
+ Action togglePanelAction = new Action("Toggle", Action.AS_CHECK_BOX) {
+ @Override
+ public void run() {
+ toggleGraphMaximized();
+ }
+ };
+ togglePanelAction.setToolTipText("Maximize graph (hide selection panel) / Restore split view");
+ togglePanelAction.setImageDescriptor(Icons.desc("icons/collapseall.png"));
+ toolbar.add(togglePanelAction);
+
+ refreshAvailable();
+ }
+
+ private void createSelectionPanel(Composite parent) {
+ Composite top = new Composite(parent, SWT.NONE);
+ GridLayout topLayout = new GridLayout(3, false);
+ topLayout.marginWidth = 5;
+ topLayout.marginHeight = 5;
+ top.setLayout(topLayout);
+
+ // --- Source / origin row ---
+ Label sourceLabel = new Label(top, SWT.NONE);
+ sourceLabel.setText("Source:");
+ sourceLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false));
+
+ originLabel = new Label(top, SWT.NONE);
+ GridData originGd = new GridData(SWT.FILL, SWT.CENTER, true, false);
+ originGd.horizontalSpan = 2;
+ originLabel.setLayoutData(originGd);
+ originLabel.setText("—");
+
+ // --- Filter row ---
+ Label filterLabel = new Label(top, SWT.NONE);
+ filterLabel.setText("Filter:");
+ filterLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false));
+
+ filterText = new Text(top, SWT.BORDER | SWT.SEARCH | SWT.ICON_CANCEL);
+ filterText.setMessage("Filter available bundles...");
+ filterText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ filterText.addModifyListener(new ModifyListener() {
+ @Override
+ public void modifyText(ModifyEvent e) {
+ filterString = filterText.getText()
+ .trim()
+ .toLowerCase();
+ refreshAvailable();
+ }
+ });
+
+ // placeholder for 3rd column alignment
+ new Label(top, SWT.NONE);
+
+ // --- Dual list area ---
+ // Left: available
+ Composite leftPanel = new Composite(top, SWT.NONE);
+ leftPanel.setLayout(new GridLayout(1, false));
+ leftPanel.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+
+ // Header row: label + Remove button (common Eclipse pattern)
+ Composite availHeaderRow = new Composite(leftPanel, SWT.NONE);
+ GridLayout availHeaderLayout = new GridLayout(2, false);
+ availHeaderLayout.marginWidth = 0;
+ availHeaderLayout.marginHeight = 0;
+ availHeaderRow.setLayout(availHeaderLayout);
+ availHeaderRow.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+
+ Label availLabel = new Label(availHeaderRow, SWT.NONE);
+ availLabel.setText("Available bundles:");
+ availLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+
+ Button removeFromUniverseBtn = new Button(availHeaderRow, SWT.PUSH);
+ removeFromUniverseBtn.setText("Remove");
+ removeFromUniverseBtn.setToolTipText("Remove selected bundles from the universe (Del)");
+ removeFromUniverseBtn.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ removeFromUniverse();
+ }
+ });
+
+ availableViewer = new TableViewer(leftPanel, SWT.BORDER | SWT.MULTI | SWT.V_SCROLL);
+ availableViewer.setContentProvider(ArrayContentProvider.getInstance());
+ availableViewer.setLabelProvider(new LabelProvider() {
+ @Override
+ public String getText(Object element) {
+ return element instanceof BundleNode ? element.toString() : String.valueOf(element);
+ }
+ });
+
+ // Double-click adds item to selected
+ availableViewer.addDoubleClickListener(new IDoubleClickListener() {
+ @Override
+ public void doubleClick(DoubleClickEvent event) {
+ addSelected();
+ }
+ });
+
+ // Delete key removes highlighted items from the universe
+ availableViewer.getTable()
+ .addKeyListener(new KeyAdapter() {
+ @Override
+ public void keyPressed(KeyEvent e) {
+ if (e.keyCode == SWT.DEL) {
+ removeFromUniverse();
+ }
+ }
+ });
+
+ Table availTable = availableViewer.getTable();
+ availTable.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+
+ // Center: buttons
+ Composite btnPanel = new Composite(top, SWT.NONE);
+ GridLayout btnLayout = new GridLayout(1, false);
+ btnLayout.marginWidth = 3;
+ btnPanel.setLayout(btnLayout);
+ btnPanel.setLayoutData(new GridData(SWT.CENTER, SWT.CENTER, false, true));
+
+ Button addBtn = new Button(btnPanel, SWT.PUSH);
+ addBtn.setText("Add >");
+ addBtn.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ addBtn.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ addSelected();
+ }
+ });
+
+ Button removeBtn = new Button(btnPanel, SWT.PUSH);
+ removeBtn.setText("< Remove");
+ removeBtn.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ removeBtn.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ removeSelected();
+ }
+ });
+
+ new Label(btnPanel, SWT.SEPARATOR | SWT.HORIZONTAL).setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+
+ Button addDepsBtn = new Button(btnPanel, SWT.PUSH);
+ addDepsBtn.setText("Add deps");
+ addDepsBtn.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ addDepsBtn.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ addDependencies();
+ }
+ });
+
+ Button addDependantsBtn = new Button(btnPanel, SWT.PUSH);
+ addDependantsBtn.setText("Add dependants");
+ addDependantsBtn.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ addDependantsBtn.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ addDependants();
+ }
+ });
+
+ // Right: selected
+ Composite rightPanel = new Composite(top, SWT.NONE);
+ rightPanel.setLayout(new GridLayout(1, false));
+ rightPanel.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+
+ Label selLabel = new Label(rightPanel, SWT.NONE);
+ selLabel.setText("Selected input bundles:");
+
+ selectedViewer = new TableViewer(rightPanel, SWT.BORDER | SWT.MULTI | SWT.V_SCROLL);
+ selectedViewer.setContentProvider(ArrayContentProvider.getInstance());
+ selectedViewer.setLabelProvider(new LabelProvider() {
+ @Override
+ public String getText(Object element) {
+ return element instanceof BundleNode ? element.toString() : String.valueOf(element);
+ }
+ });
+ // Double-click removes item from selection
+ selectedViewer.addDoubleClickListener(new IDoubleClickListener() {
+ @Override
+ public void doubleClick(DoubleClickEvent event) {
+ removeSelected();
+ }
+ });
+ // Ctrl/Cmd+C copies highlighted bundle names to clipboard
+ selectedViewer.getTable()
+ .addKeyListener(new KeyAdapter() {
+ @Override
+ public void keyPressed(KeyEvent e) {
+ if ((e.stateMask & SWT.MOD1) == SWT.MOD1 && e.keyCode == 'c') {
+ copySelectedBundlesToClipboard();
+ }
+ }
+ });
+ Table selTable = selectedViewer.getTable();
+ selTable.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+
+ // --- Options row (mode + auto-render + render button) ---
+ Composite optRow = new Composite(top, SWT.NONE);
+ GridLayout optLayout = new GridLayout(6, false);
+ optLayout.marginWidth = 0;
+ optRow.setLayout(optLayout);
+ GridData optGd = new GridData(SWT.FILL, SWT.CENTER, true, false);
+ optGd.horizontalSpan = 3;
+ optRow.setLayoutData(optGd);
+
+ Label modeLabel = new Label(optRow, SWT.NONE);
+ modeLabel.setText("Mode:");
+
+ modeCombo = new Combo(optRow, SWT.DROP_DOWN | SWT.READ_ONLY);
+ for (ExpansionMode m : ExpansionMode.values()) {
+ modeCombo.add(m.getLabel());
+ }
+ modeCombo.select(0);
+ modeCombo.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ mode = ExpansionMode.values()[modeCombo.getSelectionIndex()];
+ if (autoRender) {
+ rerender();
+ }
+ }
+ });
+
+ autoRenderCheck = new Button(optRow, SWT.CHECK);
+ autoRenderCheck.setText("Auto-render");
+ autoRenderCheck.setSelection(true);
+ autoRenderCheck.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ autoRender = autoRenderCheck.getSelection();
+ }
+ });
+
+ Button renderBtn = new Button(optRow, SWT.PUSH);
+ renderBtn.setText("Render");
+ renderBtn.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ rerender();
+ }
+ });
+
+ // Install drag-and-drop support so users can drop .bndrun files or projects onto either list
+ Transfer[] dndTransfers = new Transfer[] {
+ LocalSelectionTransfer.getTransfer(), ResourceTransfer.getInstance()
+ };
+ availableViewer.addDropSupport(DND.DROP_COPY | DND.DROP_DEFAULT, dndTransfers,
+ new BundleGraphDropAdapter(availableViewer, this, false));
+ selectedViewer.addDropSupport(DND.DROP_COPY | DND.DROP_DEFAULT, dndTransfers,
+ new BundleGraphDropAdapter(selectedViewer, this, true));
+
+ }
+
+ private void createBrowserPanel(Composite parent) {
+ browserPanelComposite = new Composite(parent, SWT.NONE);
+ Composite bottomPanel = browserPanelComposite;
+ GridLayout bottomLayout = new GridLayout(1, false);
+ bottomLayout.marginWidth = 0;
+ bottomLayout.marginHeight = 0;
+ bottomLayout.verticalSpacing = 0;
+ bottomPanel.setLayout(bottomLayout);
+
+ // ---- Zoom toolbar row ----
+ Composite zoomRow = new Composite(bottomPanel, SWT.NONE);
+ zoomRow.setLayout(new GridLayout(7, false));
+ zoomRow.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+
+ Label zoomLabel = new Label(zoomRow, SWT.NONE);
+ zoomLabel.setText("Zoom:");
+
+ Button zoomOut = new Button(zoomRow, SWT.PUSH);
+ zoomOut.setText("-");
+
+ final Combo zoomCombo = new Combo(zoomRow, SWT.DROP_DOWN | SWT.READ_ONLY);
+ final int[] zoomLevels = {
+ 25, 50, 75, 100, 125, 150, 200
+ };
+ final int defaultZoomPercent = 100;
+ int defaultZoomIndex = 0;
+ for (int i = 0; i < zoomLevels.length; i++) {
+ zoomCombo.add(zoomLevels[i] + "%");
+ if (zoomLevels[i] == defaultZoomPercent) {
+ defaultZoomIndex = i;
+ }
+ }
+ zoomCombo.select(defaultZoomIndex);
+
+ Button zoomIn = new Button(zoomRow, SWT.PUSH);
+ zoomIn.setText("+");
+
+ Label edgeFilterLabel = new Label(zoomRow, SWT.NONE);
+ edgeFilterLabel.setText("Dependencies:");
+
+ Combo edgeFilterCombo = new Combo(zoomRow, SWT.DROP_DOWN | SWT.READ_ONLY);
+ edgeFilterCombo.setToolTipText("Controls which dependency edges are shown in the graph");
+ EdgeFilter[] edgeFilters = EdgeFilter.values();
+ int defaultEdgeFilterIndex = 0;
+ for (int i = 0; i < edgeFilters.length; i++) {
+ edgeFilterCombo.add(edgeFilters[i].getLabel());
+ if (edgeFilters[i] == EdgeFilter.ALL) {
+ defaultEdgeFilterIndex = i;
+ }
+ }
+ edgeFilterCombo.select(defaultEdgeFilterIndex);
+ edgeFilterCombo.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ edgeFilter = EdgeFilter.values()[edgeFilterCombo.getSelectionIndex()];
+ rerender();
+ }
+ });
+
+ // ---- Browser area ----
+ Composite browserArea = new Composite(bottomPanel, SWT.NONE);
+ browserArea.setLayout(new FillLayout());
+ browserArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+
+ try {
+ browser = new Browser(browserArea, SWT.NONE);
+ String html = buildMermaidHtml();
+ browser.setText(html);
+ browser.addProgressListener(new org.eclipse.swt.browser.ProgressAdapter() {
+ @Override
+ public void completed(org.eclipse.swt.browser.ProgressEvent event) {
+ browserReady = true;
+ if (autoRender) {
+ rerender();
+ }
+ applyZoom(currentZoomPercent); // ensures JS scale matches
+ // UI state
+ }
+ });
+
+ // Wire zoom combo
+ zoomCombo.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ applyZoom(zoomLevels[zoomCombo.getSelectionIndex()]);
+ }
+ });
+ // Wire zoom-in button
+ zoomIn.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ int idx = zoomCombo.getSelectionIndex();
+ if (idx < zoomLevels.length - 1) {
+ zoomCombo.select(idx + 1);
+ applyZoom(zoomLevels[idx + 1]);
+ }
+ }
+ });
+ // Wire zoom-out button
+ zoomOut.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ int idx = zoomCombo.getSelectionIndex();
+ if (idx > 0) {
+ zoomCombo.select(idx - 1);
+ applyZoom(zoomLevels[idx - 1]);
+ }
+ }
+ });
+ } catch (Exception e) {
+ // Browser widget not available on this platform; show a label instead
+ Label lbl = new Label(browserArea, SWT.WRAP);
+ lbl.setText("Browser widget is not available on this platform. Cannot render Mermaid graph.");
+ // Disable zoom controls when browser is unavailable
+ zoomCombo.setEnabled(false);
+ zoomIn.setEnabled(false);
+ zoomOut.setEnabled(false);
+ }
+ }
+
+ private int currentZoomPercent = 100;
+
+ private void applyZoom(int percentValue) {
+ if (browser == null || browser.isDisposed() || !browserReady)
+ return;
+ currentZoomPercent = percentValue;
+ double target = percentValue / 100.0;
+ browser.execute("window.setZoom(" + target + ");");
+ }
+
+ private String buildMermaidHtml() {
+ String mermaidScriptTag = resolveMermaidScriptTag();
+ return "\n" //
+ + "\n" //
+ + "\n" //
+ + "\n" //
+ + "\n" //
+ + mermaidScriptTag + "\n" //
+ + "\n" //
+ + "\n" //
+ + "\n" //
+ + "\n" //
+ + "\n" //
+ + "\n";
+ }
+
+ /**
+ * Returns a {@code ";
+ }
+ }
+ } catch (IOException e) {
+ logger.logWarning("Failed to resolve bundled mermaid.min.js", e);
+ }
+ throw new IllegalArgumentException("Cannot load mermaid.min.js for graph rendering");
+ }
+
+ // ---- Public API ----
+
+ /**
+ * Sets the model and optionally seeds the selected set.
+ *
+ * @param newModel the new graph model
+ * @param initialSelected seed nodes to select (may be null)
+ */
+ public void setInput(BundleGraphModel newModel, Set initialSelected) {
+ setInput(newModel, initialSelected, null);
+ }
+
+ /**
+ * Sets the model, seeds the selected set, and stores a model builder to re-create the model on "Refresh Universe".
+ * The model already contains all edges (both mandatory and optional); the "Include optional imports" checkbox
+ * filters at render time without rebuilding the model.
+ *
+ * @param newModel the new graph model
+ * @param initialSelected seed nodes to select (may be null)
+ * @param modelBuilder supplier that produces a fresh model (may be null)
+ */
+ public void setInput(BundleGraphModel newModel, Set initialSelected,
+ Supplier modelBuilder) {
+ this.model = newModel != null ? newModel
+ : new SimpleBundleGraphModel(Collections.emptySet(), Collections.emptyMap());
+ this.modelSupplier = modelBuilder;
+ this.selected.clear();
+ if (initialSelected != null) {
+ this.selected.addAll(initialSelected);
+ }
+ // Propagate origin from model to the view label / tab title
+ setOrigin(this.model.origin(), this.model.sources());
+ refreshAvailable();
+ selectedViewer.setInput(new ArrayList<>(selected));
+ if (autoRender) {
+ rerender();
+ }
+ }
+
+ /**
+ * Sets the origin description for this graph session (e.g. "Bndrun: app.bndrun", "Projects: a, b",
+ * "Repositories: Central"). Updates the Source label inside the view and the view's part name (tab title).
+ *
+ * @param origin human-readable origin string, or {@code null} to reset to "—"
+ */
+ public void setOrigin(String origin, List sources) {
+ String originLabelText = (origin != null && !origin.isEmpty()) ? origin : "—";
+ String sourcesLabelText = (sources != null && !sources.isEmpty()) ? ": " + sources.stream()
+ .collect(Collectors.joining(", ")) : "-";
+
+ if (originLabel != null && !originLabel.isDisposed()) {
+ originLabel.setText(originLabelText + shorten(sourcesLabelText, 60));
+ originLabel.setToolTipText(originLabelText + sourcesLabelText);
+ originLabel.getParent()
+ .layout(false);
+ }
+ setPartName("Graph - " + originLabelText);
+ }
+
+ // ---- Private helpers ----
+
+ private void refreshAvailable() {
+ if (availableViewer == null || availableViewer.getControl()
+ .isDisposed()) {
+ return;
+ }
+ List filtered = new ArrayList<>();
+ for (BundleNode node : model.nodes()) {
+ if (filterString.isEmpty() || node.toString()
+ .toLowerCase()
+ .contains(filterString)) {
+ filtered.add(node);
+ }
+ }
+ filtered.sort((a, b) -> a.toString()
+ .compareToIgnoreCase(b.toString()));
+ availableViewer.setInput(filtered);
+ }
+
+ private void addSelected() {
+ IStructuredSelection sel = availableViewer.getStructuredSelection();
+ boolean changed = false;
+ for (Object o : sel) {
+ if (o instanceof BundleNode) {
+ changed |= selected.add((BundleNode) o);
+ }
+ }
+ if (changed) {
+ selectedViewer.setInput(new ArrayList<>(selected));
+ if (autoRender) {
+ rerender();
+ }
+ }
+ }
+
+ private void removeSelected() {
+ IStructuredSelection sel = selectedViewer.getStructuredSelection();
+ boolean changed = false;
+ for (Object o : sel) {
+ if (o instanceof BundleNode) {
+ changed |= selected.remove(o);
+ }
+ }
+ if (changed) {
+ selectedViewer.setInput(new ArrayList<>(selected));
+ if (autoRender) {
+ rerender();
+ }
+ }
+ }
+
+ /**
+ * Removes the highlighted entries in "Available bundles" from the universe model and (if present) from the selected
+ * set. Edges touching any removed node are also pruned from the model.
+ */
+ private void removeFromUniverse() {
+ IStructuredSelection sel = availableViewer.getStructuredSelection();
+ if (sel.isEmpty()) {
+ return;
+ }
+ Set toRemove = new LinkedHashSet<>();
+ for (Object o : sel) {
+ if (o instanceof BundleNode) {
+ toRemove.add((BundleNode) o);
+ }
+ }
+ if (toRemove.isEmpty()) {
+ return;
+ }
+ // Rebuild model without the removed nodes and their edges
+ Set newNodes = new LinkedHashSet<>(model.nodes());
+ newNodes.removeAll(toRemove);
+ Set newEdges = new LinkedHashSet<>();
+ for (BundleEdge edge : model.edges()) {
+ if (!toRemove.contains(edge.from()) && !toRemove.contains(edge.to())) {
+ newEdges.add(edge);
+ }
+ }
+ selected.removeAll(toRemove);
+ this.model = new SimpleBundleGraphModel(newNodes, newEdges, model.origin(), model.sources());
+ refreshAvailable();
+ selectedViewer.setInput(new ArrayList<>(selected));
+ if (autoRender) {
+ rerender();
+ }
+ }
+
+ private void addDependencies() {
+ Set closure = GraphClosures.dependencyClosure(model, new LinkedHashSet<>(selected));
+ boolean changed = selected.addAll(closure);
+ if (changed) {
+ selectedViewer.setInput(new ArrayList<>(selected));
+ if (autoRender) {
+ rerender();
+ }
+ }
+ }
+
+ private void addDependants() {
+ Set closure = GraphClosures.dependantClosure(model, new LinkedHashSet<>(selected));
+ boolean changed = selected.addAll(closure);
+ if (changed) {
+ selectedViewer.setInput(new ArrayList<>(selected));
+ if (autoRender) {
+ rerender();
+ }
+ }
+ }
+
+ private void rerender() {
+ if (browser == null || browser.isDisposed() || !browserReady) {
+ return;
+ }
+ Set subset;
+ switch (mode) {
+ case SELECTED_AND_DEPENDENCIES :
+ subset = GraphClosures.dependencyClosure(model, new LinkedHashSet<>(selected));
+ break;
+ case SELECTED_AND_DEPENDANTS :
+ subset = GraphClosures.dependantClosure(model, new LinkedHashSet<>(selected));
+ break;
+ case ONLY_SELECTED :
+ default :
+ subset = new LinkedHashSet<>(selected);
+ break;
+ }
+ // Pass the user-selected (primary) set so the renderer can style them differently
+ lastMermaidDef = MermaidRenderer.toMermaid(model, subset, new LinkedHashSet<>(selected), edgeFilter);
+ // Escape backticks for JS template literal
+ String escaped = lastMermaidDef.replace("\\", "\\\\")
+ .replace("`", "\\`");
+ browser.execute("window.setDiagram(`" + escaped + "`)");
+ }
+
+ /**
+ * Re-creates the model from the stored supplier (if any) and updates the view, preserving currently selected nodes
+ * that still exist in the new model.
+ */
+ private void refreshUniverse() {
+ if (modelSupplier == null) {
+ // No supplier stored – just re-apply the current filter
+ refreshAvailable();
+ return;
+ }
+ BundleGraphModel newModel = modelSupplier.get();
+ if (newModel == null) {
+ return;
+ }
+ this.model = newModel;
+ setOrigin(newModel.origin(), newModel.sources());
+ // Keep only selected nodes that still exist in the refreshed universe
+ selected.retainAll(newModel.nodes());
+ refreshAvailable();
+ selectedViewer.setInput(new ArrayList<>(selected));
+ if (autoRender) {
+ rerender();
+ }
+ }
+
+ /**
+ * Copies text to the system clipboard.
+ */
+ private void copyToClipboard(String text) {
+ if (text == null || text.isEmpty()) {
+ return;
+ }
+ Clipboard clipboard = new Clipboard(Display.getDefault());
+ try {
+ clipboard.setContents(new Object[] {
+ text
+ }, new Transfer[] {
+ TextTransfer.getInstance()
+ });
+ } finally {
+ clipboard.dispose();
+ }
+ }
+
+ /**
+ * Copies the BSNs of currently highlighted items in "Selected input bundles" to the system clipboard.
+ */
+ private void copySelectedBundlesToClipboard() {
+ IStructuredSelection sel = selectedViewer.getStructuredSelection();
+ if (sel.isEmpty()) {
+ return;
+ }
+ List lines = new ArrayList<>();
+ for (Iterator> it = sel.iterator(); it.hasNext();) {
+ Object element = it.next();
+ if (element instanceof BundleNode) {
+ lines.add(element.toString());
+ }
+ }
+ if (!lines.isEmpty()) {
+ copyToClipboard(String.join(System.lineSeparator(), lines));
+ }
+ }
+
+ /**
+ * Opens a repository selection dialog and merges bundles from the checked repositories into the current universe.
+ * The existing universe (Available bundles and Selected bundles) is preserved; new bundles are added additively.
+ */
+ private void loadFromRepositories() {
+ List allRepos = RepositoryUtils.listRepositories(true);
+ if (allRepos.isEmpty()) {
+ return;
+ }
+
+ Shell shell = getSite().getShell();
+ RepositorySelectionDialog dialog = new RepositorySelectionDialog(shell, allRepos);
+ if (dialog.open() != Window.OK) {
+ return;
+ }
+ List checkedRepos = dialog.getCheckedRepositories();
+ if (checkedRepos.isEmpty()) {
+ return;
+ }
+
+ BundleGraphModel repoModel = new RepositoryUniverseProvider().createModel(checkedRepos);
+ // Capture existing origin before the merge (mergeIntoUniverse preserves the old model's origin)
+ String existingOrigin = model.origin();
+ mergeIntoUniverse(repoModel.nodes(), repoModel.edges());
+ // Update the origin label to reflect the addition
+ String repoOrigin = repoModel.origin();
+ String combinedOrigin = existingOrigin.isEmpty() ? repoOrigin : existingOrigin + " + " + repoOrigin;
+ setOrigin(combinedOrigin, repoModel.sources());
+ }
+
+ /**
+ * Toggles between showing only the graph browser (top panel hidden) and the
+ * normal split view.
+ */
+ private void toggleGraphMaximized() {
+ graphMaximized = !graphMaximized;
+ sash.setMaximizedControl(graphMaximized ? browserPanelComposite : null);
+ }
+
+ /**
+ * Merges new nodes and edges into the current model universe (grows the "Available bundles" pool). Called by the
+ * drop adapter when something is dropped on the "Available bundles" list.
+ */
+ void mergeIntoUniverse(Set newNodes, Set newEdges) {
+ Set mergedNodes = new LinkedHashSet<>(model.nodes());
+ mergedNodes.addAll(newNodes);
+ Set mergedEdges = new LinkedHashSet<>(model.edges());
+ mergedEdges.addAll(newEdges);
+ this.model = new SimpleBundleGraphModel(mergedNodes, mergedEdges, model.origin(), model.sources());
+ refreshAvailable();
+ if (autoRender) {
+ rerender();
+ }
+ }
+
+ /**
+ * Adds new nodes to the "Selected input bundles" list. If any nodes are not yet in the universe, they are also
+ * added to the model. Called by the drop adapter when something is dropped on the "Selected input bundles" list.
+ */
+ void addNodesToSelected(Set newNodes, Set newEdges) {
+ mergeIntoUniverse(newNodes, newEdges);
+ boolean changed = selected.addAll(newNodes);
+ if (changed) {
+ selectedViewer.setInput(new ArrayList<>(selected));
+ if (autoRender) {
+ rerender();
+ }
+ }
+ }
+
+ @Override
+ public void setFocus() {
+ if (availableViewer != null) {
+ availableViewer.getControl()
+ .setFocus();
+ }
+ }
+
+
+ private static String shorten(String s, int maxLen) {
+ if (s == null || s.length() <= maxLen) {
+ return s;
+ }
+
+ // Step 1: cut to maxLen
+ String cut = s.substring(0, maxLen)
+ .trim();
+
+ // Step 2: remove trailing commas
+ while (cut.endsWith(",")) {
+ cut = cut.substring(0, cut.length() - 1);
+ }
+
+ // Step 3: choose suffix
+ String suffix = cut.endsWith(".") ? ".." : "...";
+
+ return cut + suffix;
+ }
+}
\ No newline at end of file
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/ManifestDependencyCalculator.java b/bndtools.core/src/bndtools/views/bundlegraph/ManifestDependencyCalculator.java
new file mode 100644
index 0000000000..005b237581
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/ManifestDependencyCalculator.java
@@ -0,0 +1,138 @@
+package bndtools.views.bundlegraph;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.jar.Manifest;
+
+import aQute.bnd.header.Attrs;
+import aQute.bnd.header.Parameters;
+import aQute.bnd.osgi.Constants;
+import aQute.bnd.osgi.Domain;
+import aQute.bnd.osgi.Jar;
+import bndtools.views.bundlegraph.model.BundleEdge;
+import bndtools.views.bundlegraph.model.BundleNode;
+
+/**
+ * Calculates bundle dependency edges by reading {@code Import-Package} and {@code Export-Package} headers from JAR
+ * manifests.
+ *
+ * An edge {@code A → B} is emitted when bundle A imports a package that bundle B exports. Every edge records whether
+ * it is optional (all contributing {@code Import-Package} entries carry {@code resolution:=optional}) or
+ * mandatory (at least one import is mandatory). Callers can use that flag to filter or style edges at render
+ * time without rebuilding the model.
+ */
+public final class ManifestDependencyCalculator {
+
+ /** The value of the {@code resolution} directive that marks an import as optional. */
+ private static final String RESOLUTION_OPTIONAL = "optional";
+
+ private ManifestDependencyCalculator() {}
+
+ /**
+ * Computes dependency edges for a set of bundles. Both mandatory and optional imports are always included; each
+ * resulting {@link BundleEdge} records whether it is optional via {@link BundleEdge#optional()}.
+ *
+ * @param nodeToJar map from {@link BundleNode} to its JAR {@link File}. Missing files are skipped silently.
+ * @return map where {@code (A, {B, C})} means A depends on B and C.
+ */
+ public static Map> calculateDependencies(Map nodeToJar) {
+ Map> result = new LinkedHashMap<>();
+ for (BundleEdge edge : calculateEdges(nodeToJar)) {
+ result.computeIfAbsent(edge.from(), k -> new LinkedHashSet<>())
+ .add(edge.to());
+ }
+ return result;
+ }
+
+ /**
+ * Computes dependency edges as {@link BundleEdge} objects, preserving per-edge optionality information. Both
+ * mandatory and optional imports are always included; use {@link BundleEdge#optional()} to distinguish them at
+ * render time.
+ *
+ * An edge is optional when every {@code Import-Package} entry contributing to that edge carries
+ * {@code resolution:=optional}; if at least one import is mandatory, the edge is mandatory.
+ *
+ * @param nodeToJar map from {@link BundleNode} to its JAR {@link File}. Missing files are skipped silently.
+ * @return set of {@link BundleEdge}s (one per importer–exporter pair).
+ */
+ public static Set calculateEdges(Map nodeToJar) {
+ // Step 1: collect exports (package → exporting node) and imports with optionality per package
+ Map exportedBy = new HashMap<>();
+ // importer → { pkg → isOptional }
+ Map> importsWithOptional = new LinkedHashMap<>();
+
+ for (Map.Entry entry : nodeToJar.entrySet()) {
+ BundleNode node = entry.getKey();
+ File jarFile = entry.getValue();
+ if (jarFile == null || !jarFile.exists()) {
+ continue;
+ }
+ try (Jar jar = new Jar(jarFile)) {
+ Manifest manifest = jar.getManifest();
+ if (manifest == null) {
+ continue;
+ }
+ Domain domain = Domain.domain(manifest);
+
+ Parameters exportedPkgs = domain.getExportPackage();
+ for (String pkg : exportedPkgs.keySet()) {
+ exportedBy.putIfAbsent(pkg, node);
+ }
+
+ Parameters importedPkgs = domain.getImportPackage();
+ if (!importedPkgs.isEmpty()) {
+ Map pkgOptional = new LinkedHashMap<>();
+ for (Entry pkgEntry : importedPkgs.entrySet()) {
+ String pkg = pkgEntry.getKey();
+ boolean isOptional = RESOLUTION_OPTIONAL
+ .equals(pkgEntry.getValue().get(Constants.RESOLUTION_DIRECTIVE));
+ pkgOptional.put(pkg, isOptional);
+ }
+ if (!pkgOptional.isEmpty()) {
+ importsWithOptional.put(node, pkgOptional);
+ }
+ }
+ } catch (Exception e) {
+ // Skip bundles whose JAR cannot be opened / parsed
+ }
+ }
+
+ // Step 2: match imports against exports; accumulate per-edge optionality
+ // (importer, exporter) → list of per-package optional flags
+ Map>> edgeOptionals = new LinkedHashMap<>();
+ for (Map.Entry> entry : importsWithOptional.entrySet()) {
+ BundleNode importer = entry.getKey();
+ for (Map.Entry pkgEntry : entry.getValue().entrySet()) {
+ String pkg = pkgEntry.getKey();
+ boolean isOptional = pkgEntry.getValue();
+ BundleNode exporter = exportedBy.get(pkg);
+ if (exporter != null && !exporter.equals(importer)) {
+ edgeOptionals.computeIfAbsent(importer, k -> new LinkedHashMap<>())
+ .computeIfAbsent(exporter, k -> new ArrayList<>())
+ .add(isOptional);
+ }
+ }
+ }
+
+ // Step 3: build BundleEdge set; edge is optional iff ALL contributing imports are optional
+ Set edges = new LinkedHashSet<>();
+ for (Map.Entry>> fromEntry : edgeOptionals.entrySet()) {
+ BundleNode from = fromEntry.getKey();
+ for (Map.Entry> toEntry : fromEntry.getValue().entrySet()) {
+ BundleNode to = toEntry.getKey();
+ List flags = toEntry.getValue();
+ boolean edgeIsOptional = flags.stream()
+ .allMatch(b -> b);
+ edges.add(new BundleEdge(from, to, edgeIsOptional));
+ }
+ }
+ return edges;
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/ProjectUniverseProvider.java b/bndtools.core/src/bndtools/views/bundlegraph/ProjectUniverseProvider.java
new file mode 100644
index 0000000000..5d6f19ef7b
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/ProjectUniverseProvider.java
@@ -0,0 +1,79 @@
+package bndtools.views.bundlegraph;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.core.resources.IProject;
+
+import aQute.bnd.build.Project;
+import aQute.bnd.version.Version;
+import bndtools.central.Central;
+import bndtools.views.bundlegraph.model.BundleEdge;
+import bndtools.views.bundlegraph.model.BundleGraphModel;
+import bndtools.views.bundlegraph.model.BundleNode;
+import bndtools.views.bundlegraph.model.SimpleBundleGraphModel;
+
+/**
+ * Builds a {@link BundleGraphModel} from a list of Eclipse {@link IProject} resources. One node is created per BSN
+ * found in each project. Dependency edges are derived from the {@code Import-Package} / {@code Export-Package} headers
+ * in the generated JAR manifests.
+ */
+public class ProjectUniverseProvider {
+
+
+ /**
+ * Creates a {@link BundleGraphModel} for the given list of Eclipse projects.
+ *
+ * @param projects the Eclipse projects
+ * @return a BundleGraphModel (never null)
+ */
+ public BundleGraphModel createModel(List projects) {
+ Set nodes = new HashSet<>();
+ Map nodeToJar = new HashMap<>();
+
+ for (IProject eclipseProject : projects) {
+ try {
+ Project bndProject = Central.getProject(eclipseProject);
+ if (bndProject == null) {
+ continue;
+ }
+ Map versions = bndProject.getVersions();
+ if (versions == null || versions.isEmpty()) {
+ BundleNode node = new BundleNode(eclipseProject.getName(), "", eclipseProject.getName());
+ nodes.add(node);
+ // Try default JAR name
+ try {
+ File jar = bndProject.getOutputFile(eclipseProject.getName());
+ nodeToJar.put(node, jar);
+ } catch (Exception ignored) {
+ // Output file lookup failed (e.g., project not yet built); proceed without JAR
+ } } else {
+ for (Map.Entry entry : versions.entrySet()) {
+ String bsn = entry.getKey();
+ String version = entry.getValue() != null ? entry.getValue()
+ .toString() : "";
+ BundleNode node = new BundleNode(bsn, version, eclipseProject.getName());
+ nodes.add(node);
+ try {
+ File jar = bndProject.getOutputFile(bsn, version);
+ nodeToJar.put(node, jar);
+ } catch (Exception ignored) {
+ // Output file lookup failed (e.g., project not yet built); proceed without JAR
+ } }
+ }
+ } catch (Exception e) {
+ // If we can't get project info, add a node with the project name as BSN
+ nodes.add(new BundleNode(eclipseProject.getName(), "", eclipseProject.getName()));
+ }
+ }
+
+ Set edges = ManifestDependencyCalculator.calculateEdges(nodeToJar);
+ return new SimpleBundleGraphModel(nodes, edges, "Projects", projects.stream()
+ .map(IProject::getName)
+ .toList());
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/RepositorySelectionDialog.java b/bndtools.core/src/bndtools/views/bundlegraph/RepositorySelectionDialog.java
new file mode 100644
index 0000000000..fbf0d6ddf5
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/RepositorySelectionDialog.java
@@ -0,0 +1,125 @@
+package bndtools.views.bundlegraph;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.eclipse.jface.dialogs.Dialog;
+import org.eclipse.jface.dialogs.IDialogConstants;
+import org.eclipse.jface.viewers.ArrayContentProvider;
+import org.eclipse.jface.viewers.CheckboxTableViewer;
+import org.eclipse.jface.viewers.LabelProvider;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+
+import aQute.bnd.service.RepositoryPlugin;
+
+/**
+ * A simple dialog that presents a checkbox list of available bnd repositories and lets the user select which ones to
+ * include in the Bundle Graph universe.
+ */
+class RepositorySelectionDialog extends Dialog {
+
+ private final List repos;
+ private CheckboxTableViewer viewer;
+ private List result = new ArrayList<>();
+
+ RepositorySelectionDialog(Shell parent, List repos) {
+ super(parent);
+ this.repos = repos;
+ setShellStyle(getShellStyle() | SWT.RESIZE);
+ }
+
+ @Override
+ protected void configureShell(Shell shell) {
+ super.configureShell(shell);
+ shell.setText("Select Repositories for Bundle Graph");
+ }
+
+ @Override
+ protected Control createDialogArea(Composite parent) {
+ Composite area = (Composite) super.createDialogArea(parent);
+ Composite container = new Composite(area, SWT.NONE);
+ container.setLayout(new GridLayout(1, false));
+ container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+
+ Label label = new Label(container, SWT.NONE);
+ label.setText("Select the repositories to include in the Bundle Graph universe:");
+ label.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false));
+
+ viewer = CheckboxTableViewer.newCheckList(container, SWT.BORDER | SWT.V_SCROLL);
+ viewer.setContentProvider(ArrayContentProvider.getInstance());
+ viewer.setLabelProvider(new LabelProvider() {
+ @Override
+ public String getText(Object element) {
+ return element instanceof RepositoryPlugin ? ((RepositoryPlugin) element).getName()
+ : String.valueOf(element);
+ }
+ });
+ viewer.setInput(repos);
+ // Pre-check all repositories
+ viewer.setAllChecked(true);
+
+ GridData gd = new GridData(SWT.FILL, SWT.FILL, true, true);
+ gd.heightHint = 200;
+ gd.widthHint = 350;
+ viewer.getTable()
+ .setLayoutData(gd);
+
+ // Select All / Deselect All buttons
+ Composite btnRow = new Composite(container, SWT.NONE);
+ btnRow.setLayout(new GridLayout(2, false));
+ btnRow.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, true, false));
+
+ Button selectAll = new Button(btnRow, SWT.PUSH);
+ selectAll.setText("Select All");
+ selectAll.addSelectionListener(new org.eclipse.swt.events.SelectionAdapter() {
+ @Override
+ public void widgetSelected(org.eclipse.swt.events.SelectionEvent e) {
+ viewer.setAllChecked(true);
+ }
+ });
+
+ Button deselectAll = new Button(btnRow, SWT.PUSH);
+ deselectAll.setText("Deselect All");
+ deselectAll.addSelectionListener(new org.eclipse.swt.events.SelectionAdapter() {
+ @Override
+ public void widgetSelected(org.eclipse.swt.events.SelectionEvent e) {
+ viewer.setAllChecked(false);
+ }
+ });
+
+ return area;
+ }
+
+ @Override
+ protected void createButtonsForButtonBar(Composite parent) {
+ createButton(parent, IDialogConstants.OK_ID, "Load", true);
+ createButton(parent, IDialogConstants.CANCEL_ID, IDialogConstants.CANCEL_LABEL, false);
+ }
+
+ @Override
+ protected void okPressed() {
+ result = new ArrayList<>();
+ for (Object checked : viewer.getCheckedElements()) {
+ if (checked instanceof RepositoryPlugin) {
+ result.add((RepositoryPlugin) checked);
+ }
+ }
+ super.okPressed();
+ }
+
+ /**
+ * Returns the repositories the user selected, in the order they appear in the dialog.
+ *
+ * @return list of selected {@link RepositoryPlugin}s (may be empty, never null)
+ */
+ public List getCheckedRepositories() {
+ return result;
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/RepositoryUniverseProvider.java b/bndtools.core/src/bndtools/views/bundlegraph/RepositoryUniverseProvider.java
new file mode 100644
index 0000000000..a045690888
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/RepositoryUniverseProvider.java
@@ -0,0 +1,81 @@
+package bndtools.views.bundlegraph;
+
+import java.io.File;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
+
+import org.bndtools.api.ILogger;
+import org.bndtools.api.Logger;
+
+import aQute.bnd.service.RepositoryPlugin;
+import aQute.bnd.version.Version;
+import bndtools.views.bundlegraph.model.BundleEdge;
+import bndtools.views.bundlegraph.model.BundleGraphModel;
+import bndtools.views.bundlegraph.model.BundleNode;
+import bndtools.views.bundlegraph.model.SimpleBundleGraphModel;
+
+/**
+ * Builds a {@link BundleGraphModel} from a list of bnd {@link RepositoryPlugin}s. One node is created for the latest
+ * version of every BSN found in the given repositories. Dependency edges are derived from the
+ * {@code Import-Package}/{@code Export-Package} headers in each bundle's JAR manifest.
+ */
+public class RepositoryUniverseProvider {
+
+ private static final ILogger logger = Logger.getLogger(RepositoryUniverseProvider.class);
+
+
+ /**
+ * Creates a {@link BundleGraphModel} from the bundles present in the given repositories.
+ *
+ * For each BSN the latest available version is used. The JAR file is fetched from the repository and its manifest
+ * is analysed to derive dependency edges. Each edge records whether it is optional via
+ * {@link bndtools.views.bundlegraph.model.BundleEdge#optional()}.
+ *
+ * @param repos the repositories to query (must not be null)
+ * @return a BundleGraphModel (never null)
+ */
+ public BundleGraphModel createModel(List repos) {
+ Set nodes = new HashSet<>();
+ Map nodeToJar = new HashMap<>();
+
+ for (RepositoryPlugin repo : repos) {
+ try {
+ List bsns = repo.list(null);
+ if (bsns == null) {
+ continue;
+ }
+ for (String bsn : bsns) {
+ try {
+ SortedSet versions = repo.versions(bsn);
+ if (versions == null || versions.isEmpty()) {
+ continue;
+ }
+ Version latest = versions.last();
+ String versionStr = latest.toString();
+ BundleNode node = new BundleNode(bsn, versionStr, repo.getName());
+ if (nodes.add(node)) {
+ // Download / locate the JAR file for manifest analysis
+ File jar = repo.get(bsn, latest, Collections.emptyMap());
+ if (jar != null) {
+ nodeToJar.put(node, jar);
+ }
+ }
+ } catch (Exception e) {
+ logger.logWarning("Failed to load bundle " + bsn + " from repository " + repo.getName(), e);
+ }
+ }
+ } catch (Exception e) {
+ logger.logWarning("Failed to query repository " + repo.getName(), e);
+ }
+ }
+
+ Set edges = ManifestDependencyCalculator.calculateEdges(nodeToJar);
+ return new SimpleBundleGraphModel(nodes, edges, "Repositories", repos.stream()
+ .map(RepositoryPlugin::getName).toList());
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/model/BundleEdge.java b/bndtools.core/src/bndtools/views/bundlegraph/model/BundleEdge.java
new file mode 100644
index 0000000000..cb34897910
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/model/BundleEdge.java
@@ -0,0 +1,15 @@
+package bndtools.views.bundlegraph.model;
+
+/**
+ * Represents a directed dependency edge between two bundle nodes.
+ *
+ * An edge {@code (from, to)} means bundle {@code from} depends on bundle {@code to} (i.e., {@code from} imports at
+ * least one package that {@code to} exports). The edge is considered optional when every
+ * {@code Import-Package} entry that gave rise to this edge carries {@code resolution:=optional}; if at least one
+ * contributing import is mandatory, the edge is mandatory.
+ *
+ * @param from the importing bundle
+ * @param to the exporting bundle (the dependency)
+ * @param optional {@code true} when every import that created this edge is {@code resolution:=optional}
+ */
+public record BundleEdge(BundleNode from, BundleNode to, boolean optional) {}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/model/BundleGraphModel.java b/bndtools.core/src/bndtools/views/bundlegraph/model/BundleGraphModel.java
new file mode 100644
index 0000000000..4754268ca4
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/model/BundleGraphModel.java
@@ -0,0 +1,58 @@
+package bndtools.views.bundlegraph.model;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Model interface for querying bundle graph nodes and their dependency relationships.
+ */
+public interface BundleGraphModel {
+
+ /**
+ * Returns all known bundle nodes in this model's universe.
+ */
+ Set nodes();
+
+ /**
+ * Returns the direct dependencies of the given node (i.e., bundles that {@code node} depends on).
+ */
+ Set dependenciesOf(BundleNode node);
+
+ /**
+ * Returns the direct dependants of the given node (i.e., bundles that depend on {@code node}).
+ */
+ Set dependantsOf(BundleNode node);
+
+ /**
+ * Returns all directed dependency edges in this model.
+ *
+ * The default implementation derives edges from {@link #dependenciesOf(BundleNode)}, treating all edges as
+ * mandatory. Implementations that track {@link BundleEdge#optional() optional} edges should override this method.
+ */
+ default Set edges() {
+ Set result = new LinkedHashSet<>();
+ for (BundleNode n : nodes()) {
+ for (BundleNode dep : dependenciesOf(n)) {
+ result.add(new BundleEdge(n, dep, false));
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Returns a human-readable description of the data source for this model (e.g. "Bndrun: app.bndrun",
+ * "Projects: a, b", "Repositories: Central"). Returns an empty string if no origin is set.
+ */
+ default String origin() {
+ return "";
+ }
+
+ /**
+ * @return a list of sources belong to {@link #origin()} (e.g. list of
+ * project bsn)
+ */
+ default List sources() {
+ return List.of();
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/model/BundleNode.java b/bndtools.core/src/bndtools/views/bundlegraph/model/BundleNode.java
new file mode 100644
index 0000000000..c6b8926d12
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/model/BundleNode.java
@@ -0,0 +1,54 @@
+package bndtools.views.bundlegraph.model;
+
+import java.util.Objects;
+
+/**
+ * Represents a bundle (OSGi project) node in the Bundle Graph.
+ *
+ * Identity is based solely on {@code bsn} and {@code version}; {@code projectName} is informational metadata and is
+ * intentionally excluded from {@link #equals(Object)} and {@link #hashCode()}. This ensures that a node loaded from a
+ * repository and the same bundle loaded from a workspace project are treated as the same node so that edges can be
+ * shared across providers.
+ */
+public record BundleNode(String bsn, String version, String projectName) {
+
+ public BundleNode(String bsn, String version, String projectName) {
+ this.bsn = Objects.requireNonNull(bsn, "bsn");
+ this.version = version != null ? version : "";
+ this.projectName = projectName != null ? projectName : "";
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (!(obj instanceof BundleNode other))
+ return false;
+ return Objects.equals(bsn, other.bsn) && Objects.equals(version, other.version);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(bsn, version);
+ }
+
+ public String getBsn() {
+ return bsn;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public String getProjectName() {
+ return projectName;
+ }
+
+ @Override
+ public String toString() {
+ if (version.isEmpty()) {
+ return bsn;
+ }
+ return bsn + " " + version;
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/model/GraphClosures.java b/bndtools.core/src/bndtools/views/bundlegraph/model/GraphClosures.java
new file mode 100644
index 0000000000..2ea647eac9
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/model/GraphClosures.java
@@ -0,0 +1,43 @@
+package bndtools.views.bundlegraph.model;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Helper for computing transitive closures over a {@link BundleGraphModel}.
+ */
+public final class GraphClosures {
+
+ private GraphClosures() {}
+
+ /**
+ * Returns the transitive closure of all dependencies reachable from the given seed nodes (including the seeds
+ * themselves).
+ */
+ public static Set dependencyClosure(BundleGraphModel model, Set seeds) {
+ return bfsClosure(seeds, n -> model.dependenciesOf(n));
+ }
+
+ /**
+ * Returns the transitive closure of all dependants that reach any of the seed nodes (including the seeds
+ * themselves).
+ */
+ public static Set dependantClosure(BundleGraphModel model, Set seeds) {
+ return bfsClosure(seeds, n -> model.dependantsOf(n));
+ }
+
+ private static Set bfsClosure(Set seeds,
+ java.util.function.Function> neighbours) {
+ Set visited = new LinkedHashSet<>(seeds);
+ java.util.Queue queue = new java.util.ArrayDeque<>(seeds);
+ while (!queue.isEmpty()) {
+ BundleNode current = queue.poll();
+ for (BundleNode neighbour : neighbours.apply(current)) {
+ if (visited.add(neighbour)) {
+ queue.add(neighbour);
+ }
+ }
+ }
+ return visited;
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/model/SimpleBundleGraphModel.java b/bndtools.core/src/bndtools/views/bundlegraph/model/SimpleBundleGraphModel.java
new file mode 100644
index 0000000000..d801668951
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/model/SimpleBundleGraphModel.java
@@ -0,0 +1,136 @@
+package bndtools.views.bundlegraph.model;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A simple {@link BundleGraphModel} implementation backed by explicit edge maps.
+ */
+public class SimpleBundleGraphModel implements BundleGraphModel {
+
+ private final Set nodes;
+ private final Map> dependencies;
+ private final Map> dependants;
+ private final Set edgeSet;
+ private final String origin;
+ private List sources;
+
+ /**
+ * Constructs a model from a node set and an explicit edge set. The optional flag on each {@link BundleEdge} is
+ * preserved and returned by {@link #edges()}.
+ *
+ * @param nodes all nodes in the universe
+ * @param edges directed dependency edges (may carry optionality information)
+ * @param origin human-readable description of the data source (e.g. "Bndrun: app.bndrun")
+ */
+ public SimpleBundleGraphModel(Set nodes, Set edges, String origin,
+ List sources) {
+ this.origin = origin != null ? origin : "";
+ this.sources = sources != null ? sources : List.of();
+ this.nodes = Collections.unmodifiableSet(new LinkedHashSet<>(nodes));
+ this.edgeSet = Collections.unmodifiableSet(new LinkedHashSet<>(edges));
+ Map> deps = new LinkedHashMap<>();
+ Map> revDeps = new LinkedHashMap<>();
+ for (BundleNode node : nodes) {
+ deps.put(node, new LinkedHashSet<>());
+ revDeps.put(node, new LinkedHashSet<>());
+ }
+ for (BundleEdge edge : edges) {
+ deps.computeIfAbsent(edge.from(), k -> new LinkedHashSet<>())
+ .add(edge.to());
+ revDeps.computeIfAbsent(edge.to(), k -> new LinkedHashSet<>())
+ .add(edge.from());
+ }
+ this.dependencies = Collections.unmodifiableMap(deps);
+ this.dependants = Collections.unmodifiableMap(revDeps);
+ }
+
+ /**
+ * Constructs a model from a node set and an explicit edge set, with no origin description.
+ *
+ * @param nodes all nodes in the universe
+ * @param edges directed dependency edges (may carry optionality information)
+ */
+ public SimpleBundleGraphModel(Set nodes, Set edges) {
+ this(nodes, edges, "", List.of());
+ }
+
+ /**
+ * Constructs a model from a node set and a legacy dependency map. All edges produced from this map are treated as
+ * mandatory (not optional). This constructor exists for backward compatibility with tests and callers that do not
+ * have optionality information.
+ *
+ * @param nodes all nodes in the universe
+ * @param dependencies map from an importing node to the set of exporting nodes it depends on
+ * @param origin human-readable description of the data source (e.g. "Projects: a, b")
+ */
+ public SimpleBundleGraphModel(Set nodes, Map> dependencies,
+ String origin) {
+ this.origin = origin != null ? origin : "";
+ this.nodes = Collections.unmodifiableSet(new LinkedHashSet<>(nodes));
+ Map> deps = new LinkedHashMap<>();
+ Map> revDeps = new LinkedHashMap<>();
+ Set edgeAccum = new LinkedHashSet<>();
+ for (BundleNode node : nodes) {
+ deps.put(node, new LinkedHashSet<>());
+ revDeps.put(node, new LinkedHashSet<>());
+ }
+ for (Map.Entry> entry : dependencies.entrySet()) {
+ BundleNode from = entry.getKey();
+ for (BundleNode to : entry.getValue()) {
+ deps.computeIfAbsent(from, k -> new LinkedHashSet<>())
+ .add(to);
+ revDeps.computeIfAbsent(to, k -> new LinkedHashSet<>())
+ .add(from);
+ edgeAccum.add(new BundleEdge(from, to, false));
+ }
+ }
+ this.dependencies = Collections.unmodifiableMap(deps);
+ this.dependants = Collections.unmodifiableMap(revDeps);
+ this.edgeSet = Collections.unmodifiableSet(edgeAccum);
+ }
+
+ /**
+ * Constructs a model from a node set and a legacy dependency map, with no origin description.
+ *
+ * @param nodes all nodes in the universe
+ * @param dependencies map from an importing node to the set of exporting nodes it depends on
+ */
+ public SimpleBundleGraphModel(Set nodes, Map> dependencies) {
+ this(nodes, dependencies, "");
+ }
+
+ @Override
+ public Set nodes() {
+ return nodes;
+ }
+
+ @Override
+ public Set dependenciesOf(BundleNode node) {
+ return dependencies.getOrDefault(node, Collections.emptySet());
+ }
+
+ @Override
+ public Set dependantsOf(BundleNode node) {
+ return dependants.getOrDefault(node, Collections.emptySet());
+ }
+
+ @Override
+ public Set edges() {
+ return edgeSet;
+ }
+
+ @Override
+ public String origin() {
+ return origin;
+ }
+
+ @Override
+ public List sources() {
+ return sources;
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/render/EdgeFilter.java b/bndtools.core/src/bndtools/views/bundlegraph/render/EdgeFilter.java
new file mode 100644
index 0000000000..e518443b99
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/render/EdgeFilter.java
@@ -0,0 +1,26 @@
+package bndtools.views.bundlegraph.render;
+
+/**
+ * Controls which dependency edges are rendered in the Mermaid graph.
+ */
+public enum EdgeFilter {
+
+ /** Show all edges – both mandatory and optional-only. */
+ ALL("All (mandatory + optional)"),
+
+ /** Show only mandatory edges (edges where at least one contributing import has no {@code resolution:=optional}). */
+ ONLY_MANDATORY("Only mandatory"),
+
+ /** Show only optional-only edges (edges where every contributing import carries {@code resolution:=optional}). */
+ ONLY_OPTIONAL("Only optional");
+
+ private final String label;
+
+ EdgeFilter(String label) {
+ this.label = label;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+}
diff --git a/bndtools.core/src/bndtools/views/bundlegraph/render/MermaidRenderer.java b/bndtools.core/src/bndtools/views/bundlegraph/render/MermaidRenderer.java
new file mode 100644
index 0000000000..9e21ed5958
--- /dev/null
+++ b/bndtools.core/src/bndtools/views/bundlegraph/render/MermaidRenderer.java
@@ -0,0 +1,154 @@
+package bndtools.views.bundlegraph.render;
+
+import java.util.Collections;
+import java.util.Set;
+
+import bndtools.views.bundlegraph.model.BundleEdge;
+import bndtools.views.bundlegraph.model.BundleGraphModel;
+import bndtools.views.bundlegraph.model.BundleNode;
+
+/**
+ * Renders a subset of a {@link BundleGraphModel} as a Mermaid graph definition.
+ */
+public final class MermaidRenderer {
+
+ private MermaidRenderer() {}
+
+ /**
+ * Produces a Mermaid {@code graph LR} definition for the given node subset. All nodes are rendered with the same
+ * style. Only edges where both endpoints are in the subset are included.
+ *
+ * @param model the full graph model
+ * @param subset the nodes to include in the diagram
+ * @return Mermaid graph definition string
+ */
+ public static String toMermaid(BundleGraphModel model, Set subset) {
+ return toMermaid(model, subset, Collections.emptySet(), EdgeFilter.ALL);
+ }
+
+ /**
+ * Produces a Mermaid {@code graph LR} definition for the given node subset, visually distinguishing "primary"
+ * (user-selected seed) nodes from "secondary" (closure) nodes.
+ *
+ * Primary nodes get a solid, highlighted border; secondary nodes get a dashed, muted border. Mandatory dependency
+ * edges are rendered as solid arrows ({@code -->}); optional-only edges (where every contributing
+ * {@code Import-Package} entry carried {@code resolution:=optional}) are rendered as dotted arrows ({@code -.->}).
+ * Only edges where both endpoints are in the subset are included.
+ *
+ * @param model the full graph model
+ * @param subset all nodes to include in the diagram (primary ∪ secondary)
+ * @param primaryNodes the user-selected seed nodes (subset of {@code subset})
+ * @return Mermaid graph definition string
+ */
+ public static String toMermaid(BundleGraphModel model, Set subset, Set primaryNodes) {
+ return toMermaid(model, subset, primaryNodes, EdgeFilter.ALL);
+ }
+
+ /**
+ * Produces a Mermaid {@code graph LR} definition for the given node subset, visually distinguishing "primary"
+ * (user-selected seed) nodes from "secondary" (closure) nodes, and filtering edges according to
+ * {@code edgeFilter}.
+ *
+ * Primary nodes get a solid, highlighted border; secondary nodes get a dashed, muted border. Mandatory dependency
+ * edges are rendered as solid arrows ({@code -->}); optional-only edges are rendered as dotted arrows
+ * ({@code -.->}). The {@code edgeFilter} controls which edges are included:
+ *
+ * - {@link EdgeFilter#ALL} – all edges (mandatory and optional)
+ * - {@link EdgeFilter#ONLY_MANDATORY} – only edges that are not all-optional
+ * - {@link EdgeFilter#ONLY_OPTIONAL} – only edges that are all-optional
+ *
+ * Only edges where both endpoints are in the subset are included.
+ *
+ * @param model the full graph model
+ * @param subset all nodes to include in the diagram (primary ∪ secondary)
+ * @param primaryNodes the user-selected seed nodes (subset of {@code subset})
+ * @param edgeFilter controls which dependency edges to include
+ * @return Mermaid graph definition string
+ */
+ public static String toMermaid(BundleGraphModel model, Set subset, Set primaryNodes,
+ EdgeFilter edgeFilter) {
+
+ // Collect active edges (filtered by edgeFilter, both endpoints in subset)
+ java.util.List activeEdges = new java.util.ArrayList<>();
+ for (BundleEdge edge : model.edges()) {
+ if (edgeFilter == EdgeFilter.ONLY_MANDATORY && edge.optional()) {
+ continue;
+ }
+ if (edgeFilter == EdgeFilter.ONLY_OPTIONAL && !edge.optional()) {
+ continue;
+ }
+ if (!subset.contains(edge.from()) || !subset.contains(edge.to())) {
+ continue;
+ }
+ // When a non-trivial primary set is given and the filter is non-ALL,
+ // restrict to edges that touch at least one primary node so that
+ // secondary-to-secondary edges (e.g. transitive dependencies between
+ // closure nodes) are not shown.
+ if (edgeFilter != EdgeFilter.ALL && !primaryNodes.isEmpty()
+ && !primaryNodes.contains(edge.from()) && !primaryNodes.contains(edge.to())) {
+ continue;
+ }
+ activeEdges.add(edge);
+ }
+
+ // When filtering, only show nodes that participate in at least one active edge
+ Set visibleNodes;
+ if (edgeFilter == EdgeFilter.ALL) {
+ visibleNodes = subset;
+ } else {
+ visibleNodes = new java.util.LinkedHashSet<>();
+ for (BundleEdge edge : activeEdges) {
+ visibleNodes.add(edge.from());
+ visibleNodes.add(edge.to());
+ }
+ }
+
+ boolean hasSecondary = visibleNodes.stream()
+ .anyMatch(n -> !primaryNodes.contains(n));
+ boolean hasPrimary = !primaryNodes.isEmpty();
+
+ StringBuilder sb = new StringBuilder("graph LR\n");
+
+ // Emit classDef declarations when there is a visual distinction to make
+ if (hasPrimary && hasSecondary) {
+ sb.append(" classDef primary fill:#dae8fc,stroke:#1a6496,stroke-width:2px\n");
+ sb.append(" classDef secondary fill:#f5f5f5,stroke:#888888,stroke-width:1px,stroke-dasharray:4 4\n");
+ }
+
+ for (BundleNode node : visibleNodes) {
+ sb.append(" ")
+ .append(nodeId(node))
+ .append("[\"")
+ .append(escape(node.toString()))
+ .append("\"]");
+ if (hasPrimary && hasSecondary) {
+ sb.append(":::").append(primaryNodes.contains(node) ? "primary" : "secondary");
+ }
+ sb.append("\n");
+ }
+
+ for (BundleEdge edge : activeEdges) {
+ // Arrow direction: 'to' exports something that 'from' imports → arrow points to --> from
+ String arrow = edge.optional() ? ".->" : "-->";
+ sb.append(" ")
+ .append(nodeId(edge.to()))
+ .append(" ")
+ .append(arrow)
+ .append(" ")
+ .append(nodeId(edge.from()))
+ .append("\n");
+ }
+ return sb.toString();
+ }
+
+ private static String nodeId(BundleNode node) {
+ // Replace non-alphanumeric characters to produce a valid Mermaid node id
+ return node.getBsn()
+ .replace('.', '_')
+ .replace('-', '_');
+ }
+
+ private static String escape(String text) {
+ return text.replace("\"", "#quot;");
+ }
+}
diff --git a/bndtools.core/test/bndtools/views/bundlegraph/GraphClosuresTest.java b/bndtools.core/test/bndtools/views/bundlegraph/GraphClosuresTest.java
new file mode 100644
index 0000000000..2bc38e5f08
--- /dev/null
+++ b/bndtools.core/test/bndtools/views/bundlegraph/GraphClosuresTest.java
@@ -0,0 +1,190 @@
+package bndtools.views.bundlegraph;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import bndtools.views.bundlegraph.model.BundleGraphModel;
+import bndtools.views.bundlegraph.model.BundleNode;
+import bndtools.views.bundlegraph.model.GraphClosures;
+import bndtools.views.bundlegraph.model.SimpleBundleGraphModel;
+
+public class GraphClosuresTest {
+
+ private static BundleNode node(String bsn) {
+ return new BundleNode(bsn, "1.0.0", bsn);
+ }
+
+ private static BundleGraphModel graph(Set nodes, Map> deps) {
+ return new SimpleBundleGraphModel(nodes, deps);
+ }
+
+ @Test
+ public void emptySeedsProduceEmptyClosure() {
+ BundleNode a = node("a");
+ BundleNode b = node("b");
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+ BundleGraphModel model = graph(nodes, Collections.emptyMap());
+
+ Set result = GraphClosures.dependencyClosure(model, Collections.emptySet());
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void noEdgesReturnsSeedsOnly() {
+ BundleNode a = node("a");
+ BundleNode b = node("b");
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+ BundleGraphModel model = graph(nodes, Collections.emptyMap());
+
+ Set seed = Collections.singleton(a);
+ Set result = GraphClosures.dependencyClosure(model, seed);
+ assertEquals(Collections.singleton(a), result);
+ }
+
+ @Test
+ public void directDependencyIsIncluded() {
+ BundleNode a = node("a");
+ BundleNode b = node("b");
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+
+ Map> deps = new HashMap<>();
+ deps.put(a, Collections.singleton(b)); // a depends on b
+ BundleGraphModel model = graph(nodes, deps);
+
+ Set seed = Collections.singleton(a);
+ Set result = GraphClosures.dependencyClosure(model, seed);
+
+ assertTrue(result.contains(a));
+ assertTrue(result.contains(b));
+ assertEquals(2, result.size());
+ }
+
+ @Test
+ public void transitiveDependenciesAreIncluded() {
+ BundleNode a = node("a");
+ BundleNode b = node("b");
+ BundleNode c = node("c");
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+ nodes.add(c);
+
+ Map> deps = new HashMap<>();
+ deps.put(a, Collections.singleton(b));
+ deps.put(b, Collections.singleton(c));
+ BundleGraphModel model = graph(nodes, deps);
+
+ Set seed = Collections.singleton(a);
+ Set result = GraphClosures.dependencyClosure(model, seed);
+
+ assertTrue(result.contains(a));
+ assertTrue(result.contains(b));
+ assertTrue(result.contains(c));
+ assertEquals(3, result.size());
+ }
+
+ @Test
+ public void cyclicDependenciesAreHandled() {
+ BundleNode a = node("a");
+ BundleNode b = node("b");
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+
+ Set bSet = new LinkedHashSet<>();
+ bSet.add(b);
+ Set aSet = new LinkedHashSet<>();
+ aSet.add(a);
+
+ Map> deps = new HashMap<>();
+ deps.put(a, bSet);
+ deps.put(b, aSet); // cyclic
+ BundleGraphModel model = graph(nodes, deps);
+
+ Set seed = Collections.singleton(a);
+ Set result = GraphClosures.dependencyClosure(model, seed);
+
+ assertEquals(2, result.size());
+ }
+
+ /**
+ * BundleNode identity is bsn+version only; projectName is metadata. A node loaded from a repository (projectName
+ * "") and one loaded from a workspace project (projectName "myProject") must be equal so that edges are shared
+ * across providers.
+ */
+ @Test
+ public void bundleNodesWithSameBsnVersionButDifferentProjectNameAreEqual() {
+ BundleNode fromRepo = new BundleNode("com.example.bundle", "1.0.0", "");
+ BundleNode fromProject = new BundleNode("com.example.bundle", "1.0.0", "myProject");
+ assertEquals(fromRepo, fromProject, "nodes with same bsn+version must be equal regardless of projectName");
+ assertEquals(fromRepo.hashCode(), fromProject.hashCode(),
+ "equal nodes must have same hashCode");
+
+ BundleNode differentVersion = new BundleNode("com.example.bundle", "2.0.0", "");
+ assertNotEquals(fromRepo, differentVersion, "nodes with different version must not be equal");
+ }
+
+ /**
+ * Simulates the drop-onto-selected bug: edges are built with repo-created nodes; a project-created node with the
+ * same bsn+version (but different projectName) is used as the seed for the closure. Because identity is now
+ * bsn+version only, the closure must find the transitive dependencies.
+ */
+ @Test
+ public void closureFindsEdgesWhenSeedNodeHasDifferentProjectName() {
+ BundleNode repoA = new BundleNode("a", "1.0.0", ""); // repo-created
+ BundleNode repoB = new BundleNode("b", "1.0.0", ""); // repo-created
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(repoA);
+ nodes.add(repoB);
+
+ Map> deps = new HashMap<>();
+ deps.put(repoA, Collections.singleton(repoB)); // a depends on b (edge uses repo nodes)
+ BundleGraphModel model = graph(nodes, deps);
+
+ // Seed with project-created node (different projectName but same bsn+version as repoA)
+ BundleNode projectA = new BundleNode("a", "1.0.0", "myProject");
+ Set result = GraphClosures.dependencyClosure(model, Collections.singleton(projectA));
+
+ assertEquals(2, result.size(), "transitive dep b must be found even though seed has different projectName");
+ assertTrue(result.stream().anyMatch(n -> n.bsn().equals("b")), "b must be in the closure");
+ }
+
+ @Test
+ public void dependantClosureFindsWhatDependsOnSeed() {
+ BundleNode a = node("a");
+ BundleNode b = node("b");
+ BundleNode c = node("c");
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+ nodes.add(c);
+
+ Map> deps = new HashMap<>();
+ deps.put(b, Collections.singleton(a)); // b depends on a
+ deps.put(c, Collections.singleton(a)); // c depends on a
+ BundleGraphModel model = graph(nodes, deps);
+
+ Set seed = Collections.singleton(a);
+ Set result = GraphClosures.dependantClosure(model, seed);
+
+ assertTrue(result.contains(a));
+ assertTrue(result.contains(b));
+ assertTrue(result.contains(c));
+ assertEquals(3, result.size());
+ }
+}
diff --git a/bndtools.core/test/bndtools/views/bundlegraph/ManifestDependencyCalculatorTest.java b/bndtools.core/test/bndtools/views/bundlegraph/ManifestDependencyCalculatorTest.java
new file mode 100644
index 0000000000..d19bbdc803
--- /dev/null
+++ b/bndtools.core/test/bndtools/views/bundlegraph/ManifestDependencyCalculatorTest.java
@@ -0,0 +1,197 @@
+package bndtools.views.bundlegraph;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.jar.Attributes;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import bndtools.views.bundlegraph.model.BundleEdge;
+import bndtools.views.bundlegraph.model.BundleNode;
+
+public class ManifestDependencyCalculatorTest {
+
+ @TempDir
+ File tmp;
+
+ private static BundleNode node(String bsn) {
+ return new BundleNode(bsn, "1.0.0", bsn);
+ }
+
+ /** Creates a minimal JAR file with the given Import-Package and Export-Package values. */
+ private File createJar(String name, String exportPackage, String importPackage) throws Exception {
+ Manifest manifest = new Manifest();
+ Attributes attrs = manifest.getMainAttributes();
+ attrs.put(Attributes.Name.MANIFEST_VERSION, "1.0");
+ attrs.putValue("Bundle-SymbolicName", name);
+ if (exportPackage != null && !exportPackage.isEmpty()) {
+ attrs.putValue("Export-Package", exportPackage);
+ }
+ if (importPackage != null && !importPackage.isEmpty()) {
+ attrs.putValue("Import-Package", importPackage);
+ }
+ File jarFile = new File(tmp, name + ".jar");
+ try (JarOutputStream jos = new JarOutputStream(new java.io.FileOutputStream(jarFile), manifest)) {
+ // empty JAR body is fine for manifest parsing
+ }
+ return jarFile;
+ }
+
+ @Test
+ public void noEdgesWhenNoImports() throws Exception {
+ BundleNode a = node("a");
+ BundleNode b = node("b");
+
+ Map nodeToJar = new HashMap<>();
+ nodeToJar.put(a, createJar("a", "com.example.a", null));
+ nodeToJar.put(b, createJar("b", "com.example.b", null));
+
+ Map> deps = ManifestDependencyCalculator.calculateDependencies(nodeToJar);
+ assertTrue(deps.isEmpty(), "No imports means no dependency edges");
+ }
+
+ @Test
+ public void edgeCreatedForMatchingImportExport() throws Exception {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ // b exports com.example.api; a imports it
+ Map nodeToJar = new HashMap<>();
+ nodeToJar.put(a, createJar("bundle.a", null, "com.example.api"));
+ nodeToJar.put(b, createJar("bundle.b", "com.example.api", null));
+
+ Map> deps = ManifestDependencyCalculator.calculateDependencies(nodeToJar);
+
+ Set aDeps = deps.get(a);
+ assertTrue(aDeps != null && aDeps.contains(b), "bundle.a should depend on bundle.b (which exports com.example.api)");
+ assertEquals(1, aDeps.size());
+ }
+
+ @Test
+ public void selfImportsAreIgnored() throws Exception {
+ BundleNode a = node("bundle.a");
+
+ // a exports and imports the same package
+ Map nodeToJar = new HashMap<>();
+ nodeToJar.put(a, createJar("bundle.a", "com.example.api", "com.example.api"));
+
+ Map> deps = ManifestDependencyCalculator.calculateDependencies(nodeToJar);
+ assertTrue(deps.isEmpty(), "Self-imports should not produce a self-loop edge");
+ }
+
+ @Test
+ public void missingJarIsSkippedGracefully() throws Exception {
+ BundleNode a = node("bundle.a");
+
+ Map nodeToJar = new HashMap<>();
+ nodeToJar.put(a, new File(tmp, "nonexistent.jar"));
+
+ Map> deps = ManifestDependencyCalculator.calculateDependencies(nodeToJar);
+ assertTrue(deps.isEmpty(), "Missing JAR should be skipped without error");
+ }
+
+ @Test
+ public void transitiveDependenciesResolvedCorrectly() throws Exception {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+ BundleNode c = node("bundle.c");
+
+ // c exports pkg.c; b imports pkg.c and exports pkg.b; a imports pkg.b
+ Map nodeToJar = new HashMap<>();
+ nodeToJar.put(a, createJar("bundle.a", null, "pkg.b"));
+ nodeToJar.put(b, createJar("bundle.b", "pkg.b", "pkg.c"));
+ nodeToJar.put(c, createJar("bundle.c", "pkg.c", null));
+
+ Map> deps = ManifestDependencyCalculator.calculateDependencies(nodeToJar);
+
+ // a depends on b (directly)
+ assertTrue(deps.getOrDefault(a, Set.of())
+ .contains(b), "a should depend on b");
+ // b depends on c (directly)
+ assertTrue(deps.getOrDefault(b, Set.of())
+ .contains(c), "b should depend on c");
+ // a does not directly depend on c
+ assertFalse(deps.getOrDefault(a, Set.of())
+ .contains(c), "a should not directly depend on c");
+ }
+
+ // ---- Tests for calculateEdges() with per-edge optionality ----
+
+ @Test
+ public void allOptionalImportsProduceOptionalEdge() throws Exception {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ Map nodeToJar = new HashMap<>();
+ // a imports both packages from b, both are optional → edge should be optional
+ nodeToJar.put(a, createJar("bundle.a", null,
+ "com.example.api;resolution:=optional,com.example.spi;resolution:=optional"));
+ nodeToJar.put(b, createJar("bundle.b", "com.example.api,com.example.spi", null));
+
+ Set edges = ManifestDependencyCalculator.calculateEdges(nodeToJar);
+
+ assertEquals(1, edges.size(), "Should be exactly one edge");
+ BundleEdge edge = edges.iterator().next();
+ assertEquals(a, edge.from(), "Edge should originate from bundle.a");
+ assertEquals(b, edge.to(), "Edge should point to bundle.b");
+ assertTrue(edge.optional(), "Edge should be optional when all imports are optional");
+ }
+
+ @Test
+ public void mixedImportsProduceMandatoryEdge() throws Exception {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ Map nodeToJar = new HashMap<>();
+ // a imports one optional and one mandatory package from b → edge is mandatory
+ nodeToJar.put(a, createJar("bundle.a", null,
+ "com.example.api;resolution:=optional,com.example.spi"));
+ nodeToJar.put(b, createJar("bundle.b", "com.example.api,com.example.spi", null));
+
+ Set edges = ManifestDependencyCalculator.calculateEdges(nodeToJar);
+
+ assertEquals(1, edges.size(), "Should be exactly one edge");
+ BundleEdge edge = edges.iterator().next();
+ assertFalse(edge.optional(), "Edge should be mandatory when at least one import is mandatory");
+ }
+
+ @Test
+ public void mandatoryImportProducesMandatoryEdge() throws Exception {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ Map nodeToJar = new HashMap<>();
+ nodeToJar.put(a, createJar("bundle.a", null, "com.example.api"));
+ nodeToJar.put(b, createJar("bundle.b", "com.example.api", null));
+
+ Set edges = ManifestDependencyCalculator.calculateEdges(nodeToJar);
+
+ assertEquals(1, edges.size());
+ assertFalse(edges.iterator().next().optional(), "Mandatory import should produce mandatory edge");
+ }
+
+ @Test
+ public void optionalEdgeIsAlwaysIncludedInModel() throws Exception {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ // b exports com.example.api; a imports it as optional
+ Map nodeToJar = new HashMap<>();
+ nodeToJar.put(a, createJar("bundle.a", null, "com.example.api;resolution:=optional"));
+ nodeToJar.put(b, createJar("bundle.b", "com.example.api", null));
+
+ // calculateEdges always includes optional edges, flagged via BundleEdge.optional()
+ Set edges = ManifestDependencyCalculator.calculateEdges(nodeToJar);
+ assertEquals(1, edges.size(), "Optional edge should always be included in the model");
+ assertTrue(edges.iterator().next().optional(), "Edge should be flagged optional");
+ }
+}
diff --git a/bndtools.core/test/bndtools/views/bundlegraph/MermaidRendererTest.java b/bndtools.core/test/bndtools/views/bundlegraph/MermaidRendererTest.java
new file mode 100644
index 0000000000..78374653cf
--- /dev/null
+++ b/bndtools.core/test/bndtools/views/bundlegraph/MermaidRendererTest.java
@@ -0,0 +1,362 @@
+package bndtools.views.bundlegraph;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import bndtools.views.bundlegraph.model.BundleEdge;
+import bndtools.views.bundlegraph.model.BundleGraphModel;
+import bndtools.views.bundlegraph.model.BundleNode;
+import bndtools.views.bundlegraph.model.SimpleBundleGraphModel;
+import bndtools.views.bundlegraph.render.EdgeFilter;
+import bndtools.views.bundlegraph.render.MermaidRenderer;
+
+public class MermaidRendererTest {
+
+ private static BundleNode node(String bsn) {
+ return new BundleNode(bsn, "1.0.0", bsn);
+ }
+
+ private static BundleGraphModel graph(Set nodes, Map> deps) {
+ return new SimpleBundleGraphModel(nodes, deps);
+ }
+
+ private static BundleGraphModel graphWithEdges(Set nodes, Set edges) {
+ return new SimpleBundleGraphModel(nodes, edges);
+ }
+
+ @Test
+ public void emptySubsetProducesMinimalGraph() {
+ BundleGraphModel model = graph(Collections.emptySet(), Collections.emptyMap());
+ String result = MermaidRenderer.toMermaid(model, Collections.emptySet());
+ assertTrue(result.startsWith("graph LR\n"), "Should start with graph LR header");
+ }
+
+ @Test
+ public void singleNodeAppearsInOutput() {
+ BundleNode a = node("com.example.bundle");
+ Set nodes = Collections.singleton(a);
+ BundleGraphModel model = graph(nodes, Collections.emptyMap());
+
+ String result = MermaidRenderer.toMermaid(model, nodes);
+
+ assertTrue(result.contains("com_example_bundle"), "Node id should replace dots with underscores");
+ assertTrue(result.contains("com.example.bundle"), "Node label should contain original BSN");
+ }
+
+ @Test
+ public void edgeAppearsWhenBothEndpointsInSubset() {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+
+ Map> deps = new HashMap<>();
+ deps.put(a, Collections.singleton(b)); // a depends on b
+
+ BundleGraphModel model = graph(nodes, deps);
+ String result = MermaidRenderer.toMermaid(model, nodes);
+
+ // Edge should be: b --> a (b exports something a imports)
+ assertTrue(result.contains("bundle_b --> bundle_a"), "Should have solid edge from b to a");
+ }
+
+ @Test
+ public void edgeOmittedWhenEndpointNotInSubset() {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ Set all = new LinkedHashSet<>();
+ all.add(a);
+ all.add(b);
+
+ Map> deps = new HashMap<>();
+ deps.put(a, Collections.singleton(b));
+
+ BundleGraphModel model = graph(all, deps);
+ // Subset contains only 'a', not 'b'
+ Set subset = Collections.singleton(a);
+ String result = MermaidRenderer.toMermaid(model, subset);
+
+ assertFalse(result.contains("-->") || result.contains(".->"),
+ "No edges when dependency endpoint not in subset");
+ }
+
+ // ---- Tests for the styled (primary/secondary) overload ----
+
+ @Test
+ public void noClassDefsWhenAllNodesArePrimary() {
+ BundleNode a = node("bundle.a");
+ Set nodes = Collections.singleton(a);
+ BundleGraphModel model = graph(nodes, Collections.emptyMap());
+
+ // subset == primary: no secondary nodes → no classDef emitted
+ String result = MermaidRenderer.toMermaid(model, nodes, nodes);
+ assertFalse(result.contains("classDef"), "No classDef when all nodes are primary");
+ assertFalse(result.contains(":::"), "No class assignment when all nodes are primary");
+ }
+
+ @Test
+ public void classDefsEmittedWhenMixedPrimaryAndSecondary() {
+ BundleNode primary = node("bundle.primary");
+ BundleNode secondary = node("bundle.secondary");
+
+ Set allNodes = new LinkedHashSet<>();
+ allNodes.add(primary);
+ allNodes.add(secondary);
+ BundleGraphModel model = graph(allNodes, Collections.emptyMap());
+
+ Set primarySet = Collections.singleton(primary);
+ String result = MermaidRenderer.toMermaid(model, allNodes, primarySet);
+
+ assertTrue(result.contains("classDef primary"), "Primary classDef should be declared");
+ assertTrue(result.contains("classDef secondary"), "Secondary classDef should be declared");
+ // Node declarations look like: bundle_primary["..."]:::primary
+ assertTrue(result.contains("]:::primary"), "Primary node should carry :::primary class");
+ assertTrue(result.contains("]:::secondary"), "Secondary node should carry :::secondary class");
+ }
+
+ @Test
+ public void noClassDefsWhenPrimarySetIsEmpty() {
+ BundleNode a = node("bundle.a");
+ Set nodes = Collections.singleton(a);
+ BundleGraphModel model = graph(nodes, Collections.emptyMap());
+
+ // Empty primary set → nothing to distinguish, no styling
+ String result = MermaidRenderer.toMermaid(model, nodes, Collections.emptySet());
+ assertFalse(result.contains("classDef"), "No classDef when primary set is empty");
+ }
+
+ // ---- Tests for optional vs mandatory edge rendering ----
+
+ @Test
+ public void mandatoryEdgeRenderedAsSolidArrow() {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+
+ // a depends on b via a mandatory edge
+ Set edges = Collections.singleton(new BundleEdge(a, b, false));
+ BundleGraphModel model = graphWithEdges(nodes, edges);
+
+ String result = MermaidRenderer.toMermaid(model, nodes);
+ assertTrue(result.contains("bundle_b --> bundle_a"), "Mandatory edge should use solid --> arrow");
+ assertFalse(result.contains(".->"), "Mandatory edge should not use dotted arrow");
+ }
+
+ @Test
+ public void optionalEdgeRenderedAsDottedArrow() {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+
+ // a depends on b via an all-optional edge
+ Set edges = Collections.singleton(new BundleEdge(a, b, true));
+ BundleGraphModel model = graphWithEdges(nodes, edges);
+
+ String result = MermaidRenderer.toMermaid(model, nodes);
+ assertTrue(result.contains("bundle_b .-> bundle_a"), "Optional edge should use dotted .-> arrow");
+ assertFalse(result.contains("-->"), "Optional edge should not use solid arrow");
+ }
+
+ @Test
+ public void optionalEdgeHiddenWhenIncludeOptionalIsFalse() {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+
+ // a depends on b via an all-optional edge
+ Set edges = Collections.singleton(new BundleEdge(a, b, true));
+ BundleGraphModel model = graphWithEdges(nodes, edges);
+
+ // includeOptional=false → dotted arrow should be suppressed, and both nodes hidden (no connected edges)
+ String result = MermaidRenderer.toMermaid(model, nodes, Collections.emptySet(), EdgeFilter.ONLY_MANDATORY);
+ assertFalse(result.contains("bundle_b .-> bundle_a"), "Optional edge should be hidden with EdgeFilter.ONLY_MANDATORY");
+ assertFalse(result.contains("-->"), "No solid arrow either");
+ assertFalse(result.contains("bundle_a"), "Disconnected node a should be hidden");
+ assertFalse(result.contains("bundle_b"), "Disconnected node b should be hidden");
+ }
+
+ @Test
+ public void mandatoryEdgeShownEvenWhenIncludeOptionalIsFalse() {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+
+ Set nodes = new LinkedHashSet<>();
+ nodes.add(a);
+ nodes.add(b);
+
+ // a depends on b via a mandatory edge
+ Set edges = Collections.singleton(new BundleEdge(a, b, false));
+ BundleGraphModel model = graphWithEdges(nodes, edges);
+
+ // mandatory edge must still appear when ONLY_MANDATORY filter is active
+ String result = MermaidRenderer.toMermaid(model, nodes, Collections.emptySet(), EdgeFilter.ONLY_MANDATORY);
+ assertTrue(result.contains("bundle_b --> bundle_a"), "Mandatory edge must still be shown");
+ }
+
+ @Test
+ public void mixedEdgesFilteredCorrectlyWhenIncludeOptionalIsFalse() {
+ BundleNode a = node("bundle.a");
+ BundleNode b = node("bundle.b");
+ BundleNode c = node("bundle.c");
+
+ Set