CoordinatedCleanUpPreviewViewer.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*******************************************************************************/
package org.sandbox.jdt.cleanup.multifile.ui;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.SashForm;
import org.eclipse.swt.custom.ViewForm;
import org.eclipse.swt.graphics.Image;
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.Label;
import org.eclipse.swt.widgets.Text;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IAdaptable;
import org.eclipse.core.runtime.NullProgressMonitor;
import org.eclipse.jface.dialogs.Dialog;
import org.eclipse.jface.viewers.ArrayContentProvider;
import org.eclipse.jface.viewers.IStructuredSelection;
import org.eclipse.jface.viewers.LabelProvider;
import org.eclipse.jface.viewers.TableViewer;
import org.eclipse.jface.viewers.Viewer;
import org.eclipse.compare.CompareConfiguration;
import org.eclipse.compare.CompareUI;
import org.eclipse.compare.CompareViewerSwitchingPane;
import org.eclipse.compare.IEncodedStreamContentAccessor;
import org.eclipse.compare.IResourceProvider;
import org.eclipse.compare.ITypedElement;
import org.eclipse.compare.structuremergeviewer.DiffNode;
import org.eclipse.compare.structuremergeviewer.ICompareInput;
import org.eclipse.ltk.core.refactoring.Change;
import org.eclipse.ltk.core.refactoring.CompositeChange;
import org.eclipse.ltk.core.refactoring.TextEditBasedChange;
import org.eclipse.ltk.ui.refactoring.ChangePreviewViewerInput;
import org.eclipse.ltk.ui.refactoring.IChangePreviewViewer;
/**
* Presents one coordinated multi-file Cleanup candidate without exposing its
* necessary per-file changes as independently selectable preview entries.
*
* <p>The candidate itself remains selectable through the ordinary checkbox in
* the LTK change tree. This viewer explains the atomic selection contract,
* lists the affected source files, and provides a read-only diff for each file.
* It deliberately depends only on the public LTK/Compare APIs. Access to the
* optional patched-JDT change metadata is reflective so the ordinary stock-JDT
* product can still resolve this bundle.</p>
*/
public final class CoordinatedCleanUpPreviewViewer implements IChangePreviewViewer {
private static final String CHANGE_CLASS=
"org.eclipse.jdt.internal.corext.fix.CoordinatedCleanUpChange"; //$NON-NLS-1$
private static final String ATOMIC_SELECTION=
"Selection is atomic: use the single candidate checkbox in the Changes tree to include or exclude every required file."; //$NON-NLS-1$
private AdaptableViewForm root;
private Label title;
private Text description;
private TableViewer files;
private Text details;
private ComparePreviewer comparePreviewer;
@Override
public void createControl(Composite parent) {
CompareConfiguration configuration= new CompareConfiguration();
configuration.setLeftEditable(false);
configuration.setRightEditable(false);
configuration.setLeftLabel("Refactored source"); //$NON-NLS-1$
configuration.setRightLabel("Original source"); //$NON-NLS-1$
root= new AdaptableViewForm(parent, configuration);
Composite body= new Composite(root, SWT.NONE);
body.setLayout(new GridLayout(1, false));
title= new Label(body, SWT.WRAP);
title.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
description= new Text(body, SWT.READ_ONLY | SWT.WRAP | SWT.MULTI);
GridData descriptionData= new GridData(SWT.FILL, SWT.CENTER, true, false);
descriptionData.heightHint= 42;
description.setLayoutData(descriptionData);
SashForm sash= new SashForm(body, SWT.HORIZONTAL);
sash.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
Composite overview= new Composite(sash, SWT.NONE);
overview.setLayout(new GridLayout(1, false));
Label filesLabel= new Label(overview, SWT.NONE);
filesLabel.setText("Affected source files"); //$NON-NLS-1$
files= new TableViewer(overview, SWT.BORDER | SWT.SINGLE | SWT.V_SCROLL | SWT.H_SCROLL);
files.setContentProvider(ArrayContentProvider.getInstance());
files.setLabelProvider(new LabelProvider() {
@Override
public String getText(Object element) {
return element instanceof FilePreview preview ? preview.label() : super.getText(element);
}
});
GridData filesData= new GridData(SWT.FILL, SWT.FILL, true, false);
filesData.heightHint= 110;
files.getControl().setLayoutData(filesData);
files.addSelectionChangedListener(event -> {
IStructuredSelection selection= event.getStructuredSelection();
showFile(selection.getFirstElement() instanceof FilePreview preview ? preview : null);
});
Label detailsLabel= new Label(overview, SWT.NONE);
detailsLabel.setText("Safety and scope evidence"); //$NON-NLS-1$
details= new Text(overview, SWT.READ_ONLY | SWT.WRAP | SWT.MULTI | SWT.V_SCROLL);
GridData detailsData= new GridData(SWT.FILL, SWT.FILL, true, true);
detailsData.heightHint= 90;
details.setLayoutData(detailsData);
comparePreviewer= new ComparePreviewer(sash, configuration);
sash.setWeights(35, 65);
root.setContent(body);
Dialog.applyDialogFont(root);
clear("Select a coordinated Cleanup candidate to inspect its affected files."); //$NON-NLS-1$
}
@Override
public Control getControl() {
return root;
}
@Override
public void setInput(ChangePreviewViewerInput input) {
if (root == null || root.isDisposed()) {
return;
}
Change change= input == null ? null : input.getChange();
if (change == null || !CHANGE_CLASS.equals(change.getClass().getName())) {
clear("No coordinated Cleanup candidate is selected."); //$NON-NLS-1$
return;
}
try {
CandidatePreview candidate= readCandidate(change);
title.setText(candidate.name());
description.setText(candidate.description().isBlank()
? ATOMIC_SELECTION
: candidate.description() + System.lineSeparator() + ATOMIC_SELECTION);
details.setText(String.join(System.lineSeparator(), candidate.details()));
setFileInput(candidate.files());
if (candidate.files().isEmpty()) {
showFile(null);
} else {
files.setSelection(new org.eclipse.jface.viewers.StructuredSelection(candidate.files().get(0)), true);
}
root.layout(true, true);
} catch (CoreException | ReflectiveOperationException | RuntimeException exception) {
clear("The coordinated Cleanup preview could not be created: " + safeMessage(exception)); //$NON-NLS-1$
}
}
private CandidatePreview readCandidate(Change change)
throws ReflectiveOperationException, CoreException {
String candidateDescription= invoke(change, "getDescription", String.class); //$NON-NLS-1$
List<String> safetyDetails= stringList(invoke(change, "getSafetyDetails", List.class)); //$NON-NLS-1$
Change[] changes= invoke(change, "getChanges", Change[].class); //$NON-NLS-1$
List<FilePreview> filePreviews= new ArrayList<>();
if (changes != null) {
for (Change child : changes) {
if (child != null) {
collectFilePreviews(child, filePreviews);
}
}
}
List<String> allDetails= new ArrayList<>();
allDetails.add(ATOMIC_SELECTION);
allDetails.addAll(safetyDetails);
if (filePreviews.isEmpty()) {
allDetails.add("No textual source diff is available for this candidate."); //$NON-NLS-1$
}
return new CandidatePreview(change.getName(), candidateDescription, List.copyOf(allDetails),
List.copyOf(filePreviews));
}
private static void collectFilePreviews(Change change, List<FilePreview> result) throws CoreException {
if (change instanceof TextEditBasedChange textChange) {
NullProgressMonitor monitor= new NullProgressMonitor();
String current= textChange.getCurrentContent(monitor);
String preview= textChange.getPreviewContent(new NullProgressMonitor());
IResource resource= resource(change);
String label= resource == null
? change.getName()
: resource.getProjectRelativePath().toPortableString();
String type= textChange.getTextType();
result.add(new FilePreview(label, current, preview,
type == null || type.isBlank() ? "txt" : type, resource)); //$NON-NLS-1$
return;
}
if (change instanceof CompositeChange composite) {
for (Change child : composite.getChildren()) {
collectFilePreviews(child, result);
}
}
}
private static IResource resource(Change change) {
Object modified= change.getModifiedElement();
if (modified instanceof IResource resource) {
return resource;
}
if (modified instanceof IAdaptable adaptable) {
return adaptable.getAdapter(IResource.class);
}
return null;
}
private void setFileInput(List<FilePreview> input) {
files.setInput(input);
// Keep a short file list compact so the safety evidence gets the remaining height.
GridData data= (GridData) files.getControl().getLayoutData();
data.heightHint= files.getTable().getItemHeight() * Math.max(1, Math.min(5, input.size()));
}
private void showFile(FilePreview preview) {
if (comparePreviewer != null && !comparePreviewer.isDisposed()) {
comparePreviewer.setPreview(preview);
}
}
private void clear(String message) {
if (title != null && !title.isDisposed()) {
title.setText("Coordinated Cleanup preview"); //$NON-NLS-1$
}
if (description != null && !description.isDisposed()) {
description.setText(message == null ? "" : message); //$NON-NLS-1$
}
if (details != null && !details.isDisposed()) {
details.setText(ATOMIC_SELECTION);
}
if (files != null && files.getControl() != null && !files.getControl().isDisposed()) {
setFileInput(List.of());
}
showFile(null);
}
private static String safeMessage(Throwable throwable) {
Throwable current= throwable;
if (throwable instanceof InvocationTargetException invocation && invocation.getCause() != null) {
current= invocation.getCause();
}
String message= current.getMessage();
return message == null || message.isBlank() ? current.getClass().getSimpleName() : message;
}
private static List<String> stringList(Object value) {
if (!(value instanceof List<?> list)) {
return List.of();
}
return list.stream().filter(String.class::isInstance).map(String.class::cast).toList();
}
private static <T> T invoke(Change change, String methodName, Class<T> expectedType)
throws ReflectiveOperationException {
Method method= change.getClass().getMethod(methodName);
Object value= method.invoke(change);
return expectedType.cast(value);
}
private record CandidatePreview(String name, String description, List<String> details,
List<FilePreview> files) {
CandidatePreview {
name= name == null ? "" : name; //$NON-NLS-1$
description= description == null ? "" : description; //$NON-NLS-1$
details= details == null ? List.of() : details;
files= files == null ? List.of() : files;
}
}
private record FilePreview(String label, String originalContent, String refactoredContent,
String type, IResource resource) {
}
private static final class AdaptableViewForm extends ViewForm implements IAdaptable {
private final CompareConfiguration configuration;
AdaptableViewForm(Composite parent, CompareConfiguration configuration) {
super(parent, SWT.NONE);
this.configuration= configuration;
}
@Override
public <T> T getAdapter(Class<T> adapter) {
if (CompareConfiguration.class.equals(adapter)) {
return adapter.cast(configuration);
}
return null;
}
}
private static final class ComparePreviewer extends CompareViewerSwitchingPane {
private final CompareConfiguration configuration;
ComparePreviewer(Composite parent, CompareConfiguration configuration) {
super(parent, SWT.BORDER | SWT.FLAT, true);
this.configuration= configuration;
}
@Override
protected Viewer getViewer(Viewer oldViewer, Object input) {
return CompareUI.findContentViewer(oldViewer, (ICompareInput) input, this, configuration);
}
void setPreview(FilePreview preview) {
if (preview == null) {
setInput(null);
return;
}
setInput(new DiffNode(
new CompareElement(preview.refactoredContent(), preview.type(), preview.resource()),
new CompareElement(preview.originalContent(), preview.type(), preview.resource())));
}
}
private static final class CompareElement
implements ITypedElement, IEncodedStreamContentAccessor, IResourceProvider {
private final String content;
private final String type;
private final IResource resource;
CompareElement(String content, String type, IResource resource) {
this.content= content;
this.type= type;
this.resource= resource;
}
@Override
public String getName() {
return "Source"; //$NON-NLS-1$
}
@Override
public Image getImage() {
return null;
}
@Override
public String getType() {
return type;
}
@Override
public InputStream getContents() {
return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
}
@Override
public String getCharset() {
return StandardCharsets.UTF_8.name();
}
@Override
public IResource getResource() {
return resource;
}
}
}