From d8f49e8f9861720bc2b9af15cf09d9f7d669f021 Mon Sep 17 00:00:00 2001 From: Peter Kriens Date: Wed, 3 Apr 2024 15:07:04 +0200 Subject: [PATCH] New Workspace Wizard and after creation synchronization --- Signed-off-by: Peter Kriens Signed-off-by: Peter Kriens --- bndtools.core/_plugin.xml | 25 +- bndtools.core/bnd.bnd | 8 +- .../src/bndtools/central/Central.java | 70 +- .../src/bndtools/central/Starter.java | 15 + .../sync/SynchronizeWorkspaceWithEclipse.java | 4 +- .../bndtools/perspective/BndPerspective.java | 1 + bndtools.core/src/bndtools/util/ui/UI.java | 616 ++++++++++++++++++ .../bndtools/wizards/newworkspace/Model.java | 165 +++++ .../newworkspace/NewWorkspaceWizard.java | 261 ++++++++ .../TemplateDefinitionDialog.java | 66 ++ .../test/bndtools/util/ui/UITest.java | 220 +++++++ 11 files changed, 1436 insertions(+), 15 deletions(-) create mode 100644 bndtools.core/src/bndtools/central/Starter.java create mode 100644 bndtools.core/src/bndtools/util/ui/UI.java create mode 100644 bndtools.core/src/bndtools/wizards/newworkspace/Model.java create mode 100644 bndtools.core/src/bndtools/wizards/newworkspace/NewWorkspaceWizard.java create mode 100644 bndtools.core/src/bndtools/wizards/newworkspace/TemplateDefinitionDialog.java create mode 100644 bndtools.core/test/bndtools/util/ui/UITest.java diff --git a/bndtools.core/_plugin.xml b/bndtools.core/_plugin.xml index 04756e7ad9..6ec239377e 100644 --- a/bndtools.core/_plugin.xml +++ b/bndtools.core/_plugin.xml @@ -55,6 +55,10 @@ + + + @@ -143,14 +147,29 @@ preferredPerspectives="bndtools.perspective" icon="icons/bricks.png" name="Bnd OSGi Project" project="true"> - + + Creates a new bnd workspace. You will be able to select template fragments + that will define the new workspace. At finish, you can switch to the new + workspace. + + + + icon="icons/bndtools-logo-16x16.png" name="Bnd OSGi Workspace (Deprecated)"> + + Old style workspace creation, will be deprecated in a coming release. + + eclipseWorkspaceRepository = Memoize .supplier(EclipseWorkspaceRepository::new); + private final static AtomicBoolean syncing = new AtomicBoolean(); private static Auxiliary auxiliary; @@ -96,7 +107,6 @@ public class Central implements IStartupParticipant { private final BundleContext bundleContext; private final Map javaProjectToModel = new HashMap<>(); private final List listeners = new CopyOnWriteArrayList<>(); - private RepositoryListenerPluginTracker repoListenerTracker; private final InternalPluginTracker internalPlugins; @@ -135,7 +145,6 @@ public Central() { @Override public void start() { instance = this; - repoListenerTracker = new RepositoryListenerPluginTracker(bundleContext); repoListenerTracker.open(); internalPlugins.open(); @@ -416,9 +425,58 @@ private static File getWorkspaceDirectory() throws CoreException { .getParentFile(); } + String path = Platform.getInstanceLocation() + .getURL() + .getPath(); + + if (IO.isWindows() && path.startsWith("/")) + path = path.substring(1); + + File folder = new File(path); + File build = IO.getFile(folder, "cnf/build.bnd"); + if (build.isFile()) { + if (syncing.getAndSet(true) == false) { + Job job = Job.create("sync ws", mon -> { + WorkspaceSynchronizer wss = new WorkspaceSynchronizer(); + wss.synchronize(false, mon, () -> { + syncing.set(false); + }); + setBndtoolsPerspective(); + final IIntroManager introManager = PlatformUI.getWorkbench() + .getIntroManager(); + IIntroPart part = introManager.getIntro(); + introManager.closeIntro(part); + }); + job.schedule(); + } + return folder; + } + return null; } + public static void setBndtoolsPerspective() { + Display.getDefault() + .syncExec(() -> { + IWorkbenchWindow window = PlatformUI.getWorkbench() + .getActiveWorkbenchWindow(); + if (window != null) { + IWorkbenchPage page = window.getActivePage(); + IPerspectiveRegistry reg = PlatformUI.getWorkbench() + .getPerspectiveRegistry(); + // Replace "your.perspective.id" with the + // actual ID + // of + // the perspective you want to switch to + IPerspectiveDescriptor bndtools = reg.findPerspectiveWithId("bndtools.perspective"); + if (bndtools != null) + page.setPerspective(bndtools); + + return; + } + }); + } + /** * Determine if the given directory is a workspace. * @@ -700,8 +758,8 @@ public static IResource toResource(File file) { * @throws Exception If the callable throws an exception. */ public static V bndCall(BiFunctionWithException, BooleanSupplier, V> lockMethod, - FunctionWithException, V> callable, - IProgressMonitor monitorOrNull) throws Exception { + FunctionWithException, V> callable, IProgressMonitor monitorOrNull) + throws Exception { IProgressMonitor monitor = monitorOrNull == null ? new NullProgressMonitor() : monitorOrNull; Task task = new Task() { @Override @@ -728,14 +786,14 @@ public void abort() { try { Callable with = () -> TaskManager.with(task, () -> callable.apply((name, runnable) -> after.add(() -> { monitor.subTask(name); - try { + try { runnable.run(); } catch (Exception e) { if (!(e instanceof OperationCanceledException)) { status.add(new Status(IStatus.ERROR, runnable.getClass(), "Unexpected exception in bndCall after action: " + name, e)); - } } + } }))); return lockMethod.apply(with, monitor::isCanceled); } finally { diff --git a/bndtools.core/src/bndtools/central/Starter.java b/bndtools.core/src/bndtools/central/Starter.java new file mode 100644 index 0000000000..638336ac1a --- /dev/null +++ b/bndtools.core/src/bndtools/central/Starter.java @@ -0,0 +1,15 @@ +package bndtools.central; + +import org.eclipse.ui.IStartup; + +public class Starter implements IStartup { + + @Override + public void earlyStartup() { + try { + Central.getWorkspace(); + } catch (Exception e) { + } + } + +} diff --git a/bndtools.core/src/bndtools/central/sync/SynchronizeWorkspaceWithEclipse.java b/bndtools.core/src/bndtools/central/sync/SynchronizeWorkspaceWithEclipse.java index 089fb34545..653301ac21 100644 --- a/bndtools.core/src/bndtools/central/sync/SynchronizeWorkspaceWithEclipse.java +++ b/bndtools.core/src/bndtools/central/sync/SynchronizeWorkspaceWithEclipse.java @@ -42,7 +42,7 @@ * with the file system. Any deltas are processed by creating or deleting the * project. */ -@Component(enabled = false) +@Component(enabled = true) public class SynchronizeWorkspaceWithEclipse { static IWorkspace eclipse = ResourcesPlugin.getWorkspace(); final static IWorkspaceRoot root = eclipse.getRoot(); @@ -86,8 +86,6 @@ private void sync(Collection doNotUse) { return; } - // No need to turn off autobuild as the lock will take care of it - Job sync = Job.create("sync workspace", (IProgressMonitor monitor) -> { Map projects = Stream.of(root.getProjects()) diff --git a/bndtools.core/src/bndtools/perspective/BndPerspective.java b/bndtools.core/src/bndtools/perspective/BndPerspective.java index e902eccb32..ac06352bf2 100644 --- a/bndtools.core/src/bndtools/perspective/BndPerspective.java +++ b/bndtools.core/src/bndtools/perspective/BndPerspective.java @@ -74,6 +74,7 @@ public void createInitialLayout(IPageLayout layout) { // new actions - Java project creation wizard layout.addNewWizardShortcut(PartConstants.WIZARD_ID_NEWPROJECT); layout.addNewWizardShortcut(PartConstants.WIZARD_ID_NEWWORKSPACE); + layout.addNewWizardShortcut(PartConstants.WIZARD_ID_NEWWORKSPACE + "Deprecated"); layout.addNewWizardShortcut(PartConstants.WIZARD_ID_NEWBNDRUN); layout.addNewWizardShortcut(PartConstants.WIZARD_ID_NEWBND); layout.addNewWizardShortcut(PartConstants.WIZARD_ID_NEWBLUEPRINT_XML); diff --git a/bndtools.core/src/bndtools/util/ui/UI.java b/bndtools.core/src/bndtools/util/ui/UI.java new file mode 100644 index 0000000000..1830902481 --- /dev/null +++ b/bndtools.core/src/bndtools/util/ui/UI.java @@ -0,0 +1,616 @@ +package bndtools.util.ui; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodHandles.Lookup; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.eclipse.jface.viewers.CheckboxTableViewer; +import org.eclipse.jface.viewers.ISelectionChangedListener; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Text; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import aQute.bnd.exceptions.Exceptions; +import aQute.lib.io.IO; + +/** + * A Utility for MVC like programming in Java. + *

+ * The model is a DTO class with fields and methods. The idea is that you can + * change these variables and then they are automatically updating the Widgets. + * The model can contain fields and methods. This class uses a method in + * preference of a field. A get method is applicable if the it takes the + * identical return type as the field and has no parameters. A set method is if + * it takes 1 parameter with the exact type of the field. A field, however, is + * mandatory because that is how the fields are discovered. A field must be + * non-final, non-static, non-synthetic and non-transient. + *

+ * The only requirement is that you modify them in a {@link #read(Supplier)} or + * {@link #write(Runnable)} block. These methods ensure that any updates are + * handled thread safe and properly synchronized. + *

+ * A UI is created as follows: + * + *

+ * final M model = new M();
+ * final UI ui = new UI<>(model);
+ * 
+ *

+ * To other side of the model is the _world_. These are widgets or methods + * updating some information on the GUI. These are bound through a Target + * interface. The mandatory method {@link Target#set(Object)} sets the value + * from the model to the world. The optional {@link Target#subscribe(Consumer)} + * method can be used to let the world update the model from a subscription + * model like addXXXListeners in SWT. There are convenient methods in this class + * to transform common widgets to Target. + * + *

+ * ui.u("name", model.name)
+ * 	.bind(UI.checkbox(myCheckbox));
+ * 
+ *

+ * However, a Target is also a functional interface. This makes it possible + * to just use a lambda: + * + *

+ * ui.u("name", model.name)
+ * 	.bind(this::setTitle);
+ * 
+ *

+ * The updating of the world is delayed and changes are coalesced. On the world + * side, there is a guarantee that only changes are updated. If the subscription + * sets a value than that value is is assumed to be the world's value. I.e. if + * the model tries to set that same value back, the world will not be updated. + *

+ * Values in the model must be simple type. Changes are detected with the normal + * equals and hashCode. null is properly handled everywhere. + *

+ * If the model requires some calculation before the world is updated, it can + * implement Runnable. This runnable is called inside the lock to do for example + * validation. + * + * @param model type + */ +public class UI implements AutoCloseable { + final static Logger log = LoggerFactory.getLogger(UI.class); + final static Lookup lookup = MethodHandles.lookup(); + final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + final Map access = new HashMap<>(); + final List> updaters = new ArrayList<>(); + final Class modelType; + final List updates = new CopyOnWriteArrayList<>(); + final M model; + + class Guarded { + int version = 100; + CountDownLatch updated = null; + } + + final Guarded lock = new Guarded(); + + /* + * The Access class maps to a single field in the model. It methods the + * MethodHandles to access the field or methods and it has a map of bindings + * and their last updated value. + */ + class Access implements AutoCloseable { + + final MethodHandle get; + final MethodHandle set; + final List> bindings = new ArrayList<>(); + final Class type; + final String name; + + /* + * A Binding connects the access class to n worlds that depend on the + * the same value of the model. It keeps a last value and it maintains + * the subscription. + */ + class Binding implements AutoCloseable { + final Target target; + Object lastValue; + AutoCloseable subscription; + + Binding(Target target) { + this.target = target; + subscription = target.subscribe(value -> { + lastValue = value; + toModel(value); + }); + + } + + @SuppressWarnings("unchecked") + void update(Object value) { + if (!Objects.equals(value, lastValue)) { + lastValue = value; + target.set((T) value); + } + } + + @Override + public void close() { + IO.close(subscription); + } + } + + Access(Field field) { + this.name = field.getName(); + this.type = field.getType(); + MethodHandle get = null; + MethodHandle set = null; + field.setAccessible(true); + try { + Method m = modelType.getDeclaredMethod(name); + m.setAccessible(true); + get = lookup.unreflect(m); + } catch (NoSuchMethodException | SecurityException | IllegalAccessException e) { + try { + get = lookup.unreflectGetter(field); + } catch (IllegalAccessException e1) {} + } + try { + Method m = modelType.getDeclaredMethod(name, type); + m.setAccessible(true); + set = lookup.unreflect(m); + } catch (NoSuchMethodException | SecurityException | IllegalAccessException e) { + try { + set = lookup.unreflectSetter(field); + } catch (IllegalAccessException e1) {} + } + assert get != null && set != null; + this.set = set; + this.get = get; + } + + Object fromModel() { + try { + return get.invoke(model); + } catch (Throwable e) { + throw Exceptions.duck(e); + } + } + + void toModel(Object newer) { + try { + set.invoke(model, newer); + trigger(); + } catch (Throwable e) { + throw Exceptions.duck(e); + } + } + + @SuppressWarnings({ + "unchecked", "rawtypes" + }) + void toWorld() { + Object value = fromModel(); + for (Binding binding : bindings) { + binding.update(value); + } + } + + void add(Target target) { + bindings.add(new Binding<>(target)); + } + + @Override + public void close() throws Exception { + bindings.forEach(IO::close); + } + + // test methods + + @SuppressWarnings("resource") + Object last(int i) { + return bindings.get(i).lastValue; + } + + @SuppressWarnings("resource") + Target target(int i) { + return bindings.get(i).target; + } + } + + /** + * An interface that should be implemented by parties that want to get + * updated and can be subscribed to. It is for this UI class the abstraction + * of the world. + *

+ * Although the interface has two methods, the subscribe is default + * implemented as a noop. This makes this interface easy to use as a + * Functional interface and Consumer like lambdas map well to it. + * + * @param the type of the target + */ + public interface Target { + /** + * Set the model value into the world. + * + * @param value the value + */ + void set(T value); + + /** + * Subscribe to changes in the world. + * + * @param subscription the callback to call when the world changes + * @return a closeable that will remove the subscription + */ + default AutoCloseable subscribe(Consumer subscription) { + return () -> {}; + } + + /** + * Subscribe to changes in the world. + * + * @param subscription the callback to call when the world changes + * @return a closeable that will remove the subscription + */ + default AutoCloseable subscribe(Runnable subscription) { + return subscribe(x -> subscription.run()); + } + + /** + * Sometimes the target takes a different type than the model. This + * method will create a mediator that maps the value back and forth. + * + * @param the other type + * @param down the downstream towards the world + * @param up upstream towards the model + * @return another target + */ + default Target map(Function down, Function up) { + Target THIS = this; + return new Target<>() { + + @Override + public void set(U value) { + THIS.set(down.apply(value)); + } + + @Override + public AutoCloseable subscribe(Consumer subscription) { + AutoCloseable subscribed = THIS.subscribe(v -> { + U apply = up.apply(v); + subscription.accept(apply); + }); + return subscribed; + } + }; + } + + } + + /** + * External interface to bind targets to the model. + * + * @param the type + */ + public interface Binder { + Binder bind(Target target); + } + + /** + * Constructor. + * + * @param model the model to use + */ + @SuppressWarnings("unchecked") + public UI(M model) { + this((Class) model.getClass(), model); + } + + /** + * Specify a type to use + * + * @param modelType the model type + * @param model the model + */ + UI(Class modelType, M model) { + this.modelType = modelType; + this.model = model; + + for (Field field : modelType.getDeclaredFields()) { + int mods = field.getModifiers(); + if (Modifier.isStatic(mods) || Modifier.isTransient(mods) || Modifier.isPrivate(mods) + || (field.getModifiers() & 0x00001000) != 0) + continue; + + access.put(field.getName(), new Access(field)); + } + } + + /** + * Create a binder for a given model field. + * + * @param the type of the field + * @param name the name of the field + * @param guard guard to ensure the model field's type matches the targets. + * The value is discarded. + * @return a binder + */ + public Binder u(String name, T guard) { + assert name != null; + Access access = this.access.get(name); + assert access != null : name + " is not a field in the model " + modelType.getSimpleName(); + + return new Binder<>() { + @Override + public Binder bind(Target target) { + access.add(target); + return this; + } + }; + } + + /** + * Bind the given target and return a binder for subsequent targets to bind. + * + * @param the model field's type + * @param name the name of the field + * @param guard guard to ensure the model field's type matches the targets. + * The value is discarded. + * @param target the target to bind + * @return a Binder + */ + public Binder u(String name, T guard, Target target) { + return u(name, guard).bind(target); + } + + /** + * Return a target for a Text widget. This will use + * {@link Text#setText(String)} for {@link Target#set(Object)} and it will + * subscribe to modifications with + * {@link Text#addModifyListener(ModifyListener)} + * + * @param widget the text widget + * @return a target + */ + public static Target text(Text widget) { + return new Target() { + String last; + + @Override + public void set(String value) { + if (!Objects.equals(widget.getText(), value)) { + System.out.println("setting " + widget + " " + value); + last = value; + widget.setText(value); + } + } + + @Override + public AutoCloseable subscribe(Consumer subscription) { + ModifyListener listener = e -> { + String value = widget.getText(); + if (!Objects.equals(last, value)) { + last = value; + System.out.println("event " + widget + " " + widget.getText()); + subscription.accept(widget.getText()); + } + }; + widget.addModifyListener(listener); + return () -> widget.removeModifyListener(listener); + } + }; + } + + /** + * Return a target for a checkbox button. The {@link Target#set(Object)} + * maps to {@link Button#setSelection(boolean)} and the subscription is + * handled via {@link Button#addSelectionListener(SelectionListener)}. + * + * @param widget the widget to map + * @return a target that can set and subscribe the button selection + */ + public static Target checkbox(Button widget) { + return new Target() { + + @Override + public void set(Boolean value) { + widget.setSelection(value); + } + + @Override + public AutoCloseable subscribe(Consumer subscription) { + SelectionListener listener = onSelect(e -> subscription.accept(widget.getSelection())); + widget.addSelectionListener(listener); + return () -> widget.removeSelectionListener(listener); + } + }; + } + + /** + * Map the selection of a CheckboxTableViewer to a Target. It uses + * {@link CheckboxTableViewer#setCheckedElements(Object[])} and the + * subscription is handled via the + * {@link CheckboxTableViewer#addSelectionChangedListener(ISelectionChangedListener)} + * + * @param widget the CheckboxTableViewer + * @return a new Target + */ + public static Target widget(CheckboxTableViewer widget) { + return new Target<>() { + + @Override + public void set(Object[] value) { + widget.setCheckedElements(value); + } + + @Override + public AutoCloseable subscribe(Consumer subscription) { + ISelectionChangedListener listener = se -> { + subscription.accept(widget.getCheckedElements()); + }; + widget.addSelectionChangedListener(listener); + return () -> widget.removeSelectionChangedListener(listener); + } + }; + } + + /** + * Create a selection listener with a lambda for the selection and the + * default selection + * + * @param listener the listener + * @param defaultListener the listener to default + * @return a proper listener + */ + public static SelectionListener onSelect(Consumer listener, + Consumer defaultListener) { + return new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + listener.accept(e); + } + + @Override + public void widgetDefaultSelected(SelectionEvent e) { + defaultListener.accept(e); + } + }; + } + + /** + * Create a selection listener with the same lambda for the selection and + * the default selection + * + * @param listener the listener + * @return a proper listener + */ + public static SelectionListener onSelect(Consumer listener) { + return new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + listener.accept(e); + } + }; + } + + @Override + public void close() throws Exception { + + } + + /** + * Updates to the model should be handled in here. The runnable should be + * very short since it runs in a lock that is acquired in the display + * thread. + * + * @param r the runnable to execute that updates the model + * @return a CountDownLatch that will unlatch when the write has updated the + * world. + */ + public CountDownLatch write(Runnable r) { + synchronized (lock) { + r.run(); + return trigger(); + } + } + + /** + * A read in the same lock as the write but without the world update. The + * supplier's value is returned. + * + * @param the type the return + * @param r the supplier + * @return the value returned from the supllier + */ + public X read(Supplier r) { + synchronized (lock) { + return r.get(); + } + } + + /** + * Trigger a world update. This will delay 50 ms to coalesce additional + * updates. If during the update of the world (which is done without holding + * a lock) there is another change, the update will be repeated. + * + * @return a {@link CountDownLatch} that will be unlatched when the state of + * the model at this moment is represented in the world. + */ + public CountDownLatch trigger() { + synchronized (lock) { + lock.version++; + if (lock.updated == null) { + lock.updated = new CountDownLatch(1); + scheduler.schedule(() -> { + while (true) { + int current; + synchronized (lock) { + current = lock.version; + } + + try { + dispatch(); + } catch (Exception e) { + log.error("failed to update model to world {}", e, e); + } + + synchronized (lock) { + if (current == lock.version) { + lock.updated.countDown(); + lock.updated = null; + return; + } + } + } + }, 50, TimeUnit.MILLISECONDS); + } + return lock.updated; + } + } + + /** + * This method is Eclispe SWT specific. It dispatches the updates on the UI + * thread when there are no events present. + */ + void dispatch() { + Display display = Display.getDefault(); + display.asyncExec(() -> { + while (!display.isDisposed()) { + if (!display.readAndDispatch()) { + update(); + return; + } + } + }); + } + + /** + * Copy the model to the world. + */ + public void update() { + if (model instanceof Runnable r) { + r.run(); + } + + for (Access access : this.access.values()) { + access.toWorld(); + } + } +} diff --git a/bndtools.core/src/bndtools/wizards/newworkspace/Model.java b/bndtools.core/src/bndtools/wizards/newworkspace/Model.java new file mode 100644 index 0000000000..4ab95e4090 --- /dev/null +++ b/bndtools.core/src/bndtools/wizards/newworkspace/Model.java @@ -0,0 +1,165 @@ +package bndtools.wizards.newworkspace; + +import java.io.File; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IWorkspace; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.PlatformUI; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import aQute.bnd.build.Workspace; +import aQute.bnd.wstemplates.FragmentTemplateEngine; +import aQute.bnd.wstemplates.FragmentTemplateEngine.TemplateInfo; +import aQute.bnd.wstemplates.FragmentTemplateEngine.TemplateUpdater; +import aQute.lib.io.IO; + +public class Model implements Runnable { + static final Logger log = LoggerFactory.getLogger(Model.class); + static final IWorkspace ECLIPSE_WORKSPACE = ResourcesPlugin.getWorkspace(); + static final IWorkspaceRoot ROOT = ECLIPSE_WORKSPACE.getRoot(); + static final IPath ROOT_LOCATION = ROOT.getLocation(); + static final URI TEMPLATE_HOME = URI.create("https:://github.com/bndtools/workspace"); + + final static File current = ROOT_LOCATION.toFile(); + + enum NewWorkspaceType { + newbnd, + derive, + classic + } + + File location = getUniqueWorkspaceName(); + boolean clean = false; + boolean updateWorkspace = false; + boolean switchWorkspace = true; + List templates = new ArrayList<>(); + List selectedTemplates = new ArrayList<>(); + Progress validatedUrl = Progress.init; + String urlValidationError; + String error; + String valid; + NewWorkspaceType choice = NewWorkspaceType.newbnd; + + enum Progress { + init, + start, + finished, + error + } + + boolean isValid() { + String valid; + if (location.isFile()) { + valid = "the location " + location + " is not a directory"; + } else if (location.equals(current) && !updateWorkspace) { + valid = "selected the current workspace, select another directory"; + } else if (!clean && !updateWorkspace && !getDataFiles().isEmpty()) { + valid = "the target location contains files, set delete files to delete them"; + } else { + valid = null; + } + this.valid = valid; + return valid != null; + } + + List getDataFiles() { + if (!location.isDirectory()) + return Collections.emptyList(); + + return Stream.of(location) + .filter(f -> { + if (f.getName() + .equals(".metadata")) + return false; + + return true; + }) + .toList(); + } + + boolean execute(TemplateUpdater updater) { + Display display = PlatformUI.getWorkbench() + .getDisplay(); + + Job job = Job.create("create workspace", mon -> { + try { + if (clean) { + getDataFiles().forEach(IO::delete); + } + if (!updateWorkspace) { + location.mkdirs(); + File b = IO.getFile(location, "cnf/build.bnd"); + b.getParentFile() + .mkdirs(); + + IO.store("", b); + } + updater.commit(); + + if (updateWorkspace) { + IResource workspaceRoot = ResourcesPlugin.getWorkspace() + .getRoot(); + workspaceRoot.refreshLocal(IResource.DEPTH_INFINITE, null); + } else if (switchWorkspace) { + display.asyncExec(() -> { + System.setProperty("osgi.instance.area", location.getAbsolutePath()); + System.setProperty("osgi.instance.area.default", location.getAbsolutePath()); + + PlatformUI.getWorkbench() + .restart(); + }); + } + } catch (Exception e) { + log.error("creating new workspace {}", e, e); + } + }); + job.schedule(); + return true; + } + + void updateWorkspace(boolean useEclipse) { + if (useEclipse != updateWorkspace) { + updateWorkspace = useEclipse; + if (useEclipse) { + location = current; + } else { + location = getUniqueWorkspaceName(); + } + } + } + + static File getUniqueWorkspaceName() { + return IO.unique(IO.getFile("~/workspace"), null); + } + + public void location(File file) { + location = file; + } + + public void clean(boolean selection) { + clean = selection; + } + + void selectedTemplates(List list) { + selectedTemplates = list; + } + + @Override + public void run() { + isValid(); + } + + void init(FragmentTemplateEngine templateFragments, Workspace workspace) {} + +} diff --git a/bndtools.core/src/bndtools/wizards/newworkspace/NewWorkspaceWizard.java b/bndtools.core/src/bndtools/wizards/newworkspace/NewWorkspaceWizard.java new file mode 100644 index 0000000000..fa89131c13 --- /dev/null +++ b/bndtools.core/src/bndtools/wizards/newworkspace/NewWorkspaceWizard.java @@ -0,0 +1,261 @@ +package bndtools.wizards.newworkspace; + +import java.io.File; +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.stream.Stream; + +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.CheckboxTableViewer; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.ColumnWeightData; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.TableLayout; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.jface.window.Window; +import org.eclipse.jface.wizard.Wizard; +import org.eclipse.jface.wizard.WizardPage; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.DirectoryDialog; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.Text; +import org.eclipse.ui.IImportWizard; +import org.eclipse.ui.INewWizard; +import org.eclipse.ui.IWorkbench; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import aQute.bnd.build.Workspace; +import aQute.bnd.exceptions.Exceptions; +import aQute.bnd.header.Parameters; +import aQute.bnd.result.Result; +import aQute.bnd.wstemplates.FragmentTemplateEngine; +import aQute.bnd.wstemplates.FragmentTemplateEngine.TemplateInfo; +import aQute.bnd.wstemplates.FragmentTemplateEngine.TemplateUpdater; +import bndtools.central.Central; +import bndtools.util.ui.UI; + +/** + * Create a new Workspace Wizard. + */ +public class NewWorkspaceWizard extends Wizard implements IImportWizard, INewWizard { + static final String DEFAULT_INDEX = "https://raw.githubusercontent.com/bndtools/workspace-templates/master/index.bnd"; + static final Logger log = LoggerFactory.getLogger(NewWorkspaceWizard.class); + + final Model model = new Model(); + final UI ui = new UI<>(model); + final NewWorkspaceWizardPage page = new NewWorkspaceWizardPage(); + final FragmentTemplateEngine templates; + + public NewWorkspaceWizard() throws Exception { + setWindowTitle("Create New bnd Workspace"); + Workspace workspace = Central.getWorkspace(); + templates = new FragmentTemplateEngine(workspace); + try { + Job job = Job.create("load index", mon -> { + try { + templates.read(new URL(DEFAULT_INDEX)) + .unwrap() + .forEach(templates::add); + Parameters p = workspace.getMergedParameters("-workspace-template"); + templates.read(p) + .forEach(templates::add); + ui.write(() -> model.templates = templates.getAvailableTemplates()); + } catch (Exception e) { + log.error("failed to read default index {}", e, e); + } + }); + job.schedule(); + } catch (Throwable e) { + log.error("initialization {}", e, e); + throw Exceptions.duck(e); + } + } + + @Override + public void addPages() { + addPage(page); + } + + @Override + public boolean performFinish() { + if (model.valid == null) { + ui.write(() -> { + TemplateUpdater updater = templates.updater(model.location, model.selectedTemplates); + model.execute(updater); + }); + return true; + } else + return false; + } + + class NewWorkspaceWizardPage extends WizardPage { + NewWorkspaceWizardPage() { + super("New Workspace"); + setTitle("Create New Workspace"); + setDescription("Specify the workspace details."); + } + + @Override + public void createControl(Composite parent) { + Composite container = new Composite(parent, SWT.NONE); + setControl(container); + container.setLayout(new GridLayout(8, false)); + + Button useEclipseWorkspace = new Button(container, SWT.CHECK); + useEclipseWorkspace.setText("Update current Eclipse workspace"); + useEclipseWorkspace.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false, 8, 1)); + + Label locationLabel = new Label(container, SWT.NONE); + locationLabel.setText("Location"); + locationLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, false, false, 8, 1)); + + Text location = new Text(container, SWT.BORDER); + location.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 6, 1)); + + Button browseButton = new Button(container, SWT.PUSH); + browseButton.setText("Browse..."); + browseButton.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, false, false, 2, 1)); + + Button clean = new Button(container, SWT.CHECK); + clean.setText("Clean the directory"); + clean.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false, 8, 1)); + + Button switchWorkspace = new Button(container, SWT.CHECK); + switchWorkspace.setText("Switch to new workspace after finish"); + switchWorkspace.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false, 8, 1)); + + CheckboxTableViewer selectedTemplates = CheckboxTableViewer.newCheckList(container, + SWT.BORDER | SWT.FULL_SELECTION); + selectedTemplates.setContentProvider(ArrayContentProvider.getInstance()); + Table table = selectedTemplates.getTable(); + table.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true, 6, 10)); + TableLayout tableLayout = new TableLayout(); + table.setLayout(tableLayout); + table.setHeaderVisible(true); + + TableViewerColumn nameColumn = new TableViewerColumn(selectedTemplates, SWT.NONE); + nameColumn.getColumn() + .setText("Name"); + nameColumn.setLabelProvider(new ColumnLabelProvider() { + + @Override + public String getText(Object element) { + if (element instanceof TemplateInfo) { + return ((TemplateInfo) element).name(); + } + return super.getText(element); + } + }); + + TableViewerColumn descriptionColumn = new TableViewerColumn(selectedTemplates, SWT.NONE); + descriptionColumn.getColumn() + .setText("Description"); + descriptionColumn.setLabelProvider(new ColumnLabelProvider() { + + @Override + public String getText(Object element) { + if (element instanceof TemplateInfo) { + return ((TemplateInfo) element).description(); + } + return super.getText(element); + } + }); + tableLayout.addColumnData(new ColumnWeightData(1, 80, false)); + tableLayout.addColumnData(new ColumnWeightData(10, 200, true)); + + Button addButton = new Button(container, SWT.PUSH); + addButton.setText("+"); + addButton.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false, 2, 1)); + + ui.u("location", model.location, UI.text(location) + .map(File::getAbsolutePath, File::new)); + ui.u("clean", model.clean, UI.checkbox(clean)); + ui.u("updateWorkspace", model.updateWorkspace, UI.checkbox(useEclipseWorkspace)) + .bind(v -> location.setEnabled(!v)) + .bind(v -> browseButton.setEnabled(!v)) + .bind(v -> switchWorkspace.setEnabled(!v)) + .bind(v -> clean.setEnabled(!v)) + .bind(v -> setTitle(v ? "Update Workspace" : "Create New Workspace")) + .bind(v -> setWindowTitle(v ? "Update Workspace" : "Create New Workspace")); + + ui.u("valid", model.valid, this::setErrorMessage); + ui.u("error", model.error, this::setErrorMessage); + ui.u("valid", model.valid, v -> setPageComplete(v == null)); + ui.u("switchWorkspace", model.switchWorkspace, UI.checkbox(switchWorkspace)); + ui.u("templates", model.templates, l -> selectedTemplates.setInput(l.toArray())); + ui.u("selectedTemplates", model.selectedTemplates, UI.widget(selectedTemplates) + .map(List::toArray, this::toTemplates)); + UI.checkbox(addButton) + .subscribe(this::addTemplate); + UI.checkbox(browseButton) + .subscribe(this::browseForLocation); + + ui.update(); + } + + List toTemplates(Object[] selection) { + return Stream.of(selection) + .map(o -> (TemplateInfo) o) + .toList(); + } + + void browseForLocation() { + DirectoryDialog dialog = new DirectoryDialog(getShell()); + dialog.setFilterPath(model.location.getAbsolutePath()); + String path = dialog.open(); + if (path != null) { + ui.write(() -> model.location(new File(path))); + } + } + + void addTemplate() { + TemplateDefinitionDialog dialog = new TemplateDefinitionDialog(getShell()); + if (dialog.open() == Window.OK) { + String selectedPath = dialog.getSelectedPath(); + if (!selectedPath.isBlank()) { + Job job = Job.create("read " + selectedPath, mon -> { + try { + URI uri = toURI(selectedPath); + Result> result = templates.read(uri.toURL()); + + if (result.isErr()) { + ui.write(() -> model.error = result.toString()); + } else { + result.unwrap() + .forEach(templates::add); + ui.write(() -> model.templates = templates.getAvailableTemplates()); + } + } catch (Exception e) { + ui.write(() -> model.error = "failed to add the index: " + e); + } + }); + job.schedule(); + } + } + + } + + URI toURI(String path) { + URI uri; + File f = new File(path); + if (f.isFile()) { + uri = f.toURI(); + } else { + uri = URI.create(path); + } + return uri; + } + } + + @Override + public void init(IWorkbench workbench, IStructuredSelection selection) {} + +} diff --git a/bndtools.core/src/bndtools/wizards/newworkspace/TemplateDefinitionDialog.java b/bndtools.core/src/bndtools/wizards/newworkspace/TemplateDefinitionDialog.java new file mode 100644 index 0000000000..a209807bb0 --- /dev/null +++ b/bndtools.core/src/bndtools/wizards/newworkspace/TemplateDefinitionDialog.java @@ -0,0 +1,66 @@ +package bndtools.wizards.newworkspace; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +import bndtools.util.ui.UI; + +/** + * Asks for a url or file path + */ +class TemplateDefinitionDialog extends Dialog { + final UI ui = new UI<>(this); + String path; + + public TemplateDefinitionDialog(Shell parentShell) { + super(parentShell); + } + + @Override + protected void configureShell(Shell newShell) { + super.configureShell(newShell); + newShell.setText("Template Definitions"); + } + + @Override + protected Composite createDialogArea(Composite parent) { + Composite container = (Composite) super.createDialogArea(parent); + GridLayout layout = new GridLayout(12, false); + container.setLayout(layout); + + Label label = new Label(container, SWT.NONE); + label.setText("Template definitions. You can enter a URL or a file path"); + label.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, false, false, 12, 1)); + + Text textField = new Text(container, SWT.BORDER); + GridData textFieldLayoutData = new GridData(SWT.FILL, SWT.CENTER, true, false, 11, 1); + textFieldLayoutData.minimumWidth = 200; + textField.setLayoutData(textFieldLayoutData); + + Button browseButton = new Button(container, SWT.PUSH); + browseButton.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, false, false, 1, 1)); + browseButton.setText("Browse..."); + ui.u("path", path, UI.text(textField)); + browseButton.addSelectionListener(UI.onSelect(x -> browseForFile())); + return container; + } + + private void browseForFile() { + FileDialog dialog = new FileDialog(getShell()); + String path = dialog.open(); + ui.write(() -> this.path = path); + } + + public String getSelectedPath() { + return path; + } + +} diff --git a/bndtools.core/test/bndtools/util/ui/UITest.java b/bndtools.core/test/bndtools/util/ui/UITest.java new file mode 100644 index 0000000000..786321a510 --- /dev/null +++ b/bndtools.core/test/bndtools/util/ui/UITest.java @@ -0,0 +1,220 @@ +package bndtools.util.ui; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import bndtools.util.ui.UI.Access; +import bndtools.util.ui.UI.Target; + +class UITest { + + @SuppressWarnings("rawtypes") + @Test + void test() throws Exception { + class M { + boolean vm; + } + class W { + int n = 100; + final List> subs = new ArrayList<>(); + boolean vw; + + void set(boolean v) { + vw = v; + subs.forEach(x -> x.accept(v)); + n++; + } + + AutoCloseable subscribe(Consumer sub) { + subs.add(sub); + return () -> subs.remove(sub); + } + } + M model = new M(); + W world_1 = new W(); + W world_2 = new W(); + + try (UI ui = new UI<>(model) { + public void dispatch() { + update(); + } + }) { + Target target_1 = new Target<>() { + + @Override + public void set(Boolean value) { + world_1.set(value); + } + + public AutoCloseable subscribe(Consumer subscription) { + return world_1.subscribe(subscription); + } + }; + Target target_2 = new Target<>() { + + @Override + public void set(Boolean value) { + world_2.set(value); + } + + public AutoCloseable subscribe(Consumer subscription) { + return world_2.subscribe(subscription); + } + }; + + ui.u("vm", model.vm) + .bind(target_1) + .bind(target_2); + + Access access = ui.access.get("vm"); + assertThat(access.last(0)).isNull(); + + System.out.println("write to model true, check world update"); + ui.write(() -> model.vm = true) + .await(); + assertThat(access.last(0)).isEqualTo(Boolean.TRUE); + assertThat(access.last(1)).isEqualTo(Boolean.TRUE); + assertThat(world_1.vw).isTrue(); + assertThat(world_1.n).isEqualTo(101); + assertThat(world_2.vw).isTrue(); + assertThat(world_2.n).isEqualTo(101); + + System.out.println("write to model false, check world update"); + ui.write(() -> model.vm = false) + .await(); + assertThat(access.last(0)).isEqualTo(Boolean.FALSE); + assertThat(access.last(1)).isEqualTo(Boolean.FALSE); + assertThat(world_1.vw).isFalse(); + assertThat(world_1.n).isEqualTo(102); + assertThat(world_2.vw).isFalse(); + assertThat(world_2.n).isEqualTo(102); + + System.out.println("write to model false, check no world"); + ui.write(() -> model.vm = false) + .await(); + assertThat(access.last(0)).isEqualTo(Boolean.FALSE); + assertThat(access.last(1)).isEqualTo(Boolean.FALSE); + assertThat(world_1.vw).isFalse(); + assertThat(world_1.n).isEqualTo(102); + assertThat(world_2.vw).isFalse(); + assertThat(world_2.n).isEqualTo(102); + assertThat(model.vm).isFalse(); + + System.out.println("write to world 1 true, check other world 2 update"); + assertThat(model.vm).isFalse(); + int v = ui.lock.version; + world_1.set(true); + assertThat(model.vm).isTrue(); + CountDownLatch cd = ui.lock.updated; + assertThat(cd).isNotNull(); + assertThat(world_1.vw).isTrue(); + assertThat(world_1.n).isEqualTo(103); + assertThat(world_2.vw).isFalse(); + assertThat(world_2.n).isEqualTo(102); + assertThat(access.last(0)).isEqualTo(Boolean.TRUE); + assertThat(access.last(1)).isEqualTo(Boolean.FALSE); + cd.await(); + + assertThat(world_1.vw).isTrue(); + assertThat(world_1.n).isEqualTo(103); + assertThat(world_2.vw).isTrue(); + assertThat(world_2.n).isEqualTo(103); + assertThat(access.last(0)).isEqualTo(Boolean.TRUE); + assertThat(access.last(1)).isEqualTo(Boolean.TRUE); + assertThat(model.vm).isTrue(); + + } + + } + + @SuppressWarnings("rawtypes") + @Test + void testMethodField() throws Exception { + class M { + int n = 100; + boolean vm; + + @SuppressWarnings("unused") + void vm(boolean value) { + vm = value; + n++; + } + } + M model = new M(); + + try (UI ui = new UI<>(model) { + public void dispatch() { + update(); + } + }) { + AtomicBoolean world = new AtomicBoolean(false); + ui.u("vm", model.vm) + .bind(world::set); + + Access access = ui.access.get("vm"); + access.toModel(true); + assertThat(model.n).isEqualTo(101); + } + + } + + @SuppressWarnings("rawtypes") + @Test + void testMapping() throws Exception { + class M { + boolean vm; + } + M model = new M(); + class W implements Target { + final List> subs = new ArrayList<>(); + String vw; + + @Override + public void set(String value) { + vw = value; + subs.forEach(c -> c.accept(value)); + } + + @Override + public AutoCloseable subscribe(Consumer subscription) { + subs.add(subscription); + return () -> subs.remove(subscription); + } + } + W world = new W(); + + try (UI ui = new UI<>(model) { + public void dispatch() { + update(); + } + }) { + + ui.u("vm", model.vm) + .bind(world.map(b -> Boolean.toString(b), Boolean::valueOf)); + + ui.write(() -> model.vm = true) + .await(); + assertThat(world.vw).isEqualTo("true"); + + ui.write(() -> model.vm = false) + .await(); + assertThat(world.vw).isEqualTo("false"); + + world.set("true"); + ui.lock.updated.await(); + assertThat(model.vm).isEqualTo(true); + + world.set("false"); + ui.lock.updated.await(); + assertThat(model.vm).isEqualTo(false); + } + + } +}