diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 9c6cc746c3..6beb4befd8 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,8 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/1784[#1784]: GUI now supports displaying progress bars + * https://github.com/devonfw/IDEasy/issues/1937[#1937]: Link content from settings into workspaces * https://github.com/devonfw/IDEasy/issues/1056[#1056]: Improve MSI license display by generating formatted RTF from AsciiDoc * https://github.com/devonfw/IDEasy/issues/2052[#2052]: Split issue statistics in 2 pie-charts @@ -28,7 +30,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1985[#1985]: Fix path traversal errors when extracting ZIPs on Windows * https://github.com/devonfw/IDEasy/issues/1255[#1255]: Enhance snapshot version recognition in IDEasy * https://github.com/devonfw/IDEasy/issues/1964[#1964]: Fixed gui not launching with older project java versions -* https://github.com/devonfw/IDEasy/issues/1968[#1968]: Fixed gui blocking the terminal session while running. +* https://github.com/devonfw/IDEasy/issues/1968[#1968]: Fixed gui blocking the terminal session while running * https://github.com/devonfw/IDEasy/issues/1849[#1849]: Add VSCodium support * https://github.com/devonfw/IDEasy/issues/1391[#1391]: Fix bashrc messed with terraform completions * https://github.com/devonfw/IDEasy/issues/1922[#1922]: Add Task CLI to IDEasy commandlets diff --git a/gui/src/main/java/com/devonfw/ide/gui/App.java b/gui/src/main/java/com/devonfw/ide/gui/App.java index f6f715fe7d..5a61763f8c 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/App.java +++ b/gui/src/main/java/com/devonfw/ide/gui/App.java @@ -7,6 +7,7 @@ import javafx.geometry.Rectangle2D; import javafx.scene.Parent; import javafx.scene.Scene; +import javafx.scene.control.ButtonType; import javafx.scene.image.Image; import javafx.stage.Screen; import javafx.stage.Stage; @@ -14,6 +15,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.devonfw.ide.gui.context.GuiStateManager; +import com.devonfw.ide.gui.context.TaskManager; import com.devonfw.ide.gui.modal.IdeDialog; import com.devonfw.tools.ide.variable.IdeVariables; import com.devonfw.tools.ide.version.IdeVersion; @@ -31,14 +34,19 @@ public class App extends Application { public void start(Stage primaryStage) throws IOException { Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { - LOG.error("Uncaught exception in thread {}: {}", thread.getName(), throwable.getMessage(), throwable); + System.out.println("Uncaught exception in thread " + thread.getName() + ":" + throwable.getMessage()); + + //Left this in, because of issues with printing errors when the Fx Application Thread crashes. Needs further research. + throwable.printStackTrace(); Platform.runLater(() -> new IdeDialog(IdeDialog.AlertType.ERROR, throwable.getMessage()).showAndWait()); } ); + TaskManager taskManager = new TaskManager(); + GuiStateManager guiStateManager = new GuiStateManager(taskManager, null); FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource("main-view.fxml")); fxmlLoader.setController( - new MainController(System.getenv(IdeVariables.IDE_ROOT.getName())) + new MainController(System.getenv(IdeVariables.IDE_ROOT.getName()), guiStateManager) ); root = fxmlLoader.load(); @@ -52,6 +60,30 @@ public void start(Stage primaryStage) throws IOException { primaryStage.setMinWidth(scene.getWidth()); primaryStage.setMinHeight(scene.getHeight()); primaryStage.show(); + + primaryStage.setOnCloseRequest(event -> { + + LOG.info("Closing application"); + if (!taskManager.getTasks().isEmpty()) { + IdeDialog closeConfirm = new IdeDialog(IdeDialog.AlertType.CONFIRMATION, "There are still running tasks. Are you sure you want to exit?", + ButtonType.CLOSE, ButtonType.CANCEL); + closeConfirm.showAndWait().ifPresent(response -> { + if (response == ButtonType.CLOSE) { + exitApplication(); + } else { + event.consume(); + } + }); + } else { + exitApplication(); + } + }); + } + + private void exitApplication() { + + Platform.exit(); + System.exit(0); } diff --git a/gui/src/main/java/com/devonfw/ide/gui/AppLauncher.java b/gui/src/main/java/com/devonfw/ide/gui/AppLauncher.java index 916f511b98..2c27a0fcb1 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/AppLauncher.java +++ b/gui/src/main/java/com/devonfw/ide/gui/AppLauncher.java @@ -6,8 +6,7 @@ */ public class AppLauncher { - @SuppressWarnings("MissingJavadoc") - public static void main(final String[] args) { + static void main(final String[] args) { App.main(args); } diff --git a/gui/src/main/java/com/devonfw/ide/gui/FxHelper.java b/gui/src/main/java/com/devonfw/ide/gui/FxHelper.java new file mode 100644 index 0000000000..4ba4ad1eaf --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/FxHelper.java @@ -0,0 +1,24 @@ +package com.devonfw.ide.gui; + +import javafx.application.Platform; + +/** + * Helper class containing tools for interacting with JavaFX + */ +public class FxHelper { + + /** + * Allows running operations on the Fx Application Thread, but only if the call is originating from the Fx Application Thread. Idea: Some tasks that are doing + * (potentially heavy) background work might not originate from the Fx Application (UI) Thread but will still interact with javafx observable collections. + * + * @param runnable code to execute + */ + public static void runFxSafe(Runnable runnable) { + if (Platform.isFxApplicationThread()) { + runnable.run(); + } else { + Platform.runLater(runnable); + } + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/MainController.java b/gui/src/main/java/com/devonfw/ide/gui/MainController.java index f94ab1b23f..ae30a6078e 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/MainController.java +++ b/gui/src/main/java/com/devonfw/ide/gui/MainController.java @@ -4,25 +4,36 @@ import java.nio.file.NotDirectoryException; import java.nio.file.Path; import java.util.List; +import javafx.application.Platform; +import javafx.collections.ListChangeListener; +import javafx.concurrent.Task; import javafx.fxml.FXML; +import javafx.scene.control.Alert.AlertType; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.ide.gui.context.IdeGuiStateManager; +import com.devonfw.ide.gui.context.GuiStateManager; import com.devonfw.ide.gui.context.ProjectManager; +import com.devonfw.ide.gui.context.TaskManager; import com.devonfw.ide.gui.modal.IdeDialog; +import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindow; /** * Controller of the main screen of the dashboard GUI. */ public class MainController { - private static Logger LOG = LoggerFactory.getLogger(MainController.class); + private static final Logger LOG = LoggerFactory.getLogger(MainController.class); - private ProjectManager projectManager; + private final GuiStateManager guiStateManager; + private final ProjectManager projectManager; + private final TaskManager taskManager; @FXML @@ -43,19 +54,60 @@ public class MainController { @FXML private Button vsCodeOpen; - private final String directoryPath; + @FXML + private Label statusLabel; + + @FXML + private ProgressBar statusProgressBar; + private final double PROGRESSBAR_VISIBLE_WIDTH = 150.0; + + private final String ideRootPath; private Path projectValue; private Path workspaceValue; /** * Constructor + * + * @param ideRoot the IDE_ROOT path + * @param guiStateManager the {@link GuiStateManager} to be used in this application instance */ - public MainController(String directoryPath) { + public MainController(String ideRoot, GuiStateManager guiStateManager) { - LOG.debug("IDE_ROOT path={}", directoryPath); - this.directoryPath = directoryPath; + LOG.debug("IDE_ROOT path={}", ideRoot); + this.ideRootPath = ideRoot; + this.guiStateManager = guiStateManager; + this.taskManager = guiStateManager.getTaskManager(); + this.projectManager = guiStateManager.getProjectManager(); - this.projectManager = IdeGuiStateManager.getInstance().getProjectManager(); + setUpTaskListListener(); + } + + private void setUpTaskListListener() { + + ListChangeListener taskListChangeListener = change -> { + List tasks = taskManager.getTasks(); + + while (change.next()) { + if (change.wasAdded()) { + LOG.debug("Added: {}", change.getAddedSubList()); + + for (ProgressBarTask product : change.getAddedSubList()) { + product.currentProgressProperty().addListener((_, _, _) -> + updateStatusLabel(tasks) + ); + } + updateStatusLabel(tasks); + } else if (change.wasRemoved()) { + LOG.debug("Removed: {}", change.getRemoved()); + + updateStatusLabel(tasks); + } else if (change.wasUpdated()) { + + updateStatusLabel(tasks); + } + } + }; + taskManager.getTasks().addListener(taskListChangeListener); } @FXML @@ -90,7 +142,7 @@ private void openVsCode() { private void setProjectsComboBox() { - assert (directoryPath != null) : "directoryPath is null! Please check the setup of your environment variables (IDE_ROOT)"; + assert (ideRootPath != null) : "directoryPath is null! Please check the setup of your environment variables (IDE_ROOT)"; List projects = projectManager.getProjectNames(); @@ -107,7 +159,7 @@ private void setProjectsComboBox() { private void setWorkspaceComboBox() { - List workspaces = null; + List workspaces; try { workspaces = projectManager.getWorkspaceNames(selectedProject.getValue()); } catch (NotDirectoryException e) { @@ -129,20 +181,89 @@ private void setWorkspaceComboBox() { private void openIDE(String inIde) { - IdeGuiStateManager - .getInstance() - .getCurrentContext() - .getCommandletManager() - .getCommandlet(inIde) - .run(); + Task downloadTask = runIdeCommandTask(inIde); + + new Thread(downloadTask).start(); + } + + private Task runIdeCommandTask(String inIde) { + + try (ProgressBarTask task = (ProgressBarTask) guiStateManager.getCurrentContext() + .newProgressBarIndeterminate("Starting " + inIde)) { + Task downloadTask = new Task<>() { + @Override + protected Void call() { + guiStateManager + .getCurrentContext() + .getCommandletManager() + .getCommandlet(inIde) + .run(); + task.close(); + return null; + } + }; + + downloadTask.setOnFailed(_ -> Platform.runLater(() -> { + task.close(); + IdeDialog errorDialog = new IdeDialog(AlertType.ERROR, "Error occurred while launching " + inIde); + errorDialog.showAndWait(); + })); + return downloadTask; + } } private void updateContext(String selectedProjectName, String selectedWorkspaceName) { + try { - IdeGuiStateManager.getInstance().switchContext(selectedProjectName, selectedWorkspaceName); + guiStateManager.switchContext(selectedProjectName, selectedWorkspaceName); } catch (FileNotFoundException e) { - IdeDialog errorDialog = new IdeDialog(IdeDialog.AlertType.ERROR, e.getMessage()); + IdeDialog errorDialog = new IdeDialog(AlertType.ERROR, e.getMessage()); errorDialog.showAndWait(); } } + + private void updateStatusLabel(List taskList) { + + Platform.runLater(() -> { + + if (taskList.size() > 1) { + statusLabel.setOnMouseClicked(e -> TaskOverviewWindow.getInstance(taskManager).showRelativeToReferenceNode(statusLabel)); + + statusProgressBar.setVisible(false); + statusProgressBar.setPrefWidth(0); + statusLabel.setText(taskList.size() + " tasks running..."); + + statusLabel.setUnderline(true); + statusLabel.setStyle( + "-fx-text-fill: blue;" + + "-fx-cursor: hand" + ); + } else if (taskList.size() == 1) { + statusLabel.setOnMouseClicked(null); + + ProgressBarTask task = taskList.getFirst(); + statusLabel.setText(String.format( + ProgressBarTask.TASK_DESCRIPTION_STRING_FORMAT, + task.getTitle(), + task.getCurrentProgress(), + task.getMaxSize(), + task.getUnitName()) + ); + statusLabel.setUnderline(false); + statusLabel.setStyle(""); + + statusProgressBar.setVisible(true); + statusProgressBar.setPrefWidth(PROGRESSBAR_VISIBLE_WIDTH); + statusProgressBar.setProgress((double) (task.getCurrentProgress()) / task.getMaxSize()); + } else { + statusLabel.setOnMouseClicked(null); + statusLabel.setText("IDEasy is ready."); + statusProgressBar.setVisible(false); + statusProgressBar.setPrefWidth(0); + + statusLabel.setUnderline(false); + statusLabel.setStyle(""); + } + }); + } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiStateManager.java b/gui/src/main/java/com/devonfw/ide/gui/context/GuiStateManager.java similarity index 59% rename from gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiStateManager.java rename to gui/src/main/java/com/devonfw/ide/gui/context/GuiStateManager.java index 070b0e90a0..91e5789e99 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiStateManager.java +++ b/gui/src/main/java/com/devonfw/ide/gui/context/GuiStateManager.java @@ -11,16 +11,17 @@ import com.devonfw.tools.ide.context.IdeStartContextImpl; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.log.IdeLogListenerBuffer; +import com.devonfw.tools.ide.variable.IdeVariables; /** * This class has the purpose of enabling the context state management for the IDEasy GUI. It is a thread-safe singleton implementation (Bill Pugh Singleton). */ -public class IdeGuiStateManager { +public class GuiStateManager { - private static final Logger LOG = LoggerFactory.getLogger(IdeGuiStateManager.class); + private static final Logger LOG = LoggerFactory.getLogger(GuiStateManager.class); - private Path ideRootDir; - private ProjectManager projectManager; + private final Path ideRootDir; + private final ProjectManager projectManager; private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); @@ -34,52 +35,21 @@ public class IdeGuiStateManager { */ private final IdeStartContextImpl startContext; - private IdeGuiStateManager() { - - final IdeLogListenerBuffer buffer = new IdeLogListenerBuffer(); - IdeLogLevel logLevel = IdeLogLevel.DEBUG; - startContext = new IdeStartContextImpl(logLevel, buffer); - } + private final TaskManager taskManager; /** - * @return the singleton instance of the {@link IdeGuiStateManager}. + * @param taskManager the {@link TaskManager} that manages any running tasks in the GUI. + * @param ideRoot the root directory of IDEasy. If null, the IDE_ROOT environment variable is used. */ - public static IdeGuiStateManager getInstance() { - - IdeGuiStateManager instance = Holder.INSTANCE; - if (instance.ideRootDir == null) { - String ideRoot = System.getenv("IDE_ROOT"); - if (ideRoot == null) { - throw new IllegalStateException("IDE_ROOT environment variable is not set!"); - } - instance.ideRootDir = Path.of(ideRoot); - instance.projectManager = new ProjectManager(instance.ideRootDir); - } - return Holder.INSTANCE; - } + public GuiStateManager(TaskManager taskManager, String ideRoot) { - /** - * USE WITH CARE. - * This method is used in cases where the IDE_ROOT environment variable is not set, e.g. in test contexts on GitHub actions. This method will retrieve the - * current instance, set the project directory manually an then return the updated instance. - * - * @param ideRoot root directory for the ide projects. - * @return the singleton instance of the {@link IdeGuiStateManager}. - */ - //TODO: remove this method once we have a better solution for the test context, after implementing CLi's test jar. - public static IdeGuiStateManager getInstanceOverrideRootDir(String ideRoot) { - LOG.warn("Using unsafe getInstanceOverrideRootDir method."); - - IdeGuiStateManager instance = Holder.INSTANCE; - if (ideRoot == null) { - throw new IllegalArgumentException("ideRoot must not be null!"); - } else if (instance.ideRootDir != null) { - LOG.warn("ideRootDir is already set. You are overriding it."); - } + this.taskManager = taskManager; + this.ideRootDir = Path.of(ideRoot != null ? ideRoot : System.getenv(IdeVariables.IDE_ROOT.getName())); + this.projectManager = new ProjectManager(ideRootDir); - instance.ideRootDir = Path.of(ideRoot); - instance.projectManager = new ProjectManager(instance.ideRootDir); - return instance; + final IdeLogListenerBuffer buffer = new IdeLogListenerBuffer(); + IdeLogLevel logLevel = IdeLogLevel.DEBUG; + startContext = new IdeStartContextImpl(logLevel, buffer); } /** @@ -101,7 +71,7 @@ public synchronized IdeGuiContext switchContext(String projectName, String works throw new FileNotFoundException("Workspace " + workspacePath + " does not exist!"); } - this.currentContext = new IdeGuiContext(startContext, workspacePath); + this.currentContext = new IdeGuiContext(startContext, workspacePath, taskManager); listeners.forEach(listener -> listener.onContextChange(this.currentContext)); return this.currentContext; @@ -123,6 +93,14 @@ public ProjectManager getProjectManager() { return projectManager; } + /** + * @return instance of {@link TaskManager} + */ + public TaskManager getTaskManager() { + + return taskManager; + } + /** * Add a listener to the context change events. * @@ -140,14 +118,4 @@ public void addGuiContextChangeListener(GuiContextChangeListener listener) { public void removeGuiContextChangeListener(GuiContextChangeListener listener) { listeners.remove(listener); } - - /** - * Holder class for the singleton instance. The static keyword ensures the thread-safety of the singleton. - * - * @see More info - */ - private static class Holder { - - private static final IdeGuiStateManager INSTANCE = new IdeGuiStateManager(); - } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiContext.java b/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiContext.java index 221e9325c3..b17fcec876 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiContext.java +++ b/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiContext.java @@ -1,25 +1,30 @@ package com.devonfw.ide.gui.context; import java.nio.file.Path; +import java.util.UUID; +import com.devonfw.ide.gui.progress.ProgressBarTask; import com.devonfw.tools.ide.context.AbstractIdeContext; import com.devonfw.tools.ide.context.IdeStartContextImpl; import com.devonfw.tools.ide.io.IdeProgressBar; -import com.devonfw.tools.ide.io.IdeProgressBarNone; /** - * Implementation of {@link AbstractIdeContext} for the IDEasy dashbaord (GUI). + * Implementation of {@link AbstractIdeContext} for the IDEasy dashboard (GUI). */ public class IdeGuiContext extends AbstractIdeContext { + private final TaskManager taskManager; + /** * The constructor. * * @param startContext the {@link IdeStartContextImpl}. * @param workingDirectory the optional {@link Path} to current working directory. + * @param taskManager the {@link TaskManager} to manage tasks and progress bars in the GUI. */ - public IdeGuiContext(IdeStartContextImpl startContext, Path workingDirectory) { + public IdeGuiContext(IdeStartContextImpl startContext, Path workingDirectory, TaskManager taskManager) { + this.taskManager = taskManager; super(startContext, workingDirectory); } @@ -32,6 +37,21 @@ protected String readLine() { @Override public IdeProgressBar newProgressBar(String title, long size, String unitName, long unitSize) { - return new IdeProgressBarNone(title, 0, unitName, unitSize); + ProgressBarTask newTask = new ProgressBarTask(taskManager, UUID.randomUUID().toString(), title, size, unitName, unitSize); + taskManager.addTask(newTask); + + return newTask; + } + + /** + * @param title the title of the progress bar + * @return a progress bar implementation that is indeterminate + */ + public IdeProgressBar newProgressBarIndeterminate(String title) { + + ProgressBarTask newTask = new ProgressBarTask(taskManager, UUID.randomUUID().toString(), title); + taskManager.addTask(newTask); + + return newTask; } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/TaskManager.java b/gui/src/main/java/com/devonfw/ide/gui/context/TaskManager.java new file mode 100644 index 0000000000..7ef9c037df --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/context/TaskManager.java @@ -0,0 +1,51 @@ +package com.devonfw.ide.gui.context; + +import java.util.Objects; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +import com.devonfw.ide.gui.FxHelper; +import com.devonfw.ide.gui.progress.ProgressBarTask; + +/** + * Singleton class that manages all currently running tasks and their progress bars. It provides an {@link ObservableList} of tasks, which can be observed by + * components like in the UI. + * + * @see ProgressBarTask + */ +public class TaskManager { + + private final ObservableList tasks = FXCollections.observableArrayList(); + + /** + * @param task the task to be added to the list of tasks. + */ + public void addTask(ProgressBarTask task) { + assert task != null; + + boolean exists = tasks.stream() + .anyMatch(t -> Objects.equals(t.getTaskId(), task.getTaskId())); + if (exists) { + throw new IllegalArgumentException("Task with ID " + task.getTaskId() + " already exists."); + } + + FxHelper.runFxSafe(() -> tasks.add(task)); + } + + /** + * @param task task to be removed + */ + public void removeTask(ProgressBarTask task) { + assert task != null; + + FxHelper.runFxSafe(() -> tasks.remove(task)); + } + + /** + * @return the {@link ObservableList} of currently running tasks. + */ + public ObservableList getTasks() { + + return tasks; + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java b/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java index 2ee467ae23..f152d30c0f 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java +++ b/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java @@ -11,7 +11,7 @@ public class IdeDialog extends Alert { /** - * @param alertType the {@link AlertType} of the alert (e.g. INFORMATION, CONFIRMATION, etc). + * @param alertType the {@link AlertType} of the alert (e.g. INFORMATION, CONFIRMATION, etc.). */ public IdeDialog(AlertType alertType) { @@ -20,7 +20,7 @@ public IdeDialog(AlertType alertType) { } /** - * @param alertType the {@link AlertType} of the alert (e.g. INFORMATION, CONFIRMATION, etc). + * @param alertType the {@link AlertType} of the alert (e.g. INFORMATION, CONFIRMATION, etc.). * @param message main message displayed in the dialoge * @param buttonTypes defines the different buttons that the alert displays. */ diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java b/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java new file mode 100644 index 0000000000..570aea5cc9 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java @@ -0,0 +1,139 @@ +package com.devonfw.ide.gui.progress; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.LongProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleLongProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.FxHelper; +import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.tools.ide.io.AbstractIdeProgressBar; + +/** + * This is a handler for the progress bars in the GUI + */ +public class ProgressBarTask extends AbstractIdeProgressBar { + + private final TaskManager taskManager; + /** + * This is the format representing how task titles are displayed in the UI. It follows the scheme "Task title [current progress/maximum progress Unit]" + */ + public static final String TASK_DESCRIPTION_STRING_FORMAT = "%s [%d/%d %s]"; + + private static final Logger LOG = LoggerFactory.getLogger(ProgressBarTask.class.getName()); + + private boolean isIndeterminate = false; + private final String taskId; //We use a task id to differentiate between multiple tasks with the same title. + + private final LongProperty progressProperty = new SimpleLongProperty(getCurrentProgress()); + //we only set the title on this line, once. the title is final in AbstractIdeContext, but we also use a property here to be consistent and allow dynamic updates if needed in the future. + private final StringProperty titleProperty = new SimpleStringProperty(getTitle()); + private final BooleanProperty indeterminateProperty = new SimpleBooleanProperty(isIndeterminate()); + + /** + * @param taskManager the {@link TaskManager} to link this progress bar to. Note: The task manager supplied here is only used for closing the task, in + * case {link #close()} is called. + * @param taskId a unique id to identify this task. + * @param title title of the task + * @param maxSize maximum progress + * @param unitName unit of the progress (e.g., %, MB, files, etc.) + * @param unitSize unit size (e.g., 1%) + */ + public ProgressBarTask(TaskManager taskManager, String taskId, String title, long maxSize, String unitName, long unitSize) { + + super(title, maxSize, unitName, unitSize); + this.taskId = taskId; + this.taskManager = taskManager; + } + + /** + * This constructor is used for indeterminate progress bars in the UI. + * + * @param taskManager the {@link TaskManager} to link this progress bar to. Note: The task manager supplied here is only used for closing the task, in + * case {link #close()} is called. + * @param taskId a unique id to identify this task. + * @param title the title of the progress bar + */ + public ProgressBarTask(TaskManager taskManager, String taskId, String title) { + + super(title, 100, "%", 1); + setIndeterminate(true); + this.taskId = taskId; + this.taskManager = taskManager; + } + + //currentProgress is only for test purposes, see AbstractIdeProgressBar + @Override + protected void doStepBy(long stepSize, long currentProgress) { + + LOG.debug("Updating progress bar by {} to {}", stepSize, currentProgress); + + FxHelper.runFxSafe(() -> progressProperty.setValue(getCurrentProgress())); + } + + @Override + protected void doStepTo(long stepPosition) { + + LOG.debug("Updating progress bar to {}", getCurrentProgress()); + + FxHelper.runFxSafe(() -> progressProperty.setValue(stepPosition)); + } + + @Override + public void close() { + + LOG.info("Closing progress bar"); + taskManager.removeTask(this); + super.close(); + } + + /** + * @return true if the progress bar is indeterminate, false otherwise + */ + public boolean isIndeterminate() { + return isIndeterminate; + } + + /** + * @param indeterminate set whether the progress bar is indeterminate or not + */ + public void setIndeterminate(boolean indeterminate) { + isIndeterminate = indeterminate; + indeterminateProperty.set(indeterminate); + } + + /** + * @return id of the current task + */ + public String getTaskId() { + return taskId; + } + + /** + * Properties are relevant for dynamically updating the ui. + * + * @return progress property of this task. + */ + public LongProperty currentProgressProperty() { + return progressProperty; + } + + /** + * @return title property of this task. + */ + public StringProperty titleProperty() { + return titleProperty; + } + + /** + * @return indeterminate property of this task. + */ + public BooleanProperty indeterminateProperty() { + return indeterminateProperty; + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindow.java b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindow.java new file mode 100644 index 0000000000..c02f1c7787 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindow.java @@ -0,0 +1,111 @@ +package com.devonfw.ide.gui.progress.taskwindow; + +import java.io.IOException; +import javafx.fxml.FXMLLoader; +import javafx.geometry.Point2D; +import javafx.geometry.Rectangle2D; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; + +import com.devonfw.ide.gui.App; +import com.devonfw.ide.gui.context.TaskManager; + +/** + * This window is displayed when the user clicks on the task label in main-view.fxml. + */ +public class TaskOverviewWindow { + + private final Stage stage = new Stage(); + + private static TaskOverviewWindow INSTANCE; + + /** + * @param taskManager the {@link TaskManager} to link to this TaskOverviewWindow. + * @return instance of the TaskOverviewWindow. + */ + public static TaskOverviewWindow getInstance(TaskManager taskManager) { + + if (INSTANCE == null) { + INSTANCE = new TaskOverviewWindow(taskManager); + } + return INSTANCE; + } + + /** + * + */ + private TaskOverviewWindow(TaskManager taskManager) { + + FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource("task_overview_window.fxml")); + fxmlLoader.setController(new TaskOverviewWindowController(taskManager)); + + Parent root; + try { + root = fxmlLoader.load(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + stage.setResizable(false); + stage.setAlwaysOnTop(true); + stage.setTitle("Running tasks"); + stage.setScene(new Scene(root)); + stage.initStyle(StageStyle.UTILITY); + } + + /** + * display the TaskOverviewWindow (or put it to the front if it is already open). + */ + public void show() { + showRelativeToReferenceNode(null); + } + + /** + * Displays the dialogue relative to the given reference node. + * + * @param referenceNode the node to position the TaskOverviewWindow relative to. If null, the window is centered on the screen. + */ + public void showRelativeToReferenceNode(Node referenceNode) { + + stage.show(); + Screen screen = Screen.getPrimary(); + double x, y; + if (referenceNode != null) { + Point2D referenceNodePos = referenceNode.localToScreen(0, 0); + + x = referenceNodePos.getX() + referenceNode.getBoundsInParent().getWidth() - stage.getWidth(); + y = referenceNodePos.getY() - referenceNode.getBoundsInParent().getHeight() - stage.getHeight(); + } else { + Rectangle2D screenBounds = screen.getVisualBounds(); + + x = screenBounds.getWidth() / 2 - stage.getWidth() / 2; + y = screenBounds.getHeight() / 2 - stage.getHeight() / 2; + } + setPositionRelative(x, y); + } + + /** + * @return this TaskOverviewWindow's {@link Stage}. + */ + public Stage getStage() { + return stage; + } + + /** + * Set the position of the TaskOverviewWindow relative to the given x and y coordinates. The window's bottom right corner is going to be positioned to the top + * left of the coordinates. + * + * @param x x position + * @param y y position + */ + public void setPositionRelative(double x, double y) { + if (stage.isShowing()) { + stage.setX(x); + stage.setY(y); + } + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindowController.java b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindowController.java new file mode 100644 index 0000000000..19776bf04a --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindowController.java @@ -0,0 +1,48 @@ +package com.devonfw.ide.gui.progress.taskwindow; + +import javafx.beans.Observable; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.scene.control.ListView; + +import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.ide.gui.progress.ProgressBarTask; + +/** + * Controller for the task overview window, which shows all currently running tasks and their progressbar. + */ +public class TaskOverviewWindowController { + + @FXML + private ListView taskList; + private final TaskManager taskManager; + + /** + * @param taskManager the {@link TaskManager} to link to this TaskOverviewWindow. + */ + public TaskOverviewWindowController(TaskManager taskManager) { + + this.taskManager = taskManager; + } + + @FXML + private void initialize() { + + taskList.setCellFactory(new TaskWindowCellFactory()); + + /* This part... + 1. connects the task list to the UI, automatically reacting to additions and removals + 2. also sets an Observable on progress property, so the UI also gets updated in case it changes + */ + ObservableList tasks = taskManager.getTasks(); + FXCollections.observableList( + tasks, + task -> new Observable[] { + task.currentProgressProperty() + } + ); + + taskList.setItems(tasks); + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskWindowCellFactory.java b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskWindowCellFactory.java new file mode 100644 index 0000000000..c7b943dad8 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskWindowCellFactory.java @@ -0,0 +1,82 @@ +package com.devonfw.ide.gui.progress.taskwindow; + +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.StringExpression; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.ProgressBar; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.util.Callback; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.progress.ProgressBarTask; + +/** + * Cell factory for displaying a list of tasks in the {@link TaskOverviewWindow} + */ +public class TaskWindowCellFactory implements Callback, ListCell> { + + private static final Logger LOG = LoggerFactory.getLogger(TaskWindowCellFactory.class); + + @Override + public ListCell call(ListView param) { + return new ListCell<>() { + + final ProgressBar progressBar = new ProgressBar(); + final Label titleLabel = new Label(); + final VBox contentBox = new VBox(5, titleLabel, progressBar); + + final HBox root = new HBox(10, contentBox); + + { + HBox.setHgrow(contentBox, Priority.ALWAYS); + root.setAlignment(Pos.CENTER_LEFT); + } + + @Override + public void updateItem(ProgressBarTask progressTask, boolean empty) { + super.updateItem(progressTask, empty); + + Platform.runLater(() -> { + if (empty || progressTask == null) { + setText(null); + setGraphic(null); + } else { + LOG.debug("updating task {} '{}'", progressTask.getTaskId(), progressTask.getTitle()); + + StringExpression formattedLabelText = Bindings.format( + ProgressBarTask.TASK_DESCRIPTION_STRING_FORMAT, + progressTask.titleProperty(), + progressTask.currentProgressProperty(), + progressTask.getMaxSize(), + progressTask.getUnitName() + ); + + titleLabel.textProperty().bind( + Bindings.when(progressTask.indeterminateProperty()) + .then(progressTask.titleProperty()) + .otherwise(formattedLabelText) + ); + progressBar.progressProperty().bind( + Bindings.when(progressTask.indeterminateProperty()) + .then(-1) + .otherwise(progressTask.currentProgressProperty().divide((double) progressTask.getMaxSize())) + ); + + //set the size of the progress bar to fill the window completely + progressBar.setMaxWidth(Double.MAX_VALUE); + + setGraphic(root); + } + }); + } + }; + } +} diff --git a/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml b/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml index 49246b642d..419cef4792 100644 --- a/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml +++ b/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml @@ -1,50 +1,48 @@ - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
@@ -53,111 +51,93 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
diff --git a/gui/src/main/resources/com/devonfw/ide/gui/task_overview_window.fxml b/gui/src/main/resources/com/devonfw/ide/gui/task_overview_window.fxml new file mode 100644 index 0000000000..d0f6be7df4 --- /dev/null +++ b/gui/src/main/resources/com/devonfw/ide/gui/task_overview_window.fxml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java b/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java index bb2f9062ac..d13c70665d 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java @@ -1,6 +1,7 @@ package com.devonfw.ide.gui; import static org.testfx.assertions.api.Assertions.assertThat; +import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; import java.io.IOException; import java.net.URL; @@ -11,29 +12,40 @@ import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.scene.input.MouseEvent; import javafx.stage.Stage; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.ide.gui.context.IdeGuiStateManager; +import com.devonfw.ide.gui.context.GuiStateManager; +import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindow; /** * Basic UI Test */ public class AppBaseTest extends HeadlessApplicationTest { - private static Logger LOGGER = LoggerFactory.getLogger(AppBaseTest.class); + private static final Logger LOG = LoggerFactory.getLogger(AppBaseTest.class); private Button androidStudioOpen, eclipseOpen, intellijOpen, vsCodeOpen; private ComboBox selectedProject, selectedWorkspace; + private Label statusText; + private ProgressBar taskProgressBar; @TempDir private static Path mockIdeRoot; + private static final TaskManager taskManager = new TaskManager(); + private static GuiStateManager guiStateManager; @Override public void start(Stage stage) throws IOException { @@ -42,7 +54,7 @@ public void start(Stage stage) throws IOException { assertThat(mainViewUrl).as("Cannot resolve main UI FXML resource!").isNotNull(); FXMLLoader fxmlLoader = new FXMLLoader(mainViewUrl); - fxmlLoader.setController(new MainController(mockIdeRoot.toString())); + fxmlLoader.setController(new MainController(mockIdeRoot.toString(), guiStateManager)); Parent root = fxmlLoader.load(); stage.setScene(new Scene(root)); stage.requestFocus(); //sometimes needed for headless setup to work @@ -54,35 +66,44 @@ public void start(Stage stage) throws IOException { vsCodeOpen = (Button) root.lookup("#vsCodeOpen"); selectedProject = (ComboBox) root.lookup("#selectedProject"); selectedWorkspace = (ComboBox) root.lookup("#selectedWorkspace"); + statusText = (Label) root.lookup("#statusLabel"); + taskProgressBar = (ProgressBar) root.lookup("#statusProgressBar"); } /** - * Generate a temporary project directories in order to be able to test on any device (including GitHub CI). This is required for the {@link MainController} - * to work in the test context. Generates a structure like this: /project-[0..6]/workspaces/main + * Generate temporary project directories to be able to test on any device (including GitHub CI). This is required for the {@link MainController} to work in + * the test context. Generates a structure like this: /project-[0..6]/workspaces/main */ @BeforeAll protected static void generateProjectFolderStructure() throws IOException { - LOGGER.debug("tempDir: {}", mockIdeRoot); + LOG.debug("tempDir: {}", mockIdeRoot); FakeProjectFolderStructureHelper.createFakeProjectFolderStructure(mockIdeRoot); - LOGGER.debug("project folders: {}", Arrays.toString(mockIdeRoot.toFile().list())); + LOG.debug("project folders: {}", Arrays.toString(mockIdeRoot.toFile().list())); - //We set the project root directory to the temporary directory before all tests, so that the IDE can find the projects in the test. - IdeGuiStateManager.getInstanceOverrideRootDir(mockIdeRoot.toString()).switchContext("project-1", "main"); + guiStateManager = new GuiStateManager(taskManager, mockIdeRoot.toString()); + //We set the project root directory to the temporary directory before all tests so that the IDE can find the projects in the test. + guiStateManager.switchContext("project-1", "main"); + } + + @BeforeEach + protected void resetTaskManager() { + + taskManager.getTasks().clear(); } /** * This test ensures that all IDE open buttons are disabled when no project is selected. */ @Test - public void testIdeOpenButtonsDisabledWhenNoWorkspaceSelected() { + public void testIdeOpenButtonsDisabledWhenNoProjectSelected() { // assert that no project is selected - assertThat(selectedWorkspace.getValue()).isNull(); + assertThat(selectedProject.getValue()).isNull(); // assert all IDE open buttons are disabled for (Button button : new Button[] { androidStudioOpen, eclipseOpen, intellijOpen, vsCodeOpen }) { - assertThat(button.isDisabled()).as(button.getId() + " button should be disabled when no workspace has been selected").isTrue(); + assertThat(button.isDisabled()).as(button.getId() + " button should be disabled when no project has been selected").isTrue(); } } @@ -90,9 +111,9 @@ public void testIdeOpenButtonsDisabledWhenNoWorkspaceSelected() { * This test ensures that all IDE open buttons are enabled when a project is selected. */ @Test - public void testIdeOpenButtonsEnabledWhenWorkspaceSelected() { + public void testIdeOpenButtonsEnabledWhenProjectSelected() { - // assert that a project and workspace is selected + // assert that project and workspace are selected interact(() -> selectedProject.getSelectionModel().select("project-1")); interact(() -> selectedWorkspace.getSelectionModel().select("main")); @@ -119,7 +140,7 @@ public void testWorkspaceComboBoxDisabledWhenNoProjectSelected() { * Tests that the workspace {@link ComboBox} is enabled when a project is selected. */ @Test - public void testWorkspaceComboBoxEnabledEnabledWhenProjectSelected() { + public void testWorkspaceComboboxEnabledEnabledWhenProjectSelected() { // assert that a project is selected interact(() -> selectedProject.getSelectionModel().select("project-1")); @@ -129,4 +150,57 @@ public void testWorkspaceComboBoxEnabledEnabledWhenProjectSelected() { .as("selectedWorkspace ComboBox should be enabled when a project is selected") .isFalse(); } + + @Test + protected void testStatusLabelDisplaysCorrectMessage() { + + ProgressBarTask task1 = new ProgressBarTask(taskManager, "task-1", "Test Task"); + ProgressBarTask task2 = new ProgressBarTask(taskManager, "task-2", "Test Task"); + + //Case 1: No tasks added yet, check correct message + assertThat(statusText.getText()).isEqualTo("IDEasy is ready."); + + //Case 2: Only single task exists, should display the task title and a progress bar next to the label + taskManager.addTask(task1); + waitForFxEvents(); + + assertThat(statusText.getText()).isEqualTo( + String.format(ProgressBarTask.TASK_DESCRIPTION_STRING_FORMAT, + task1.getTitle(), + task1.getCurrentProgress(), + task1.getMaxSize(), + task1.getUnitName()) + ); + assertThat(taskProgressBar.isVisible()).as("Task progress bar should be visible").isTrue(); + + //Case 3: Multiple tasks exist, should display the number of tasks and a progress bar next to the label + taskManager.addTask(task2); + waitForFxEvents(); + + assertThat(statusText.getText()).isEqualTo(String.format("%d tasks running...", taskManager.getTasks().size())); + assertThat(taskProgressBar.isVisible()).as("Task progress bar should not be visible").isFalse(); + + //...and back to the default state: + taskManager.getTasks().clear(); + waitForFxEvents(); + + assertThat(statusText.getText()).isEqualTo("IDEasy is ready."); + } + + @Test + protected void testStatusTextOpensTaskOverviewWindow() { + + ProgressBarTask task1 = new ProgressBarTask(taskManager, "task-1", "Test Task"); + ProgressBarTask task2 = new ProgressBarTask(taskManager, "task-2", "Test Task"); + + taskManager.addTask(task1); + taskManager.addTask(task2); + waitForFxEvents(); + + interact(() -> statusText.fireEvent( + new MouseEvent(MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, null, 1, false, false, false, false, false, false, false, false, false, false, null))); + + assertThat(TaskOverviewWindow.getInstance(taskManager).getStage().isShowing()).as("Task overview window should be opened when clicking on status text") + .isTrue(); + } } diff --git a/gui/src/test/java/com/devonfw/ide/gui/FakeProjectFolderStructureHelper.java b/gui/src/test/java/com/devonfw/ide/gui/FakeProjectFolderStructureHelper.java index d6850e22c3..8434e42958 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/FakeProjectFolderStructureHelper.java +++ b/gui/src/test/java/com/devonfw/ide/gui/FakeProjectFolderStructureHelper.java @@ -10,15 +10,15 @@ * This class helps to create a fake project folder structure for testing. The projects are named in the format "project-{i}" and the workspaces are named * "main". *

- * This class is supposed to be replaced by enabling ide-cli test dependencies allowing to mock a IdeContext + * This class is supposed to be replaced by enabling ide-cli test dependencies allowing to mock an IdeContext */ public class FakeProjectFolderStructureHelper { /** - * @param rootPath root path where fake structure should be created - * @return the rootPath + * @param rootPath root path where a fake structure should be created + * @throws IOException if any of the directories cannot be created */ - public static Path createFakeProjectFolderStructure(Path rootPath) throws IOException { + public static void createFakeProjectFolderStructure(Path rootPath) throws IOException { for (int i = 0; i <= 5; i++) { String projectFolderName = "project-" + i; @@ -34,7 +34,6 @@ public static Path createFakeProjectFolderStructure(Path rootPath) throws IOExce .isNotNull(); } - return rootPath; } } diff --git a/gui/src/test/java/com/devonfw/ide/gui/context/IdeGuiStateManagerTest.java b/gui/src/test/java/com/devonfw/ide/gui/context/GuiStateManagerTest.java similarity index 75% rename from gui/src/test/java/com/devonfw/ide/gui/context/IdeGuiStateManagerTest.java rename to gui/src/test/java/com/devonfw/ide/gui/context/GuiStateManagerTest.java index c92f12e56f..dcfb331227 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/context/IdeGuiStateManagerTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/context/GuiStateManagerTest.java @@ -16,47 +16,39 @@ import com.devonfw.tools.ide.context.IdeTestContext; /** - * Tests for {@link IdeGuiStateManager}. + * Tests for {@link GuiStateManager}. */ -public class IdeGuiStateManagerTest extends AbstractIdeContextTest { +public class GuiStateManagerTest extends AbstractIdeContextTest { - private static final Logger LOG = LoggerFactory.getLogger(IdeGuiStateManagerTest.class); + private static final Logger LOG = LoggerFactory.getLogger(GuiStateManagerTest.class); private static IdeTestContext context; - private static IdeGuiStateManager guiStateManager; + private static GuiStateManager guiStateManager; private static ProjectManager projectManager; + private TaskManager taskManager; @BeforeAll static void setup() { context = newContext("testProject", "project-0"); LOG.debug("root: {}", context.getIdeRoot()); - - guiStateManager = IdeGuiStateManager.getInstanceOverrideRootDir(context.getIdeRoot().toString()); - projectManager = guiStateManager.getProjectManager(); } @BeforeEach void reset() { - IdeGuiStateManager.getInstanceOverrideRootDir(context.getIdeRoot().toString()); - } - - @Test - void testThrowsIfIdeRootNull() { - try { - IdeGuiStateManager.getInstanceOverrideRootDir(null); - } catch (IllegalArgumentException e) { - assertThat(e.getMessage()).contains("ideRoot must not be null!"); - } + TaskManager taskManager = new TaskManager(); + guiStateManager = new GuiStateManager(taskManager, context.getIdeRoot().toString()); + projectManager = guiStateManager.getProjectManager(); } @Test void testThrowsIfIdeRootDoesNotExist() { try { - IdeGuiStateManager.getInstanceOverrideRootDir("nonExistingIdeRoot"); + new GuiStateManager(taskManager, "nonExistingIdeRoot"); + fail("IllegalArgumentException expected"); } catch (IllegalArgumentException e) { assertThat(e.getMessage()).contains("Root directory does not exist"); } @@ -66,7 +58,7 @@ void testThrowsIfIdeRootDoesNotExist() { void testGetContext() throws FileNotFoundException { IdeGuiContext context = guiStateManager.switchContext(projectManager.getProjectNames().getFirst(), "main"); - assertThat(context).isNotNull().as("context was null after switchContext was called"); // When switching to a project, the context should be set. + assertThat(context).as("context was null after switchContext was called").isNotNull(); // When switching to a project, the context should be set. } @Test @@ -88,6 +80,7 @@ void testThrowsIfNonExistentProjectSelected() { try { guiStateManager.switchContext(fakeProject.getFileName().toString(), "main"); + fail("FileNotFoundException expected"); } catch (FileNotFoundException e) { assertThat(e.getMessage()).contains("Project " + fakeProject + " does not exist!") .as("GuiStateManager.switchContext should throw an exception, if a non-existent project is selected"); diff --git a/gui/src/test/java/com/devonfw/ide/gui/context/ProjectManagerTest.java b/gui/src/test/java/com/devonfw/ide/gui/context/ProjectManagerTest.java index 3a7ddd8b1c..58de824ec1 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/context/ProjectManagerTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/context/ProjectManagerTest.java @@ -22,7 +22,6 @@ public class ProjectManagerTest extends AbstractIdeContextTest { private static ProjectManager projectManager; - private static IdeTestContext context; private static Path ideRoot; private final List VALID_PROJECT_LIST = List.of("project-0", "project-1", "project-2", "project-3", "project-4", "project-5"); @@ -30,7 +29,7 @@ public class ProjectManagerTest extends AbstractIdeContextTest { @BeforeEach void resetContext() { - context = newContext("testProject", "project-0"); + IdeTestContext context = newContext("testProject", "project-0"); ideRoot = context.getIdeRoot(); } @@ -115,7 +114,7 @@ void testRefreshProjects() throws IOException { @Test void testReadProjectsExcludesFoldersWithoutWorkspaces() throws IOException { - // Create a project folder without a workspaces subdirectory + // Create a project folder without a workspace subdirectory Path testProject = ideRoot.resolve("test-project-no-workspaces"); Files.createDirectory(testProject); diff --git a/gui/src/test/java/com/devonfw/ide/gui/progress/TaskManagerTest.java b/gui/src/test/java/com/devonfw/ide/gui/progress/TaskManagerTest.java new file mode 100644 index 0000000000..a7a772a93f --- /dev/null +++ b/gui/src/test/java/com/devonfw/ide/gui/progress/TaskManagerTest.java @@ -0,0 +1,74 @@ +package com.devonfw.ide.gui.progress; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.devonfw.ide.gui.HeadlessApplicationTest; +import com.devonfw.ide.gui.context.TaskManager; + +/** + * Test for the @TaskManager class. Currently, we extend HeadlessApplicationTest because we need the JavaFX Application Thread. The reason for this is, that + * TaskManager uses a method from JavaFX (currently; looking for a better implementation in the future). + * + * @see TaskManager + */ +class TaskManagerTest extends HeadlessApplicationTest { + + private TaskManager taskManager; + + @BeforeEach + void setUp() { + + taskManager = new TaskManager(); + + waitForFxEvents(); + } + + @Test + void shouldAddTask() { + + ProgressBarTask task = new ProgressBarTask(taskManager, "task-1", "Test Task"); + + taskManager.addTask(task); + + waitForFxEvents(); + + assertEquals(1, taskManager.getTasks().size()); + assertTrue(taskManager.getTasks().contains(task)); + } + + @Test + void shouldRemoveTask() { + + ProgressBarTask task = new ProgressBarTask(taskManager, "task-1", "Test Task"); + + taskManager.addTask(task); + taskManager.removeTask(task); + + waitForFxEvents(); + + assertTrue(taskManager.getTasks().isEmpty()); + } + + @Test + void shouldThrowExceptionWhenAddingDuplicateTaskId() { + + ProgressBarTask task1 = new ProgressBarTask(taskManager, "task-1", "Test Task 1"); + ProgressBarTask task2 = new ProgressBarTask(taskManager, "task-1", "Test Task 2"); + + taskManager.addTask(task1); + + waitForFxEvents(); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> taskManager.addTask(task2) + ); + assertEquals("Task with ID task-1 already exists.", exception.getMessage()); + } +} diff --git a/gui/src/test/java/com/devonfw/ide/gui/progress/TaskWindowTest.java b/gui/src/test/java/com/devonfw/ide/gui/progress/TaskWindowTest.java new file mode 100644 index 0000000000..a399a0ce50 --- /dev/null +++ b/gui/src/test/java/com/devonfw/ide/gui/progress/TaskWindowTest.java @@ -0,0 +1,190 @@ +package com.devonfw.ide.gui.progress; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; + +import java.net.URL; +import javafx.fxml.FXMLLoader; +import javafx.geometry.Rectangle2D; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.ListView; +import javafx.stage.Screen; +import javafx.stage.Stage; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.devonfw.ide.gui.App; +import com.devonfw.ide.gui.FxHelper; +import com.devonfw.ide.gui.HeadlessApplicationTest; +import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindow; +import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindowController; + +/** + * Tests for the TaskOverviewWindow. We check whether the window is displayed correctly and whether it properly reacts to changes in the TaskManager. + */ +public class TaskWindowTest extends HeadlessApplicationTest { + + private ListView taskList; + private static TaskManager taskManager; + + @Override + public void start(Stage stage) throws Exception { + + URL taskOverviewWindowUrl = App.class.getResource("task_overview_window.fxml"); + assertThat(taskOverviewWindowUrl).as("Cannot resolve task overview window FXML resource!").isNotNull(); + + FXMLLoader fxmlLoader = new FXMLLoader(taskOverviewWindowUrl); + fxmlLoader.setController(new TaskOverviewWindowController(taskManager)); + Parent root = fxmlLoader.load(); + stage.setScene(new Scene(root)); + stage.show(); + + taskList = (ListView) root.lookup("#taskList"); + } + + @BeforeAll + static void setup() { + taskManager = new TaskManager(); + } + + @BeforeEach + void reset() { + taskManager.getTasks().clear(); + waitForFxEvents(); + } + + /** + * We check, whether our implementation of {@link TaskOverviewWindow#show()} actually displays the window. + */ + @Test + void isWindowShown() { + FxHelper.runFxSafe(() -> { + TaskOverviewWindow testWindow = TaskOverviewWindow.getInstance(taskManager); + testWindow.show(); + + assertThat(testWindow.getStage().isShowing()).isTrue(); + }); + } + + @Test + void shouldShowTaskWhenTaskAdded() { + + ProgressBarTask task = new ProgressBarTask(taskManager, "task-1", "Test Task"); + taskManager.addTask(task); + waitForFxEvents(); + + assertThat(taskList.getItems()).contains(task); + } + + @Test + void shouldNotShowTaskWhenTaskRemoved() { + + ProgressBarTask task = new ProgressBarTask(taskManager, "task-1", "Test Task"); + taskManager.addTask(task); + waitForFxEvents(); + + assertThat(taskList.getItems()).contains(task); + + taskManager.removeTask(task); + waitForFxEvents(); + + assertThat(taskList.getItems()).isEmpty(); + } + + /** + * Ensures that the list of tasks stays coherent with the actual tasks. + */ + @Test + void listContentsAreCoherent() { + + ProgressBarTask task1 = new ProgressBarTask(taskManager, "task-1", "Test Task"); + ProgressBarTask task2 = new ProgressBarTask(taskManager, "task-2", "Test Task 2"); + ProgressBarTask task3 = new ProgressBarTask(taskManager, "task-3", "Test Task 3"); + + taskManager.addTask(task1); + taskManager.addTask(task2); + taskManager.addTask(task3); + waitForFxEvents(); + + assertThat(taskList.getItems()).containsExactly(task1, task2, task3); + } + + /** + * Our TaskOverViewWindow should reuse the same window instance if it is already open. Also, the existing window should be brought to the front + */ + @Test + void reusesExistingWindow() { + + FxHelper.runFxSafe(() -> { + + TaskOverviewWindow testWindow1 = TaskOverviewWindow.getInstance(taskManager); + testWindow1.show(); + + TaskOverviewWindow testWindow2 = TaskOverviewWindow.getInstance(taskManager); + testWindow2.show(); + + assertThat(testWindow1.equals(testWindow2)).isTrue().as("Window instances differentiate"); + assertThat(testWindow1.getStage().isShowing()).isTrue().as("Window is not showing"); + assertThat(testWindow1.getStage().isFocused()).isTrue().as("Window is not focused"); + }); + } + + /** + * When no tasks are running, the TaskOverViewWindow should show an empty list, not a null pointer exception or similar. + */ + @Test + void showsEmptyListWhenNoTasks() { + + // In @BeforeEach tasks get cleared before each test + assertThat(taskList.getItems()).isEmpty(); + } + + /** + * tests whether progress tasks are properly updated in the list when the properties update + */ + @Test + void testTaskProgressUpdatesProperly() { + + ProgressBarTask task = new ProgressBarTask(taskManager, "task-1", "Test Task", 100, "Units", 1); + taskManager.addTask(task); + waitForFxEvents(); + + assertThat(taskList.getItems()).as("Task should be in the list").contains(task); + + //Test stepBy (includes doStepBy; see implementation of stepBy()) + task.stepBy(20); + waitForFxEvents(); + + assertThat(taskList.getItems().getFirst().currentProgressProperty().getValue()).as("Progress should be 20").isEqualTo(20); + + //Test doStepTo (only used internally) + task.doStepTo(40); + waitForFxEvents(); + + assertThat(taskList.getItems().getFirst().currentProgressProperty().getValue()).as("Progress should be 40").isEqualTo(40); + } + + /** + * We check here that a null node reference is handled properly and leads to the window being displayed in the center of the screen. + */ + @Test + void testNullReferenceNode() { + + FxHelper.runFxSafe(() -> { + TaskOverviewWindow nullRefWindow = TaskOverviewWindow.getInstance(taskManager); + nullRefWindow.showRelativeToReferenceNode(null); + + Rectangle2D screenMeasures = Screen.getPrimary().getVisualBounds(); + + double expectedPositionX = screenMeasures.getWidth() / 2 - nullRefWindow.getStage().getScene().getWidth() / 2; + double expectedPositionY = screenMeasures.getHeight() / 2 - nullRefWindow.getStage().getScene().getHeight() / 2; + + assertThat(nullRefWindow.getStage().getX()).as("Window should be in the expected X position").isEqualTo(expectedPositionX); + assertThat(nullRefWindow.getStage().getY()).as("Window should be in the expected Y position").isEqualTo(expectedPositionY); + }); + } +}