From c6f51d13b589a04a436b1df6728363c0b9ac7b33 Mon Sep 17 00:00:00 2001 From: peterkir Date: Fri, 21 Aug 2026 12:07:48 +0200 Subject: [PATCH] Display and navigate merged include properties --- .github/dependabot.yml | 4 - .github/workflows/codeql.yml | 4 +- .../test/test/BndEditModelTest.java | 42 ++ .../aQute/bnd/build/model/BndEditModel.java | 34 ++ .../aQute/bnd/build/model/package-info.java | 2 +- bndtools.core/_plugin.xml | 29 +- bndtools.core/bndtools.win32.x86_64.bndrun | 3 + .../src/bndtools/editor/BndEditor.java | 37 ++ .../bndtools/editor/pages/ProjectRunPage.java | 6 - .../project/AbstractRequirementListPart.java | 213 ++++++++-- .../editor/project/BndEditModelAccessor.java | 268 ++++++++++++ .../project/IncludeConflictDetector.java | 204 +++++++++ ...ludeConflictMarkerResolutionGenerator.java | 69 ++++ .../RepositoryBundleSelectionPart.java | 139 ++++++- .../editor/project/RunBlacklistPart.java | 16 +- .../editor/project/RunBundlesPart.java | 7 +- .../editor/project/RunPropertiesPart.java | 389 +++++++++++++++--- .../editor/project/RunRequirementsPart.java | 39 +- .../model/repo/RepositoryBundleUtils.java | 26 ++ .../src/org/bndtools/core/ui/icons/Icons.java | 2 +- 20 files changed, 1383 insertions(+), 150 deletions(-) create mode 100644 bndtools.core/src/bndtools/editor/project/BndEditModelAccessor.java create mode 100644 bndtools.core/src/bndtools/editor/project/IncludeConflictDetector.java create mode 100644 bndtools.core/src/bndtools/editor/project/IncludeConflictMarkerResolutionGenerator.java diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b6cda11ae6..7e3de66179 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,10 +14,6 @@ updates: directory: "/" schedule: interval: "daily" - groups: - codeql-action: - patterns: - - "github/codeql-action*" # Maintain dependencies for maven - package-ecosystem: "maven" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 42efa70733..2751df8538 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -57,7 +57,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb - name: Initialize CodeQL Analysis - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e with: languages: 'java' - name: Build for CodeQL Analysis @@ -65,4 +65,4 @@ jobs: run: | ./.github/scripts/codeql-build.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e diff --git a/biz.aQute.bndlib.tests/test/test/BndEditModelTest.java b/biz.aQute.bndlib.tests/test/test/BndEditModelTest.java index c483e44779..aa7d753d62 100644 --- a/biz.aQute.bndlib.tests/test/test/BndEditModelTest.java +++ b/biz.aQute.bndlib.tests/test/test/BndEditModelTest.java @@ -352,4 +352,46 @@ private String getPortablePath(File base) { return path; } + /** + * Regression test for the ClassCastException reported in PR #7356: a merge + * key read/written with the generic string accessors cached a String under + * a key whose typed getter expects a List. The typed accessors must keep + * the object cache consistently typed. + */ + @Test + public void testTypedPropertyMergeKeys() throws Exception { + Run run = Run.createRun(null, DEBUG_BNDRUN); + BndEditModel model = new BndEditModel(run); + + // -runrequires only exists in the included file, not the document + List plain = model.getTypedProperty(Constants.RUNREQUIRES); + assertThat(plain).isNull(); + + // suffixed merge key is converted with the stem's converter + List debug = model.getTypedProperty("-runrequires.debug"); + assertThat(debug).hasSize(2); + + // typed reads/writes by key must not break the typed setters (CCE) + model.setTypedProperty("-runrequires.debug", debug); + model.setRunRequires(new ExtList<>(getReq("(osgi.identity=x)"))); + assertThat(model.getRunRequires()).containsExactly(getReq("(osgi.identity=x)")); + + // typed write to a new suffixed key uses the stem's formatter + model.setTypedProperty("-runrequires.local", new ExtList<>(getReq("(osgi.identity=y)"))); + assertThat(model.getDocumentChanges()).containsEntry("-runrequires.local", + "osgi.identity;filter:='(osgi.identity=y)'"); + List local = model.getTypedProperty("-runrequires.local"); + assertThat(local).containsExactly(getReq("(osgi.identity=y)")); + + // -runblacklist now has a registered converter/formatter + model.setTypedProperty(Constants.RUNBLACKLIST, new ExtList<>(getReq("(osgi.identity=bad)"))); + assertThat(model.getRunBlacklist()).containsExactly(getReq("(osgi.identity=bad)")); + assertThat(model.getDocumentChanges()).containsEntry(Constants.RUNBLACKLIST, + "osgi.identity;filter:='(osgi.identity=bad)'"); + + // keys without a registered converter fall back to plain strings + model.setTypedProperty("foo", "FOO"); + assertThat((String) model.getTypedProperty("foo")).isEqualTo("FOO"); + } + } diff --git a/biz.aQute.bndlib/src/aQute/bnd/build/model/BndEditModel.java b/biz.aQute.bndlib/src/aQute/bnd/build/model/BndEditModel.java index 86d95b6331..defa77fd91 100644 --- a/biz.aQute.bndlib/src/aQute/bnd/build/model/BndEditModel.java +++ b/biz.aQute.bndlib/src/aQute/bnd/build/model/BndEditModel.java @@ -307,6 +307,7 @@ public ImportPattern error( // converters.put(BndConstants.RUNVMARGS, stringConverter); converters.put(Constants.TESTCASES, listConverter); converters.put(Constants.RUNREQUIRES, requirementListConverter); + converters.put(Constants.RUNBLACKLIST, requirementListConverter); converters.put(Constants.RUNEE, eeConverter); converters.put(Constants.RUNREPOS, listConverter); // converters.put(BndConstants.RESOLVE_MODE, resolveModeConverter); @@ -349,6 +350,7 @@ public ImportPattern error( // formatters.put(BndConstants.TESTSUITES, stringListFormatter); formatters.put(Constants.TESTCASES, stringListFormatter); formatters.put(Constants.RUNREQUIRES, requirementListFormatter); + formatters.put(Constants.RUNBLACKLIST, requirementListFormatter); formatters.put(Constants.RUNEE, eeFormatter); formatters.put(Constants.RUNREPOS, runReposFormatter); // formatters.put(BndConstants.RESOLVE_MODE, resolveModeFormatter); @@ -1437,6 +1439,38 @@ public void setGenericString(String name, String value) { doSetObject(name, getGenericString(name), value, stringConverter); } + /** + * Get a property value using the converter registered for the key's stem, + * so merge keys such as {@code -runrequires.extra} are converted to the + * same type as their stem key. Falls back to the raw string value when no + * converter is registered. + */ + @SuppressWarnings("unchecked") + public T getTypedProperty(String key) { + Converter converter = getConverter(converters, key); + if (converter == null) + return (T) getGenericString(key); + return doGetObject(key, converter); + } + + /** + * Set a property value using the formatter registered for the key's stem, + * so merge keys such as {@code -runrequires.extra} are formatted exactly + * like their stem key. Unlike {@link #setGenericString(String, String)} + * this keeps the internal object cache consistently typed for keys that + * also have typed getters/setters, avoiding ClassCastExceptions. + */ + @SuppressWarnings("unchecked") + public void setTypedProperty(String key, T value) { + Converter formatter = getConverter(formatters, key); + if (formatter == null) { + setGenericString(key, value == null ? null : value.toString()); + return; + } + T oldValue = getTypedProperty(key); + doSetObject(key, oldValue, value, formatter); + } + /** * Return a processor for this model. This processor is based on the * properties of the source processor but with the values of the changed diff --git a/biz.aQute.bndlib/src/aQute/bnd/build/model/package-info.java b/biz.aQute.bndlib/src/aQute/bnd/build/model/package-info.java index 3dd3c3d9e2..d04d40eb01 100644 --- a/biz.aQute.bndlib/src/aQute/bnd/build/model/package-info.java +++ b/biz.aQute.bndlib/src/aQute/bnd/build/model/package-info.java @@ -1,4 +1,4 @@ -@Version("4.5.0") +@Version("4.6.0") package aQute.bnd.build.model; import org.osgi.annotation.versioning.Version; diff --git a/bndtools.core/_plugin.xml b/bndtools.core/_plugin.xml index ee9ca75103..29794a15f6 100644 --- a/bndtools.core/_plugin.xml +++ b/bndtools.core/_plugin.xml @@ -105,7 +105,7 @@ icon="icons/repoindex.png" name="OSGi Repository Index" project="false"> - + @@ -137,7 +137,7 @@ class="bndtools.wizards.newworkspace.NewWorkspaceWizard" finalPerspective="bndtools.perspective" preferredPerspectives="bndtools.perspective" - icon="icons/bndtools-logo-16x16.png" + icon="icons/bndtools-logo-16x16.png" name="Bnd Workspace (Fragments)" project="true"> @@ -149,14 +149,14 @@ class="bndtools.wizards.workspace.WorkspaceSetupWizard" finalPerspective="bndtools.perspective" preferredPerspectives="bndtools.perspective" - icon="icons/bndtools-logo-16x16.png" + icon="icons/bndtools-logo-16x16.png" name="Bnd OSGi Workspace" project="true"> Create a new bnd workspace - + + + + + + + + @@ -482,7 +493,7 @@ sourceLocatorId="bndtools.launch.sourcelookup.BndDependencySourceLookupDirector" sourcePathComputerId="org.eclipse.jdt.launching.sourceLookup.javaSourcePathComputer" /> - + - + @@ -1042,7 +1053,7 @@ tooltip="Analyzes a .bndrun file e.g. for unused bundles in repositories and other aspects."> - + @@ -1205,7 +1216,7 @@ id="bndtools.core.BndtoolsJavaWorkingSet"> - + diff --git a/bndtools.core/bndtools.win32.x86_64.bndrun b/bndtools.core/bndtools.win32.x86_64.bndrun index f93d18ecca..920f264a82 100644 --- a/bndtools.core/bndtools.win32.x86_64.bndrun +++ b/bndtools.core/bndtools.win32.x86_64.bndrun @@ -20,6 +20,9 @@ osgi.arch=x86_64,\ osgi.os=win32 +-runproperties.workspace: \ + osgi.instance.area=C:/git/github.com/peterkir/bnd-rcp-sample-workspace + -runblacklist.win32: \ osgi.identity;filter:='(osgi.identity=*macosx*)',\ osgi.identity;filter:='(osgi.identity=*linux*)',\ diff --git a/bndtools.core/src/bndtools/editor/BndEditor.java b/bndtools.core/src/bndtools/editor/BndEditor.java index a225ed562e..d39e8a8044 100644 --- a/bndtools.core/src/bndtools/editor/BndEditor.java +++ b/bndtools.core/src/bndtools/editor/BndEditor.java @@ -41,6 +41,7 @@ import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.Path; import org.eclipse.core.runtime.Platform; import org.eclipse.core.runtime.Status; import org.eclipse.core.runtime.jobs.IJobChangeEvent; @@ -97,6 +98,7 @@ import bndtools.editor.pages.ProjectRunPage; import bndtools.editor.pages.TestSuitesPage; import bndtools.editor.pages.WorkspacePage; +import bndtools.editor.project.IncludeConflictDetector; import bndtools.launch.util.LaunchUtils; import bndtools.preferences.BndPreferences; import bndtools.types.Pair; @@ -657,6 +659,7 @@ private Promise loadEditModel(File inputFile, BndEditModel model) thr IDocument document = docProvider.getDocument(getEditorInput()); model.loadFrom(new IDocumentWrapper(document)); model.setDirty(false); + IncludeConflictDetector.updateMarkers(inputResource, model); } catch (IOException e) { logger.logError("Unable to load edit model", e); completed.fail(e); @@ -775,6 +778,40 @@ public void resourceChanged(IResourceChangeEvent event) { IResourceDelta delta = event.getDelta(); if (delta == null) return; + + // When an included file is saved, refresh the owner processor and reload + // so merged properties (e.g. -runrequires, -runproperties) reflect the change. + aQute.bnd.osgi.Processor owner = model.getOwner(); + if (owner != null && !saving.get()) { + final IResourceDelta fullDelta = delta; // delta is reassigned below; capture before + boolean includedChanged = owner.getIncluded() + .stream() + .anyMatch(includedFile -> { + IFile wsFile = ResourcesPlugin.getWorkspace() + .getRoot() + .getFileForLocation(new Path(includedFile.getAbsolutePath())); + if (wsFile == null) + return false; + IResourceDelta d = fullDelta.findMember(wsFile.getFullPath()); + return d != null + && (d.getKind() & IResourceDelta.CHANGED) != 0 + && (d.getFlags() & IResourceDelta.CONTENT) != 0; + }); + if (includedChanged) { + final IDocumentProvider docProvider = sourcePage.getDocumentProvider(); + if (docProvider != null) { + final IDocument document = docProvider.getDocument(getEditorInput()); + SWTConcurrencyUtil.execForControl(getEditorSite().getShell(), true, () -> { + try { + owner.forceRefresh(); + model.loadFrom(new IDocumentWrapper(document)); + } catch (IOException e) { + logger.logError("Failed to reload model after included file change", e); + } + }); + } + } + } IPath fullPath = myResource.getFullPath(); delta = delta.findMember(fullPath); if (delta == null) diff --git a/bndtools.core/src/bndtools/editor/pages/ProjectRunPage.java b/bndtools.core/src/bndtools/editor/pages/ProjectRunPage.java index 18e58ee5c8..fab92ddd4e 100644 --- a/bndtools.core/src/bndtools/editor/pages/ProjectRunPage.java +++ b/bndtools.core/src/bndtools/editor/pages/ProjectRunPage.java @@ -175,18 +175,12 @@ protected void createFormContent(IManagedForm managedForm) { RepositorySelectionPart reposPart = new RepositorySelectionPart(getEditor(), left, tk, ExpandableComposite.TITLE_BAR | ExpandableComposite.TWISTIE); managedForm.addPart(reposPart); - gd = new GridData(SWT.FILL, SWT.FILL, true, true); - gd.widthHint = 50; - gd.heightHint = 50; reposPart.getSection() .setLayoutData(PageLayoutUtils.createCollapsed()); AvailableBundlesPart availableBundlesPart = new AvailableBundlesPart(left, tk, ExpandableComposite.TITLE_BAR | ExpandableComposite.EXPANDED); managedForm.addPart(availableBundlesPart); - gd = new GridData(SWT.FILL, SWT.FILL, true, true); - gd.widthHint = 50; - gd.heightHint = 50; availableBundlesPart.getSection() .setLayoutData(PageLayoutUtils.createExpanded()); diff --git a/bndtools.core/src/bndtools/editor/project/AbstractRequirementListPart.java b/bndtools.core/src/bndtools/editor/project/AbstractRequirementListPart.java index 271660532e..b73c72ac0c 100644 --- a/bndtools.core/src/bndtools/editor/project/AbstractRequirementListPart.java +++ b/bndtools.core/src/bndtools/editor/project/AbstractRequirementListPart.java @@ -2,47 +2,65 @@ import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; +import java.io.File; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; -import java.util.Iterator; +import java.util.HashSet; import java.util.LinkedHashSet; -import java.util.LinkedList; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import org.bndtools.core.ui.resource.RequirementLabelProvider; import org.bndtools.utils.dnd.AbstractViewerDropAdapter; import org.bndtools.utils.dnd.SupportedTransfer; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.MultiStatus; +import org.eclipse.core.runtime.Path; import org.eclipse.core.runtime.Status; import org.eclipse.jface.dialogs.ErrorDialog; import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.DoubleClickEvent; +import org.eclipse.jface.viewers.IDoubleClickListener; import org.eclipse.jface.viewers.ISelection; import org.eclipse.jface.viewers.IStructuredSelection; import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.ViewerCell; import org.eclipse.jface.window.Window; import org.eclipse.jface.wizard.WizardDialog; import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.StyleRange; import org.eclipse.swt.events.KeyAdapter; import org.eclipse.swt.events.KeyEvent; import org.eclipse.swt.events.SelectionAdapter; import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Color; import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Table; import org.eclipse.swt.widgets.ToolBar; import org.eclipse.swt.widgets.ToolItem; import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PartInitException; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.forms.editor.IFormPage; import org.eclipse.ui.forms.widgets.FormToolkit; import org.eclipse.ui.forms.widgets.Section; +import org.eclipse.ui.part.FileEditorInput; import org.osgi.resource.Requirement; import aQute.bnd.build.model.clauses.VersionedClause; import aQute.bnd.osgi.resource.CapReqBuilder; import bndtools.Plugin; +import bndtools.editor.BndEditor; import bndtools.editor.common.BndEditorPart; import bndtools.model.repo.DependencyPhase; import bndtools.model.repo.FeatureVersionNode; @@ -62,24 +80,78 @@ public AbstractRequirementListPart(Composite parent, FormToolkit toolkit, int st super(parent, toolkit, style); } - private final BndPreferences preferences = new BndPreferences(); - private final List requires = new ArrayList<>(); + private final BndPreferences preferences = new BndPreferences(); + /** Local requirements: written to this file's own merge key. */ + private final List requires = new ArrayList<>(); + /** Inherited requirements: from included files, shown in gray (read-only). */ + private final List inheritedRequires = new ArrayList<>(); private TableViewer viewer; private ToolItem addBundleTool; private ToolItem removeTool; - private boolean committing = false; + private boolean committing = false; + /** Per-requirement provenance: maps each inherited requirement to the file path that defines it. */ + private Map inheritedProvenances = new java.util.LinkedHashMap<>(); + /** The key used to write local requirements (plain stem or stem.local). */ + private String localKey = null; + /** Extra property key subscribed dynamically (the localKey when it's a suffix). */ + private String subscribedLocalKey = null; + + /** Returns the primary bnd property key this part displays (e.g. {@code -runrequires}). */ + protected abstract String getPrimaryPropertyKey(); + + /** Returns the local write key (plain stem or stem.local) determined during the last refresh. */ + protected final String getLocalKey() { + return localKey != null ? localKey : getPrimaryPropertyKey(); + } + + /** Colors inherited items gray and local items with the default foreground. */ + private class MixedRequirementLabelProvider extends RequirementLabelProvider { + private final Color grey; + + MixedRequirementLabelProvider(Display display) { + grey = display.getSystemColor(SWT.COLOR_DARK_GRAY); + } + + @Override + public void update(ViewerCell cell) { + super.update(cell); + if (inheritedRequires.contains(cell.getElement())) { + cell.setForeground(grey); + // clear styled ranges so the grey foreground is not overridden + cell.setStyleRanges(new StyleRange[0]); + } + } + } protected TableViewer createViewer(Composite parent, FormToolkit tk) { Table table = tk.createTable(parent, SWT.FULL_SELECTION | SWT.MULTI | SWT.BORDER); viewer = new TableViewer(table); viewer.setContentProvider(ArrayContentProvider.getInstance()); - viewer.setLabelProvider(new RequirementLabelProvider()); + viewer.setLabelProvider(new MixedRequirementLabelProvider(table.getDisplay())); // Listeners - viewer.addSelectionChangedListener(event -> removeTool.setEnabled(!viewer.getSelection() - .isEmpty())); + viewer.addSelectionChangedListener(event -> { + IStructuredSelection sel = (IStructuredSelection) viewer.getSelection(); + boolean hasLocalSelected = !sel.isEmpty() + && sel.toList().stream().anyMatch(e -> requires.contains(e)); + removeTool.setEnabled(hasLocalSelected); + }); + viewer.addDoubleClickListener(new IDoubleClickListener() { + @Override + public void doubleClick(DoubleClickEvent event) { + IStructuredSelection sel = (IStructuredSelection) viewer.getSelection(); + if (sel.isEmpty()) + return; + Requirement req = (Requirement) sel.getFirstElement(); + if (!inheritedRequires.contains(req)) + return; + String prov = inheritedProvenances.get(req); + if (prov != null) + openProvenanceFile(prov); + } + }); table.addKeyListener(new KeyAdapter() { @Override public void keyReleased(KeyEvent e) { @@ -193,14 +265,11 @@ private void doAddBundle() { private void doRemove() { IStructuredSelection selection = (IStructuredSelection) viewer.getSelection(); if (!selection.isEmpty()) { - Iterator elements = selection.iterator(); - List removed = new LinkedList<>(); - while (elements.hasNext()) { - Object element = elements.next(); - if (this.requires.remove(element)) - removed.add(element); - } - + // Only local items may be removed; inherited ones stay in their source file. + @SuppressWarnings("unchecked") + List removed = ((List) selection.toList()).stream() + .filter(e -> this.requires.remove(e)) + .collect(Collectors.toList()); if (!removed.isEmpty()) { viewer.remove(removed.toArray()); markDirty(); @@ -220,16 +289,83 @@ public final void commitToModel(boolean onSave) { @Override protected final void refreshFromModel() { - List loadedReqs = doRefreshFromModel(); - if (loadedReqs == null) - loadedReqs = Collections.emptyList(); + // Local requirements: only what is defined in this file's own merge keys. + List newLocal = doRefreshFromModel(); + if (newLocal == null) + newLocal = Collections.emptyList(); + + // Inherited requirements: merged view minus local. + String primaryKey = getPrimaryPropertyKey(); + List newInherited = Collections.emptyList(); + if (primaryKey != null && model != null) { + List merged = BndEditModelAccessor.getMergedRequirements(model, primaryKey); + if (merged != null && !merged.isEmpty()) { + Set localSet = new HashSet<>(newLocal); + newInherited = merged.stream() + .filter(r -> !localSet.contains(r)) + .collect(Collectors.toList()); + } + } - if (loadedReqs.equals(this.requires)) + // Determine which key local additions should be written to. + String newLocalKey = primaryKey; + if (primaryKey != null && model != null) { + String existingKey = BndEditModelAccessor.findLocalMergeKey(model, primaryKey); + if (existingKey != null) { + newLocalKey = existingKey; + } else if (!newInherited.isEmpty()) { + // No local key yet but inherited items exist: use a suffix so bnd merges them. + newLocalKey = primaryKey + ".local"; + } + } + localKey = newLocalKey; + + // Keep the property-change subscription aligned with the local key. + if (!Objects.equals(subscribedLocalKey, newLocalKey)) { + if (subscribedLocalKey != null && model != null) + model.removePropertyChangeListener(subscribedLocalKey, this); + subscribedLocalKey = newLocalKey; + if (newLocalKey != null && model != null + && !Arrays.asList(getProperties()).contains(newLocalKey)) + model.addPropertyChangeListener(newLocalKey, this); + } + + // Update provenance tooltip for inherited items. + if (!newInherited.isEmpty()) { + inheritedProvenances = BndEditModelAccessor.getInheritedRequirementProvenances(model, primaryKey); + String tip = inheritedProvenances.values().stream().findAny().isPresent() + ? "Some requirements are inherited from included files. Double-click an inherited item to open its source." + : "Some requirements are inherited from included files."; + viewer.getControl().setToolTipText(tip); + } else { + inheritedProvenances = Collections.emptyMap(); + viewer.getControl().setToolTipText(null); + } + + addBundleTool.setEnabled(true); + removeTool.setEnabled(false); + + if (newInherited.equals(this.inheritedRequires) && newLocal.equals(this.requires)) return; + this.inheritedRequires.clear(); + this.inheritedRequires.addAll(newInherited); this.requires.clear(); - this.requires.addAll(loadedReqs); - viewer.setInput(this.requires); + this.requires.addAll(newLocal); + + List combined = new ArrayList<>(this.inheritedRequires.size() + this.requires.size()); + combined.addAll(this.inheritedRequires); + combined.addAll(this.requires); + viewer.setInput(combined); + } + + @Override + public void dispose() { + if (subscribedLocalKey != null && model != null) { + model.removePropertyChangeListener(subscribedLocalKey, this); + subscribedLocalKey = null; + } + super.dispose(); } @Override @@ -244,19 +380,32 @@ public void propertyChange(PropertyChangeEvent evt) { } } - /** - * Update the requirements already available with new ones. Already existing - * requirements will be removed from the given set. - * - * @param adding Set with {@link Requirement}s to add - * @return true if requirements were added. - */ + private void openProvenanceFile(String absolutePath) { + File file = new File(absolutePath); + if (!file.isFile()) + return; + IWorkspaceRoot root = ResourcesPlugin.getWorkspace() + .getRoot(); + IFile iFile = root.getFileForLocation(new Path(absolutePath)); + if (iFile == null || !iFile.exists()) + return; + try { + PlatformUI.getWorkbench() + .getActiveWorkbenchWindow() + .getActivePage() + .openEditor(new FileEditorInput(iFile), BndEditor.WORKSPACE_EDITOR); + } catch (PartInitException e) { + ErrorDialog.openError(getSection().getShell(), "Error", null, + new Status(IStatus.ERROR, Plugin.PLUGIN_ID, 0, "Failed to open source file.", e)); + } + } + + /** Adds new requirements as local items. Items already in inherited or local lists are skipped. */ private boolean updateViewerWithNewRequirements(Set adding) { - // remove duplicates + adding.removeAll(this.inheritedRequires); adding.removeAll(this.requires); - if (adding.isEmpty()) { + if (adding.isEmpty()) return false; - } this.requires.addAll(adding); viewer.add(adding.toArray()); markDirty(); diff --git a/bndtools.core/src/bndtools/editor/project/BndEditModelAccessor.java b/bndtools.core/src/bndtools/editor/project/BndEditModelAccessor.java new file mode 100644 index 0000000000..a63911bfff --- /dev/null +++ b/bndtools.core/src/bndtools/editor/project/BndEditModelAccessor.java @@ -0,0 +1,268 @@ +package bndtools.editor.project; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; + +import org.osgi.resource.Requirement; + +import aQute.bnd.build.model.BndEditModel; +import aQute.bnd.build.model.clauses.VersionedClause; +import aQute.bnd.build.model.conversions.Converter; +import aQute.bnd.build.model.conversions.HeaderClauseListConverter; +import aQute.bnd.build.model.conversions.RequirementListConverter; +import aQute.bnd.build.model.conversions.VersionedClauseConverter; +import aQute.bnd.header.OSGiHeader; +import aQute.bnd.osgi.Processor; +import aQute.bnd.osgi.Processor.PropertyKey; + +/** Convenience accessors for {@link BndEditModel} used by UI components that need to distinguish local vs inherited properties. */ +class BndEditModelAccessor { + + private static final RequirementListConverter requirementListConverter = new RequirementListConverter(); + + private static final Converter, String> versionedClauseListConverter = + new HeaderClauseListConverter<>(new VersionedClauseConverter()); + + /** Returns true if any local property key matches the stem or a stem.* variant. */ + static boolean hasLocalMergeProperty(BndEditModel model, String stem) { + return !getLocalMergeKeys(model, stem).isEmpty(); + } + + /** Returns requirements merged across all stem.* variants from the owner processor (includes inherited files). */ + static List getMergedRequirements(BndEditModel model, String stem) { + Processor p = model.getOwner(); + if (p == null) + return null; + String merged = p.mergeProperties(stem); + if (merged == null || merged.isBlank()) + return null; + return requirementListConverter.convert(merged); + } + + /** + * Returns requirements from all local document merge keys (stem and stem.*). + * Reads from the saved document state; uncommitted in-memory drops are tracked + * separately in the viewer and committed on save. + */ + static List getLocalMergeRequirements(BndEditModel model, String stem) { + List result = new ArrayList<>(); + for (String key : getLocalMergeKeys(model, stem)) { + List reqs = model.getTypedProperty(key); + if (reqs != null) + result.addAll(reqs); + } + return result; + } + + /** Writes requirements to an arbitrary merge key using the formatter registered for the stem. */ + static void setRequirementListByKey(BndEditModel model, String key, List requires) { + model.setTypedProperty(key, requires); + } + + // ---- VersionedClause (runbundles, buildpath) ---------------------------- + + /** Returns merged VersionedClause list from the owner processor (includes inherited files). */ + static List getMergedVersionedClauses(BndEditModel model, String stem) { + Processor p = model.getOwner(); + if (p == null) + return null; + String merged = p.mergeProperties(stem); + if (merged == null || merged.isBlank()) + return null; + return versionedClauseListConverter.convert(merged); + } + + /** Returns VersionedClause items from all local document merge keys (stem and stem.*). */ + static List getLocalVersionedClauses(BndEditModel model, String stem) { + Set keys = getLocalMergeKeys(model, stem); + if (keys.isEmpty()) + return null; + List result = new ArrayList<>(); + for (String key : keys) { + List clauses = model.getTypedProperty(key); + if (clauses != null) + result.addAll(clauses); + } + return result; + } + + // ---- Map (runproperties) --------------------------------- + + /** Returns merged properties map from the owner processor (includes inherited files). */ + static Map getMergedProperties(BndEditModel model, String stem) { + Processor p = model.getOwner(); + if (p == null) + return null; + String merged = p.mergeProperties(stem); + if (merged == null || merged.isBlank()) + return null; + return OSGiHeader.parseProperties(merged); + } + + /** Returns properties map from all local document merge keys (stem and stem.*). */ + static Map getLocalProperties(BndEditModel model, String stem) { + Set keys = getLocalMergeKeys(model, stem); + if (keys.isEmpty()) + return null; + Map result = new LinkedHashMap<>(); + for (String key : keys) { + Map props = model.getTypedProperty(key); + if (props != null) + result.putAll(props); + } + return result; + } + + /** Writes a properties map to an arbitrary merge key using the formatter registered for the stem. */ + static void setPropertiesByKey(BndEditModel model, String key, Map props) { + model.setTypedProperty(key, props); + } + + /** + * Returns per-entry provenance for inherited properties: maps each inherited property key + * to the absolute path of the file where that specific merge key was defined. + * First definition wins (matches bnd merge semantics). + */ + static Map getInheritedPropertiesProvenance(BndEditModel model, String stem) { + return getInheritedEntryProvenances(model, stem, + raw -> OSGiHeader.parseProperties(raw).keySet()); + } + + /** + * Returns per-bundle provenance for inherited bundles: maps each BSN to the absolute path + * of the file where that bundle was defined. + */ + static Map getInheritedBundleProvenances(BndEditModel model, String stem) { + return getInheritedEntryProvenances(model, stem, raw -> { + List clauses = versionedClauseListConverter.convert(raw); + if (clauses == null) + return Collections.emptySet(); + Set bsns = new LinkedHashSet<>(); + for (VersionedClause vc : clauses) + bsns.add(vc.getName()); + return bsns; + }); + } + + /** + * Returns per-requirement provenance: maps each inherited Requirement to the absolute path + * of the file where it was defined. + */ + static Map getInheritedRequirementProvenances(BndEditModel model, String stem) { + Processor p = model.getOwner(); + if (p == null) + return Collections.emptyMap(); + Set localDocKeys = getLocalMergeKeys(model, stem); + Map result = new LinkedHashMap<>(); + for (PropertyKey pk : PropertyKey.findVisible(p.getMergePropertyKeys(stem))) { + if (localDocKeys.contains(pk.key())) + continue; + Optional prov = pk.getProvenance(); + if (prov.isEmpty()) + continue; + String raw = pk.getRawValue(); + if (raw == null || raw.isBlank()) + continue; + List reqs = requirementListConverter.convert(raw); + if (reqs != null) + for (Requirement req : reqs) + result.putIfAbsent(req, prov.get()); + } + return result; + } + + /** Shared helper: maps each string entry (key/bsn) to its provenance file. */ + private static Map getInheritedEntryProvenances(BndEditModel model, String stem, + java.util.function.Function> entryExtractor) { + Processor p = model.getOwner(); + if (p == null) + return Collections.emptyMap(); + Set localDocKeys = getLocalMergeKeys(model, stem); + Map result = new LinkedHashMap<>(); + for (PropertyKey pk : PropertyKey.findVisible(p.getMergePropertyKeys(stem))) { + if (localDocKeys.contains(pk.key())) + continue; + Optional prov = pk.getProvenance(); + if (prov.isEmpty()) + continue; + String raw = pk.getRawValue(); + if (raw == null || raw.isBlank()) + continue; + for (String entry : entryExtractor.apply(raw)) + result.putIfAbsent(entry, prov.get()); + } + return result; + } + + // ---- String (runvm, runprogramargs) ------------------------------------- + + /** Returns merged string value from the owner processor (includes inherited files). */ + static String getMergedString(BndEditModel model, String stem) { + Processor p = model.getOwner(); + if (p == null) + return null; + return p.mergeProperties(stem); + } + + /** Returns visible non-local merge keys for the stem that carry a provenance (inherited from included files). */ + static List getInheritedPropertyKeys(BndEditModel model, String stem) { + Processor p = model.getOwner(); + if (p == null) + return Collections.emptyList(); + Set localDocKeys = getLocalMergeKeys(model, stem); + List result = new ArrayList<>(); + for (PropertyKey pk : PropertyKey.findVisible(p.getMergePropertyKeys(stem))) { + if (localDocKeys.contains(pk.key())) + continue; + if (pk.getProvenance() + .isPresent()) + result.add(pk); + } + return result; + } + + // ---- Local key resolution ----------------------------------------------- + + /** + * Returns the first existing local merge key for stem (plain stem takes precedence over stem.*), + * or {@code null} if no local key exists. + */ + static String findLocalMergeKey(BndEditModel model, String stem) { + Set keys = getLocalMergeKeys(model, stem); + if (keys.contains(stem)) + return stem; + return keys.stream().findFirst().orElse(null); + } + + /** Returns the provenance (file path) of the first visible merge key for the given stem, or empty if local/unknown. */ + static Optional getPropertyProvenance(BndEditModel model, String key) { + Processor p = model.getOwner(); + if (p == null) + return Optional.empty(); + return PropertyKey.findVisible(p.getMergePropertyKeys(key)) + .stream() + .findFirst() + .flatMap(PropertyKey::getProvenance); + } + + /** Returns all local document property keys matching {@code stem} or {@code stem.*}. */ + static Set getLocalMergeKeys(BndEditModel model, String stem) { + String prefix = stem + "."; + Properties docProps = model.getDocumentProperties(); + Set keys = new LinkedHashSet<>(); + docProps.stringPropertyNames() + .stream() + .filter(k -> k.equals(stem) || k.startsWith(prefix)) + .forEach(keys::add); + return keys; + } + + private BndEditModelAccessor() {} +} diff --git a/bndtools.core/src/bndtools/editor/project/IncludeConflictDetector.java b/bndtools.core/src/bndtools/editor/project/IncludeConflictDetector.java new file mode 100644 index 0000000000..08db022c66 --- /dev/null +++ b/bndtools.core/src/bndtools/editor/project/IncludeConflictDetector.java @@ -0,0 +1,204 @@ +package bndtools.editor.project; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IWorkspace; +import org.eclipse.core.resources.IWorkspaceRunnable; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.Path; + +import aQute.bnd.build.model.BndEditModel; +import aQute.bnd.osgi.Constants; +import aQute.bnd.osgi.Processor; +import aQute.lib.io.IO; +import aQute.lib.utf8properties.UTF8Properties; + +/** + * Detects plain merge-stem properties (e.g. {@code -runprogramargs}) that are + * defined in more than one file of the include tree. Such definitions shadow + * each other instead of merging; an error marker with a rename quickfix is + * created on the including file. + */ +public class IncludeConflictDetector { + + public static final String MARKER_TYPE = "bndtools.core.includeconflict"; + public static final String ATTR_KEY = "conflictKey"; + /** ';'-joined absolute paths of the files defining the conflicting key. */ + public static final String ATTR_SOURCES = "conflictSources"; + + /** Recomputes the conflict markers on the edited resource. Never throws. */ + public static void updateMarkers(IResource resource, BndEditModel model) { + if (resource == null || !resource.exists() || model == null) + return; + try { + Map> conflicts = findConflicts(model); + IWorkspaceRunnable runnable = monitor -> { + resource.deleteMarkers(MARKER_TYPE, false, IResource.DEPTH_ZERO); + for (Map.Entry> e : conflicts.entrySet()) { + String key = e.getKey(); + String files = e.getValue() + .stream() + .map(File::getName) + .collect(Collectors.joining(", ")); + IMarker marker = resource.createMarker(MARKER_TYPE); + marker.setAttribute(IMarker.SEVERITY, IMarker.SEVERITY_ERROR); + marker.setAttribute(IMarker.MESSAGE, "Property '" + key + + "' is defined in multiple files of the include tree (" + files + + "); the values shadow each other instead of merging. Rename to merged syntax, e.g. '" + key + + ".'."); + marker.setAttribute(IMarker.LINE_NUMBER, findLineNumber(resource, key)); + marker.setAttribute(ATTR_KEY, key); + marker.setAttribute(ATTR_SOURCES, e.getValue() + .stream() + .map(File::getAbsolutePath) + .collect(Collectors.joining(";"))); + } + }; + resource.getWorkspace() + .run(runnable, resource, IWorkspace.AVOID_UPDATE, null); + } catch (Exception e) { + // validation only: never break the editor + } + } + + /** Maps each plain merge-stem key to the files defining it; entries with more than one source conflict. */ + private static Map> findConflicts(BndEditModel model) throws Exception { + Map> sources = new LinkedHashMap<>(); + File docFile = model.getBndResource(); + if (docFile != null) { + for (String key : model.getDocumentProperties() + .stringPropertyNames()) { + if (isPlainMergeStem(key)) + sources.computeIfAbsent(key, k -> new ArrayList<>()) + .add(docFile); + } + } + Processor owner = model.getOwner(); + if (owner != null && owner.getIncluded() != null) { + for (File included : owner.getIncluded()) { + if (!included.isFile()) + continue; + UTF8Properties p = new UTF8Properties(); + p.load(IO.collect(included), included, null); + for (String key : p.stringPropertyNames()) { + if (isPlainMergeStem(key)) + sources.computeIfAbsent(key, k -> new ArrayList<>()) + .add(included); + } + } + } + Map> conflicts = new LinkedHashMap<>(); + sources.forEach((key, files) -> { + if (files.size() > 1) + conflicts.put(key, files); + }); + return conflicts; + } + + /** Plain key without suffix that supports merged syntax. */ + private static boolean isPlainMergeStem(String key) { + return Constants.MERGED_HEADERS.contains(key); + } + + /** Line of the key in the edited file, else the first -include line, else 1. */ + private static int findLineNumber(IResource resource, String key) { + try { + String content = IO.collect(resource.getLocation() + .toFile()); + String[] lines = content.split("\r?\n", -1); + int includeLine = 1; + boolean includeSeen = false; + for (int i = 0; i < lines.length; i++) { + String t = lines[i].trim(); + if (matchesKey(t, key)) + return i + 1; + if (!includeSeen && matchesKey(t, Constants.INCLUDE)) { + includeLine = i + 1; + includeSeen = true; + } + } + return includeLine; + } catch (Exception e) { + return 1; + } + } + + private static boolean matchesKey(String line, String key) { + if (!line.startsWith(key)) + return false; + String rest = line.substring(key.length()); + return rest.isEmpty() || rest.charAt(0) == ':' || rest.charAt(0) == '=' + || Character.isWhitespace(rest.charAt(0)); + } + + /** + * Suffix for renaming a plain key in the given file: the dominant suffix + * convention among the file's suffixed merge keys, else the sanitized file + * name without extension. + */ + static String suggestSuffixForFile(File file) { + try { + UTF8Properties p = new UTF8Properties(); + p.load(IO.collect(file), file, null); + Map counts = new LinkedHashMap<>(); + for (String key : p.stringPropertyNames()) { + String stem = BndEditModel.getStem(key); + if (!key.equals(stem) && Constants.MERGED_HEADERS.contains(stem)) { + String suffix = key.substring(stem.length() + 1); + if (!suffix.isEmpty()) + counts.merge(suffix, 1, Integer::sum); + } + } + Optional dominant = counts.entrySet() + .stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey); + if (dominant.isPresent()) + return dominant.get(); + } catch (Exception e) { + // fall through to file name + } + String name = file.getName(); + if (name.endsWith(".bndrun")) + name = name.substring(0, name.length() - ".bndrun".length()); + else if (name.endsWith(".bnd")) + name = name.substring(0, name.length() - ".bnd".length()); + name = name.replaceAll("[^A-Za-z0-9._-]", "-"); + return name.isEmpty() ? "local" : name; + } + + /** Renames the first occurrence of the key at line start, preserving indentation and separator. */ + static void renameKeyInFile(File file, String key, String newKey) throws Exception { + String content = IO.collect(file); + Pattern pattern = Pattern.compile("(?m)^([ \\t]*)" + Pattern.quote(key) + "(?=\\s*[:=\\s])"); + Matcher matcher = pattern.matcher(content); + if (!matcher.find()) + return; + String updated = new StringBuilder(content).replace(matcher.start(), matcher.end(), + matcher.group(1) + newKey) + .toString(); + IFile iFile = ResourcesPlugin.getWorkspace() + .getRoot() + .getFileForLocation(new Path(file.getAbsolutePath())); + if (iFile != null && iFile.exists()) { + iFile.setContents(new ByteArrayInputStream(updated.getBytes(StandardCharsets.UTF_8)), true, true, null); + } else { + IO.store(updated, file); + } + } + + private IncludeConflictDetector() {} +} diff --git a/bndtools.core/src/bndtools/editor/project/IncludeConflictMarkerResolutionGenerator.java b/bndtools.core/src/bndtools/editor/project/IncludeConflictMarkerResolutionGenerator.java new file mode 100644 index 0000000000..3d6972f1a5 --- /dev/null +++ b/bndtools.core/src/bndtools/editor/project/IncludeConflictMarkerResolutionGenerator.java @@ -0,0 +1,69 @@ +package bndtools.editor.project; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.core.resources.IMarker; +import org.eclipse.swt.graphics.Image; +import org.eclipse.ui.IMarkerResolution; +import org.eclipse.ui.IMarkerResolution2; +import org.eclipse.ui.IMarkerResolutionGenerator2; + +import org.bndtools.api.ILogger; +import org.bndtools.api.Logger; + +/** Quickfixes for {@link IncludeConflictDetector#MARKER_TYPE}: rename the plain key in one of the conflicting files to merged syntax. */ +public class IncludeConflictMarkerResolutionGenerator implements IMarkerResolutionGenerator2 { + + private static final ILogger logger = Logger.getLogger(IncludeConflictMarkerResolutionGenerator.class); + + @Override + public boolean hasResolutions(IMarker marker) { + return marker.getAttribute(IncludeConflictDetector.ATTR_KEY, null) != null; + } + + @Override + public IMarkerResolution[] getResolutions(IMarker marker) { + String key = marker.getAttribute(IncludeConflictDetector.ATTR_KEY, null); + String sources = marker.getAttribute(IncludeConflictDetector.ATTR_SOURCES, ""); + if (key == null || sources.isEmpty()) + return new IMarkerResolution[0]; + + List resolutions = new ArrayList<>(); + for (String path : sources.split(";")) { + File file = new File(path); + if (!file.isFile()) + continue; + String newKey = key + "." + IncludeConflictDetector.suggestSuffixForFile(file); + resolutions.add(new IMarkerResolution2() { + @Override + public String getLabel() { + return "Rename '" + key + "' to '" + newKey + "' in " + file.getName(); + } + + @Override + public String getDescription() { + return "Renames the plain property in " + file.getAbsolutePath() + + " so bnd merges it with the other definitions instead of shadowing them."; + } + + @Override + public Image getImage() { + return null; + } + + @Override + public void run(IMarker m) { + try { + IncludeConflictDetector.renameKeyInFile(file, key, newKey); + m.delete(); + } catch (Exception e) { + logger.logError("Failed to rename " + key + " in " + file, e); + } + } + }); + } + return resolutions.toArray(new IMarkerResolution[0]); + } +} diff --git a/bndtools.core/src/bndtools/editor/project/RepositoryBundleSelectionPart.java b/bndtools.core/src/bndtools/editor/project/RepositoryBundleSelectionPart.java index a86ab03a08..98bfda97c0 100644 --- a/bndtools.core/src/bndtools/editor/project/RepositoryBundleSelectionPart.java +++ b/bndtools.core/src/bndtools/editor/project/RepositoryBundleSelectionPart.java @@ -5,13 +5,23 @@ import java.io.File; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.ListIterator; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Path; import org.eclipse.core.runtime.Status; import org.eclipse.jface.dialogs.ErrorDialog; import org.eclipse.jface.dialogs.MessageDialogWithToggle; @@ -20,11 +30,14 @@ import org.eclipse.jface.viewers.IBaseLabelProvider; import org.eclipse.jface.viewers.ISelection; import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.StyledCellLabelProvider; import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.ViewerCell; import org.eclipse.jface.viewers.ViewerDropAdapter; import org.eclipse.jface.window.Window; import org.eclipse.jface.wizard.WizardDialog; import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.StyleRange; import org.eclipse.swt.dnd.DND; import org.eclipse.swt.dnd.DropTargetEvent; import org.eclipse.swt.dnd.FileTransfer; @@ -33,8 +46,11 @@ import org.eclipse.swt.dnd.URLTransfer; import org.eclipse.swt.events.KeyAdapter; import org.eclipse.swt.events.KeyEvent; +import org.eclipse.swt.events.MouseAdapter; +import org.eclipse.swt.events.MouseEvent; import org.eclipse.swt.events.SelectionAdapter; import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Color; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; @@ -43,18 +59,22 @@ import org.eclipse.swt.widgets.ToolBar; import org.eclipse.swt.widgets.ToolItem; import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PartInitException; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.forms.IManagedForm; import org.eclipse.ui.forms.editor.IFormPage; import org.eclipse.ui.forms.widgets.FormToolkit; import org.eclipse.ui.forms.widgets.Section; +import org.eclipse.ui.part.FileEditorInput; import org.eclipse.ui.part.ResourceTransfer; +import org.osgi.framework.namespace.IdentityNamespace; import aQute.bnd.build.model.BndEditModel; import aQute.bnd.build.model.clauses.VersionedClause; import aQute.bnd.header.Attrs; import aQute.bnd.osgi.Constants; import bndtools.Plugin; +import bndtools.editor.BndEditor; import bndtools.editor.common.BndEditorPart; import bndtools.model.clauses.VersionedClauseLabelProvider; import bndtools.model.repo.DependencyPhase; @@ -79,6 +99,10 @@ public abstract class RepositoryBundleSelectionPart extends BndEditorPart implem protected BndEditModel model; protected List bundles; + /** Bundles inherited from included files; shown gray, not committable. */ + protected List inheritedBundles = new ArrayList<>(); + /** Per-BSN provenance: maps each inherited bundle's BSN to the file path that defines it. */ + private Map inheritedBundleProvenances = Collections.emptyMap(); protected ToolItem removeItemTool; protected RepositoryBundleSelectionPart(String propertyName, DependencyPhase phase, Composite parent, @@ -144,7 +168,23 @@ protected void fillToolBar(ToolBar toolbar) { } protected IBaseLabelProvider getLabelProvider() { - return new VersionedClauseLabelProvider(); + return new MixedVersionedClauseLabelProvider(); + } + + /** A label provider that colors inherited bundles gray and local bundles with the default foreground. */ + protected class MixedVersionedClauseLabelProvider extends VersionedClauseLabelProvider { + private Color grey; + + @Override + public void update(ViewerCell cell) { + super.update(cell); + if (inheritedBundles.contains(cell.getElement())) { + if (grey == null) + grey = cell.getItem().getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY); + cell.setForeground(grey); + cell.setStyleRanges(new StyleRange[0]); + } + } } void createSection(Section section, FormToolkit toolkit) { @@ -168,6 +208,18 @@ void createSection(Section section, FormToolkit toolkit) { if (remove != null) remove.setEnabled(isRemovable(event.getSelection())); }); + // Double-click on an inherited row opens the file that defines that specific bundle. + table.addMouseListener(new MouseAdapter() { + @Override + public void mouseDoubleClick(MouseEvent e) { + IStructuredSelection sel = (IStructuredSelection) viewer.getSelection(); + if (sel.isEmpty()) return; + Object first = sel.getFirstElement(); + if (!(first instanceof VersionedClause) || !inheritedBundles.contains(first)) return; + String path = inheritedBundleProvenances.get(((VersionedClause) first).getName()); + if (path != null) openProvenanceByPath(path); + } + }); ViewerDropAdapter dropAdapter = new ViewerDropAdapter(viewer) { @Override public void dragEnter(DropTargetEvent event) { @@ -297,19 +349,9 @@ private boolean handleSelectionDrop() { adding.add(newClause); } else if (item instanceof RepositoryFeature) { RepositoryFeature feature = (RepositoryFeature) item; - // Create VersionedClause with "feature:id" BSN and feature=true attribute - VersionedClause newClause = new VersionedClause("feature:" + feature.getFeature() - .getId(), new Attrs()); - // Set version if available - if (feature.getFeature() - .getVersion() != null) { - newClause.setVersionRange(feature.getFeature() - .getVersion()); - } - // Add feature=true attribute for resolver identification - newClause.getAttribs() - .put("feature", "true"); - adding.add(newClause); + // Create a clause in the canonical feature syntax: + // id;version='V';type=org.eclipse.update.feature + adding.add(RepositoryBundleUtils.convertRepoFeature(feature)); } else if (item instanceof IncludedBundleItem) { IncludedBundleItem bundleItem = (IncludedBundleItem) item; VersionedClause newClause = new VersionedClause(bundleItem.getPlugin().id, new Attrs()); @@ -336,7 +378,11 @@ private void handleAdd(Collection newClauses) { for (ListIterator iter = bundles.listIterator(); iter.hasNext();) { VersionedClause existing = iter.next(); if (newClause.getName() - .equals(existing.getName())) { + .equals(existing.getName()) + && Objects.equals(newClause.getAttribs() + .get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE), + existing.getAttribs() + .get(IdentityNamespace.CAPABILITY_TYPE_ATTRIBUTE))) { int index = iter.previousIndex(); iter.set(newClause); viewer.replace(newClause, index); @@ -404,6 +450,11 @@ private static boolean isRemovable(ISelection selection) { return false; } + /** Returns null to disable inherited display; subclasses may override. */ + protected List loadMergedFromModel(BndEditModel m) { + return null; + } + protected int getTableHeightHint() { return SWT.DEFAULT; } @@ -415,7 +466,15 @@ protected List getBundles() { protected void setBundles(final List bundles) { this.bundles = bundles; Display.getDefault() - .asyncExec(() -> viewer.setInput(bundles)); + .asyncExec(() -> viewer.setInput(buildDisplayList())); + } + + private List buildDisplayList() { + List combined = new ArrayList<>(inheritedBundles.size() + (bundles != null ? bundles.size() : 0)); + combined.addAll(inheritedBundles); + if (bundles != null) + combined.addAll(bundles); + return combined; } private void doAdd() { @@ -444,7 +503,8 @@ private void doRemove() { List removed = new LinkedList<>(); while (elements.hasNext()) { Object element = elements.next(); - if (bundles.remove(element)) + // Only local bundles can be removed; inherited stay in their source file. + if (!inheritedBundles.contains(element) && bundles.remove(element)) removed.add(element); } @@ -476,11 +536,48 @@ protected final RepoBundleSelectionWizard createBundleSelectionWizard(List bundles = loadFromModel(model); - if (bundles != null) { - setBundles(new ArrayList<>(bundles)); + // Compute inherited bundles (merged view minus local). + List merged = loadMergedFromModel(model); + List local = loadFromModel(model); + if (local == null) local = new ArrayList<>(); + if (merged != null) { + Set localNames = new HashSet<>(); + for (VersionedClause vc : local) + localNames.add(vc.getName()); + inheritedBundles.clear(); + for (VersionedClause vc : merged) + if (!localNames.contains(vc.getName())) + inheritedBundles.add(vc); } else { - setBundles(new ArrayList()); + inheritedBundles.clear(); + } + + // Per-bundle provenance for the double-click handler. + inheritedBundleProvenances = inheritedBundles.isEmpty() ? Collections.emptyMap() + : BndEditModelAccessor.getInheritedBundleProvenances(model, propertyName); + + table.setToolTipText(inheritedBundles.isEmpty() ? null + : "Some bundles are inherited from included files. Double-click an inherited item to open its source."); + + setBundles(new ArrayList<>(local)); + } + + private void openProvenanceByPath(String absolutePath) { + File file = new File(absolutePath); + if (!file.isFile()) + return; + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + IFile iFile = root.getFileForLocation(new Path(absolutePath)); + if (iFile == null || !iFile.exists()) + return; + try { + PlatformUI.getWorkbench() + .getActiveWorkbenchWindow() + .getActivePage() + .openEditor(new FileEditorInput(iFile), BndEditor.WORKSPACE_EDITOR); + } catch (PartInitException e) { + ErrorDialog.openError(getSection().getShell(), "Error", null, + new Status(IStatus.ERROR, Plugin.PLUGIN_ID, 0, "Failed to open source file.", e)); } } diff --git a/bndtools.core/src/bndtools/editor/project/RunBlacklistPart.java b/bndtools.core/src/bndtools/editor/project/RunBlacklistPart.java index c5b9595968..ad2018fc7f 100644 --- a/bndtools.core/src/bndtools/editor/project/RunBlacklistPart.java +++ b/bndtools.core/src/bndtools/editor/project/RunBlacklistPart.java @@ -29,6 +29,11 @@ protected String[] getProperties() { return SUBSCRIBE_PROPS; } + @Override + protected String getPrimaryPropertyKey() { + return Constants.RUNBLACKLIST; + } + private void createSection(Section section, FormToolkit tk) { section.setText("Run Blacklist"); section.setDescription("The specified requirements will be excluded from the resolution."); @@ -65,12 +70,17 @@ private void createSection(Section section, FormToolkit tk) { @Override protected void doCommitToModel(List requires) { - model.setRunBlacklist(requires); + String key = getLocalKey(); + if (Constants.RUNBLACKLIST.equals(key)) { + model.setRunBlacklist(requires); + } else { + BndEditModelAccessor.setRequirementListByKey(model, key, requires); + } } @Override - protected List doRefreshFromModel() { - return model.getRunBlacklist(); + public List doRefreshFromModel() { + return BndEditModelAccessor.getLocalMergeRequirements(model, Constants.RUNBLACKLIST); } @Override diff --git a/bndtools.core/src/bndtools/editor/project/RunBundlesPart.java b/bndtools.core/src/bndtools/editor/project/RunBundlesPart.java index 618dbf9962..0e2a420c4f 100644 --- a/bndtools.core/src/bndtools/editor/project/RunBundlesPart.java +++ b/bndtools.core/src/bndtools/editor/project/RunBundlesPart.java @@ -165,7 +165,12 @@ protected void saveToModel(BndEditModel model, List loadFromModel(BndEditModel model) { - return model.getRunBundles(); + return BndEditModelAccessor.getLocalVersionedClauses(model, aQute.bnd.osgi.Constants.RUNBUNDLES); + } + + @Override + protected List loadMergedFromModel(BndEditModel model) { + return BndEditModelAccessor.getMergedVersionedClauses(model, aQute.bnd.osgi.Constants.RUNBUNDLES); } @Override diff --git a/bndtools.core/src/bndtools/editor/project/RunPropertiesPart.java b/bndtools.core/src/bndtools/editor/project/RunPropertiesPart.java index 635c916fc4..93ce9fa7c7 100644 --- a/bndtools.core/src/bndtools/editor/project/RunPropertiesPart.java +++ b/bndtools.core/src/bndtools/editor/project/RunPropertiesPart.java @@ -1,30 +1,54 @@ package bndtools.editor.project; +import java.io.File; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import org.bndtools.utils.swt.AddRemoveButtonBarPart; import org.bndtools.utils.swt.AddRemoveButtonBarPart.AddRemoveListener; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.Path; import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.LabelProvider; import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.ViewerCell; +import org.eclipse.jface.window.Window; import org.eclipse.swt.SWT; import org.eclipse.swt.events.ControlAdapter; import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.MouseAdapter; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Table; import org.eclipse.swt.widgets.TableColumn; import org.eclipse.swt.widgets.Text; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.dialogs.ElementListSelectionDialog; +import org.eclipse.ui.forms.editor.IFormPage; import org.eclipse.ui.forms.widgets.FormToolkit; import org.eclipse.ui.forms.widgets.Section; +import org.eclipse.ui.part.FileEditorInput; import aQute.bnd.osgi.Constants; +import aQute.bnd.osgi.Processor.PropertyKey; +import bndtools.Plugin; +import bndtools.editor.BndEditor; import bndtools.editor.common.BndEditorPart; import bndtools.editor.common.MapContentProvider; import bndtools.editor.common.MapEntryCellModifier; @@ -32,18 +56,42 @@ import bndtools.editor.utils.ToolTips; import bndtools.utils.ModificationLock; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.eclipse.jface.dialogs.ErrorDialog; + public class RunPropertiesPart extends BndEditorPart { private final ModificationLock lock = new ModificationLock(); - private Map runProperties; + /** Local properties that are written to this file. */ + private final Map localProperties = new LinkedHashMap<>(); + /** Keys present in included files but absent locally (shown gray, non-editable). */ + private final Map inheritedProperties = new LinkedHashMap<>(); + /** Combined view used as viewer input: inherited entries first, then local. */ + private final Map displayProperties = new LinkedHashMap<>(); + /** Keys from inheritedProperties (for label provider and modifier). */ + private final Set inheritedKeys = new java.util.LinkedHashSet<>(); + /** Per-key provenance: maps each inherited property name to the file path that defines it. */ + private Map inheritedProvenances = new java.util.LinkedHashMap<>(); + private String programArgs = null; + private String inheritedProgramArgs = null; private String vmArgs = null; + private String inheritedVmArgs = null; + + /** Key used to write local properties; may be plain or a .local suffix. */ + private String localPropertiesKey = Constants.RUNPROPERTIES; + /** Key used to write local programArgs. */ + private String localProgramArgsKey = Constants.RUNPROGRAMARGS; + /** Key used to write local vmArgs. */ + private String localVmArgsKey = Constants.RUNVM; private final AddRemoveButtonBarPart createRemovePropsPart = new AddRemoveButtonBarPart(); private Table tblRunProperties; private TableViewer viewRunProperties; + private final TableColumn[] tblCols = new TableColumn[2]; private MapEntryCellModifier runPropertiesModifier; private Text txtProgramArgs; @@ -58,44 +106,71 @@ public RunPropertiesPart(Composite parent, FormToolkit toolkit, int style) { createSection(getSection(), toolkit); } + /** Colors inherited table rows gray; local rows use the default foreground. */ + private class MixedPropertiesLabelProvider extends PropertiesTableLabelProvider { + private final Color grey; + + MixedPropertiesLabelProvider(Display display) { + grey = display.getSystemColor(SWT.COLOR_DARK_GRAY); + } + + @Override + public void update(ViewerCell cell) { + super.update(cell); + if (inheritedKeys.contains(cell.getElement())) { + cell.setForeground(grey); + } + } + } + + /** Prevents editing of inherited (gray) entries. */ + private class LocalOnlyModifier extends MapEntryCellModifier { + LocalOnlyModifier(TableViewer viewer) { + super(viewer); + } + + @Override + public boolean canModify(Object element, String property) { + return !inheritedKeys.contains(element) && super.canModify(element, property); + } + } + private void createSection(Section section, FormToolkit toolkit) { section.setText("Runtime Properties"); final Composite composite = toolkit.createComposite(section); section.setClient(composite); - // Create controls: Run Properties Label lblRunProperties = toolkit.createLabel(composite, "OSGi Framework properties:"); tblRunProperties = toolkit.createTable(composite, SWT.FULL_SELECTION | SWT.MULTI | SWT.BORDER); viewRunProperties = new TableViewer(tblRunProperties); - runPropertiesModifier = new MapEntryCellModifier<>(viewRunProperties); + runPropertiesModifier = new LocalOnlyModifier(viewRunProperties); tblRunProperties.setHeaderVisible(true); - final TableColumn tblRunPropsCol1 = new TableColumn(tblRunProperties, SWT.NONE); - tblRunPropsCol1.setText("Name"); - tblRunPropsCol1.setWidth(100); - final TableColumn tblRunPropsCol2 = new TableColumn(tblRunProperties, SWT.NONE); - tblRunPropsCol1.setText("Value"); - tblRunPropsCol1.setWidth(100); + tblCols[0] = new TableColumn(tblRunProperties, SWT.NONE); + tblCols[0].setText("Name"); + tblCols[0].setWidth(100); + tblCols[1] = new TableColumn(tblRunProperties, SWT.NONE); + tblCols[1].setText("Value"); + tblCols[1].setWidth(100); viewRunProperties.setUseHashlookup(true); viewRunProperties.setColumnProperties(MapEntryCellModifier.getColumnProperties()); runPropertiesModifier.addCellEditorsToViewer(); viewRunProperties.setCellModifier(runPropertiesModifier); - viewRunProperties.setContentProvider(new MapContentProvider()); - viewRunProperties.setLabelProvider(new PropertiesTableLabelProvider()); + viewRunProperties.setLabelProvider(new MixedPropertiesLabelProvider(tblRunProperties.getDisplay())); Control createRemovePropsToolBar = createRemovePropsPart.createControl(composite, SWT.FLAT | SWT.VERTICAL); - // Create controls: program args Label lblProgramArgs = toolkit.createLabel(composite, "Launcher Arguments:"); txtProgramArgs = toolkit.createText(composite, "", SWT.MULTI | SWT.BORDER); ToolTips.setupMessageAndToolTipFromSyntax(txtProgramArgs, Constants.RUNPROGRAMARGS); + txtProgramArgs.addMouseListener(inheritedArgsNavigator(txtProgramArgs, Constants.RUNPROGRAMARGS)); - // Create controls: vm args Label lblVmArgs = toolkit.createLabel(composite, "JVM Arguments:"); txtVmArgs = toolkit.createText(composite, "", SWT.MULTI | SWT.BORDER); ToolTips.setupMessageAndToolTipFromSyntax(txtVmArgs, Constants.RUNVM); + txtVmArgs.addMouseListener(inheritedArgsNavigator(txtVmArgs, Constants.RUNVM)); // Layout GridLayout gl; @@ -108,47 +183,70 @@ private void createSection(Section section, FormToolkit toolkit) { lblRunProperties.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false, 2, 1)); - gd = new GridData(SWT.FILL, SWT.FILL, true, false); + // All three content areas grab vertical space equally (equal heightHint = equal base share). + gd = new GridData(SWT.FILL, SWT.FILL, true, true); gd.heightHint = 50; gd.widthHint = 50; tblRunProperties.setLayoutData(gd); - gd = new GridData(SWT.FILL, SWT.TOP, false, true); + gd = new GridData(SWT.FILL, SWT.TOP, false, false); createRemovePropsToolBar.setLayoutData(gd); lblProgramArgs.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false, 2, 1)); gd = new GridData(SWT.FILL, SWT.FILL, true, true, 2, 1); - gd.heightHint = 40; + gd.heightHint = 50; gd.widthHint = 50; txtProgramArgs.setLayoutData(gd); lblVmArgs.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false, 2, 1)); gd = new GridData(SWT.FILL, SWT.FILL, true, true, 2, 1); - gd.heightHint = 40; + gd.heightHint = 50; gd.widthHint = 50; txtVmArgs.setLayoutData(gd); // Listeners - viewRunProperties.addSelectionChangedListener( - event -> createRemovePropsPart.setRemoveEnabled(!viewRunProperties.getSelection() - .isEmpty())); + viewRunProperties.addSelectionChangedListener(event -> { + IStructuredSelection sel = (IStructuredSelection) viewRunProperties.getSelection(); + boolean hasLocal = !sel.isEmpty() && sel.toList().stream().anyMatch(k -> !inheritedKeys.contains(k)); + createRemovePropsPart.setRemoveEnabled(hasLocal); + }); + // Double-click on an inherited row opens the file that defines that specific key. + tblRunProperties.addMouseListener(new MouseAdapter() { + @Override + public void mouseDoubleClick(MouseEvent e) { + IStructuredSelection sel = (IStructuredSelection) viewRunProperties.getSelection(); + if (sel.isEmpty()) return; + String propKey = (String) sel.getFirstElement(); + if (!inheritedKeys.contains(propKey)) return; + String path = inheritedProvenances.get(propKey); + if (path != null) openProvenanceByPath(path); + } + }); createRemovePropsPart.addListener(new AddRemoveListener() { @Override public void addSelected() { - runProperties.put("name", ""); - viewRunProperties.add("name"); + // New entries always go into local display; local key will be resolved on commit. + String newKey = "name"; + // Avoid collision with existing keys + int n = 1; + while (displayProperties.containsKey(newKey)) + newKey = "name" + n++; + displayProperties.put(newKey, ""); + viewRunProperties.add(newKey); markDirty(); - viewRunProperties.editElement("name", 0); + viewRunProperties.editElement(newKey, 0); } @Override public void removeSelected() { - @SuppressWarnings("rawtypes") - Iterator iter = ((IStructuredSelection) viewRunProperties.getSelection()).iterator(); + @SuppressWarnings("unchecked") + Iterator iter = ((IStructuredSelection) viewRunProperties.getSelection()).iterator(); while (iter.hasNext()) { Object item = iter.next(); - runProperties.remove(item); - viewRunProperties.remove(item); + if (!inheritedKeys.contains(item)) { + displayProperties.remove(item); + viewRunProperties.remove(item); + } } markDirty(); } @@ -157,13 +255,13 @@ public void removeSelected() { txtProgramArgs.addModifyListener(ev -> lock.ifNotModifying(() -> { markDirty(); programArgs = txtProgramArgs.getText(); - if (programArgs.length() == 0) + if (programArgs.isEmpty()) programArgs = null; })); txtVmArgs.addModifyListener(ev -> lock.ifNotModifying(() -> { markDirty(); vmArgs = txtVmArgs.getText(); - if (vmArgs.length() == 0) + if (vmArgs.isEmpty()) vmArgs = null; })); composite.addControlListener(new ControlAdapter() { @@ -173,27 +271,19 @@ public void controlResized(ControlEvent e) { Point preferredSize = tblRunProperties.computeSize(SWT.DEFAULT, SWT.DEFAULT); int width = area.width - 2 * tblRunProperties.getBorderWidth(); if (preferredSize.y > area.height + tblRunProperties.getHeaderHeight()) { - // Subtract the scrollbar width from the total column width - // if a vertical scrollbar will be required Point vBarSize = tblRunProperties.getVerticalBar() .getSize(); width -= vBarSize.x; } Point oldSize = tblRunProperties.getSize(); if (oldSize.x > area.width) { - // table is getting smaller so make the columns - // smaller first and then resize the table to - // match the client area width - tblRunPropsCol1.setWidth(width / 3); - tblRunPropsCol2.setWidth(width - tblRunPropsCol1.getWidth()); + tblCols[0].setWidth(width / 3); + tblCols[1].setWidth(width - tblCols[0].getWidth()); tblRunProperties.setSize(area.width, area.height); } else { - // table is getting bigger so make the table - // bigger first and then make the columns wider - // to match the client area width tblRunProperties.setSize(area.width, area.height); - tblRunPropsCol1.setWidth(width / 3); - tblRunPropsCol2.setWidth(width - tblRunPropsCol1.getWidth()); + tblCols[0].setWidth(width / 3); + tblCols[1].setWidth(width - tblCols[0].getWidth()); } } }); @@ -206,36 +296,211 @@ protected String[] getProperties() { @Override protected void refreshFromModel() { - Map tmp = model.getRunProperties(); - if (tmp == null) - this.runProperties = new HashMap<>(); - else - this.runProperties = new HashMap<>(tmp); - viewRunProperties.setInput(runProperties); + // --- Properties table ------------------------------------------------ + Map mergedProps = BndEditModelAccessor.getMergedProperties(model, Constants.RUNPROPERTIES); + Map localProps = BndEditModelAccessor.getLocalProperties(model, Constants.RUNPROPERTIES); + if (mergedProps == null) mergedProps = new LinkedHashMap<>(); + if (localProps == null) localProps = new LinkedHashMap<>(); + + inheritedProperties.clear(); + localProperties.clear(); + inheritedKeys.clear(); + for (Map.Entry e : mergedProps.entrySet()) { + if (localProps.containsKey(e.getKey())) { + localProperties.put(e.getKey(), e.getValue()); + } else { + inheritedProperties.put(e.getKey(), e.getValue()); + inheritedKeys.add(e.getKey()); + } + } + // Local-only keys (not in merged) are also local + for (Map.Entry e : localProps.entrySet()) { + if (!mergedProps.containsKey(e.getKey())) + localProperties.put(e.getKey(), e.getValue()); + } + + displayProperties.clear(); + displayProperties.putAll(inheritedProperties); + displayProperties.putAll(localProperties); + + // Per-key provenance for the double-click handler. + inheritedProvenances = BndEditModelAccessor.getInheritedPropertiesProvenance(model, Constants.RUNPROPERTIES); + + // Table tooltip. + tblRunProperties.setToolTipText( + inheritedProperties.isEmpty() ? null + : "Some entries are inherited from included files. Double-click an inherited row to open its source."); + viewRunProperties.setInput(displayProperties); + + // Determine local key for properties. + String existingKey = BndEditModelAccessor.findLocalMergeKey(model, Constants.RUNPROPERTIES); + localPropertiesKey = (existingKey != null) ? existingKey + : (!inheritedProperties.isEmpty() ? Constants.RUNPROPERTIES + ".local" : Constants.RUNPROPERTIES); + + // --- Launcher Arguments text field ----------------------------------- + refreshTextArg(txtProgramArgs, Constants.RUNPROGRAMARGS, + s -> programArgs = s, this::setLocalProgramArgsKey); + + // --- JVM Arguments text field ---------------------------------------- + refreshTextArg(txtVmArgs, Constants.RUNVM, + s -> vmArgs = s, this::setLocalVmArgsKey); + } + + @FunctionalInterface + private interface StringSetter { void set(String v); } + @FunctionalInterface + private interface StringKeySetter { void set(String key); } + + private void refreshTextArg(Text txt, String stem, + StringSetter localSetter, StringKeySetter keySetter) { + + String merged = BndEditModelAccessor.getMergedString(model, stem); + boolean hasLocal = BndEditModelAccessor.hasLocalMergeProperty(model, stem); + String existing = BndEditModelAccessor.findLocalMergeKey(model, stem); + String key = (existing != null) ? existing + : (!hasLocal && merged != null && !merged.isBlank() + ? stem + ".local" : stem); + keySetter.set(key); lock.modifyOperation(() -> { - programArgs = model.getRunProgramArgs(); - if (programArgs == null) - programArgs = ""; //$NON-NLS-1$ - txtProgramArgs.setText(programArgs); - - vmArgs = model.getRunVMArgs(); - if (vmArgs == null) - vmArgs = ""; - txtVmArgs.setText(vmArgs); + if (hasLocal) { + // Show the local value, editable. + String localVal = model.getTypedProperty(existing != null ? existing : stem); + String display = localVal != null ? localVal : ""; + txt.setText(display); + localSetter.set(localVal); + txt.setEditable(true); + txt.setForeground(null); + txt.setToolTipText(null); + } else if (merged != null && !merged.isBlank()) { + // Show inherited value grayed out; editable=false keeps mouse events (enabled=false would swallow them). + txt.setText(merged); + localSetter.set(null); + txt.setEditable(false); + Color grey = txt.getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY); + txt.setForeground(grey); + List inherited = BndEditModelAccessor.getInheritedPropertyKeys(model, stem); + String tip; + if (inherited.size() > 1) { + tip = "Inherited from multiple included files. Double-click to choose and open a source."; + } else { + tip = BndEditModelAccessor.getPropertyProvenance(model, stem) + .map(p -> "Inherited from " + p + ". Double-click to open source.") + .orElse("Inherited from an included file."); + } + txt.setToolTipText(tip); + } else { + txt.setText(""); + localSetter.set(null); + txt.setEditable(true); + txt.setForeground(null); + txt.setToolTipText(null); + } }); } + private void setLocalProgramArgsKey(String key) { localProgramArgsKey = key; } + private void setLocalVmArgsKey(String key) { localVmArgsKey = key; } + @Override protected void commitToModel(boolean onSave) { - model.setRunProperties(runProperties); - model.setRunProgramArgs(emptyToNull(programArgs)); - model.setRunVMArgs(emptyToNull(vmArgs)); + // Properties: local entries = displayProperties minus inherited keys. + Map toSave = new LinkedHashMap<>(displayProperties); + toSave.keySet().removeAll(inheritedKeys); + String propsKey = localPropertiesKey != null ? localPropertiesKey : Constants.RUNPROPERTIES; + if (Constants.RUNPROPERTIES.equals(propsKey)) { + model.setRunProperties(toSave); + } else { + BndEditModelAccessor.setPropertiesByKey(model, propsKey, toSave); + } + + // getEditable(): inherited fields are editable=false; enabled state is unreliable during save + if (txtProgramArgs.getEditable()) { + String value = emptyToNull(txtProgramArgs.getText()); + String key = localProgramArgsKey != null ? localProgramArgsKey : Constants.RUNPROGRAMARGS; + if (Constants.RUNPROGRAMARGS.equals(key)) { + model.setRunProgramArgs(value); + } else { + model.setTypedProperty(key, value); + } + } + if (txtVmArgs.getEditable()) { + String value = emptyToNull(txtVmArgs.getText()); + String key = localVmArgsKey != null ? localVmArgsKey : Constants.RUNVM; + if (Constants.RUNVM.equals(key)) { + model.setRunVMArgs(value); + } else { + model.setTypedProperty(key, value); + } + } + } + + private MouseAdapter inheritedArgsNavigator(Text txt, String stem) { + return new MouseAdapter() { + @Override + public void mouseDoubleClick(MouseEvent e) { + if (txt.getEditable()) + return; + openInheritedArgsProvenance(stem); + } + }; + } + + /** Opens the defining file of an inherited args value; multiple sources show a chooser. */ + private void openInheritedArgsProvenance(String stem) { + List inherited = BndEditModelAccessor.getInheritedPropertyKeys(model, stem); + if (inherited.isEmpty()) + return; + if (inherited.size() == 1) { + inherited.get(0) + .getProvenance() + .ifPresent(this::openProvenanceByPath); + return; + } + ElementListSelectionDialog dialog = new ElementListSelectionDialog(getSection().getShell(), + new LabelProvider() { + @Override + public String getText(Object element) { + PropertyKey pk = (PropertyKey) element; + String value = pk.getRawValue() != null ? pk.getRawValue() : ""; + String file = pk.getProvenance() + .map(p -> new File(p).getName()) + .orElse("?"); + return pk.key() + " = " + value + " \u2014 " + file; + } + }); + dialog.setTitle("Inherited " + stem); + dialog.setMessage("Select an entry to open its defining file:"); + dialog.setElements(inherited.toArray()); + dialog.setMultipleSelection(false); + if (dialog.open() == Window.OK) { + PropertyKey pk = (PropertyKey) dialog.getFirstResult(); + if (pk != null) + pk.getProvenance() + .ifPresent(this::openProvenanceByPath); + } } private String emptyToNull(String s) { - if (s != null && s.isEmpty()) - return null; - return s; + return (s != null && !s.isEmpty()) ? s : null; + } + + private void openProvenanceByPath(String absolutePath) { + File file = new File(absolutePath); + if (!file.isFile()) + return; + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + IFile iFile = root.getFileForLocation(new Path(absolutePath)); + if (iFile == null || !iFile.exists()) + return; + try { + PlatformUI.getWorkbench() + .getActiveWorkbenchWindow() + .getActivePage() + .openEditor(new FileEditorInput(iFile), BndEditor.WORKSPACE_EDITOR); + } catch (PartInitException e) { + ErrorDialog.openError(getSection().getShell(), "Error", null, + new Status(IStatus.ERROR, Plugin.PLUGIN_ID, 0, "Failed to open source file.", e)); + } } } diff --git a/bndtools.core/src/bndtools/editor/project/RunRequirementsPart.java b/bndtools.core/src/bndtools/editor/project/RunRequirementsPart.java index 589c82d9c6..4d3ee098e7 100644 --- a/bndtools.core/src/bndtools/editor/project/RunRequirementsPart.java +++ b/bndtools.core/src/bndtools/editor/project/RunRequirementsPart.java @@ -14,7 +14,6 @@ import org.eclipse.swt.events.SelectionAdapter; import org.eclipse.swt.events.SelectionEvent; import org.eclipse.swt.events.SelectionListener; -import org.eclipse.swt.graphics.Image; import org.eclipse.swt.layout.FillLayout; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; @@ -45,7 +44,6 @@ public class RunRequirementsPart extends AbstractRequirementListPart { RUNREQUIRE, Constants.RUNREQUIRES, Constants.RESOLVE }; - private final static Image resolveIcon = Icons.image("resolve"); private Button btnResolveNow; private JobChangeAdapter resolveJobListener; private Combo comboResolveMode; @@ -61,6 +59,11 @@ protected String[] getProperties() { return SUBSCRIBE_PROPS; } + @Override + protected String getPrimaryPropertyKey() { + return Constants.RUNREQUIRES; + } + private void createSection(Section section, FormToolkit tk) { section.setText("Run Requirements"); section.setDescription( @@ -104,7 +107,7 @@ private void createSection(Section section, FormToolkit tk) { resolveModeComposite.setLayout(new FillLayout()); btnResolveNow = tk.createButton(composite, "Resolve", SWT.PUSH); - btnResolveNow.setImage(resolveIcon); + setResolveButtonImage(); btnResolveNow.addSelectionListener(new SelectionAdapter() { @Override @@ -180,7 +183,12 @@ private void doResolve() { @Override protected void doCommitToModel(List requires) { if (isDirty()) { - model.setRunRequires(requires); + String key = getLocalKey(); + if (Constants.RUNREQUIRES.equals(key)) { + model.setRunRequires(requires); + } else { + BndEditModelAccessor.setRequirementListByKey(model, key, requires); + } } } @@ -189,18 +197,33 @@ public List doRefreshFromModel() { comboResolveMode.select(model.getResolveMode() .ordinal()); updateResolveModeDescription(); - - return model.getRunRequires(); + // Return only requirements defined in this file's own merge keys (inherited items are + // computed separately by the base class and displayed in gray). + return BndEditModelAccessor.getLocalMergeRequirements(model, Constants.RUNREQUIRES); } private void updateResolveModeDescription() { if (lblResolveModeDescription == null || lblResolveModeDescription.isDisposed()) return; + if (btnResolveNow != null && !btnResolveNow.isDisposed()) { + setResolveButtonImage(); + } ResolveMode mode = model.getResolveMode(); String description = getResolveModeDescription(mode); lblResolveModeDescription.setText(description != null ? description : ""); - lblResolveModeDescription.getParent() - .layout(); + Composite parent = lblResolveModeDescription.getParent(); + if (parent != null && !parent.isDisposed()) { + parent.layout(); + } + } + + private void setResolveButtonImage() { + if (btnResolveNow == null || btnResolveNow.isDisposed()) + return; + if (btnResolveNow.getImage() == null || btnResolveNow.getImage() + .isDisposed()) { + btnResolveNow.setImage(Icons.image("resolve")); + } } private static String getResolveModeDescription(ResolveMode mode) { diff --git a/bndtools.core/src/bndtools/model/repo/RepositoryBundleUtils.java b/bndtools.core/src/bndtools/model/repo/RepositoryBundleUtils.java index 32be671869..557a94d814 100644 --- a/bndtools.core/src/bndtools/model/repo/RepositoryBundleUtils.java +++ b/bndtools.core/src/bndtools/model/repo/RepositoryBundleUtils.java @@ -151,4 +151,30 @@ public static VersionRange toVersionRangeUpToNextMajor(Version l) { return new VersionRange(true, l.getWithoutQualifier(), h, false); } + /** + * Converts a RepositoryFeature into a VersionedClause in the canonical feature syntax: + * id;version='V';feature=true;type=org.eclipse.update.feature + * + * @param feature + * @return a VersionedClause for the feature + */ + public static VersionedClause convertRepoFeature(RepositoryFeature feature) { + Attrs attribs = new Attrs(); + String featureId = feature.getFeature().getId(); + VersionedClause clause = new VersionedClause("feature:" + featureId, attribs); + + // Set version if available + if (feature.getFeature().getVersion() != null) { + clause.setVersionRange(feature.getFeature().getVersion()); + } + + // Add feature=true attribute for resolver identification + clause.getAttribs().put("feature", "true"); + + // Add type attribute for Eclipse update compatibility + clause.getAttribs().put("type", "org.eclipse.update.feature"); + + return clause; + } + } diff --git a/bndtools.core/src/org/bndtools/core/ui/icons/Icons.java b/bndtools.core/src/org/bndtools/core/ui/icons/Icons.java index e2e96bf406..52bf05a0ee 100644 --- a/bndtools.core/src/org/bndtools/core/ui/icons/Icons.java +++ b/bndtools.core/src/org/bndtools/core/ui/icons/Icons.java @@ -155,7 +155,7 @@ public static Image image(String name, boolean nullIfAbsent) { Key k = new Key(name); synchronized (images) { Image image = images.get(k); - if (image == null) { + if (image == null || image.isDisposed()) { image = desc.createImage(); images.put(k, image); }