From d3438450bcc0859005d4fbf6d8dd1bfe9afe758b Mon Sep 17 00:00:00 2001 From: laim2003 Date: Mon, 17 Aug 2026 18:53:25 +0200 Subject: [PATCH 1/4] #2314: initial commit Signed-off-by: laim2003 --- .../com/devonfw/tools/ide/step/StepImpl.java | 2 +- .../com/devonfw/ide/gui/MainController.java | 1 - .../ide/gui/context/IdeGuiContext.java | 27 ++++++++++++++ .../devonfw/ide/gui/context/TaskManager.java | 25 +++++++++++++ .../ide/gui/progress/step/GuiStep.java | 35 +++++++++++++++++++ 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java diff --git a/cli/src/main/java/com/devonfw/tools/ide/step/StepImpl.java b/cli/src/main/java/com/devonfw/tools/ide/step/StepImpl.java index 5c7723b0b6..82bbeeecb7 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/step/StepImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/step/StepImpl.java @@ -16,7 +16,7 @@ /** * Regular implementation of {@link Step}. */ -public final class StepImpl implements Step { +public class StepImpl implements Step { private static final Logger LOG = LoggerFactory.getLogger(StepImpl.class); 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 e501beb388..199da9914a 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/MainController.java +++ b/gui/src/main/java/com/devonfw/ide/gui/MainController.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; - import javafx.application.Platform; import javafx.collections.ListChangeListener; import javafx.concurrent.Task; 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 87ecad2155..76fa22682f 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 @@ -4,9 +4,12 @@ import java.util.UUID; import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.progress.step.GuiStep; 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.step.Step; +import com.devonfw.tools.ide.step.StepImpl; /** * Implementation of {@link AbstractIdeContext} for the IDEasy dashboard (GUI). @@ -54,4 +57,28 @@ public IdeProgressBar newProgressBarIndeterminate(String title) { return newTask; } + + @Override + public Step newStep(String name) { + GuiStep step = new GuiStep(taskManager, this, null, name, false); + taskManager.addStep(step); + + return step; + } + + @Override + public Step newStep(String name, Object... parameters) { + GuiStep step = new GuiStep(taskManager, this, null, name, false, parameters); + taskManager.addStep(step); + + return step; + } + + @Override + public StepImpl newStep(boolean silent, String name, Object... parameters) { + GuiStep step = new GuiStep(taskManager, this, null, name, silent, parameters); + taskManager.addStep(step); + + return step; + } } 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 index 1ac8da65e6..9d8bf8b733 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/TaskManager.java +++ b/gui/src/main/java/com/devonfw/ide/gui/context/TaskManager.java @@ -2,6 +2,8 @@ import java.util.Objects; +import com.devonfw.ide.gui.progress.step.GuiStep; + import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -24,6 +26,9 @@ public class TaskManager { private final ObservableList tasks = FXCollections.observableArrayList(); private final ObservableList taskListReadOnly = FXCollections.unmodifiableObservableList(tasks); + private final ObservableList steps = FXCollections.observableArrayList(); + private final ObservableList stepListReadOnly = FXCollections.unmodifiableObservableList(steps); + /** * Adds a task to the task list. The duplicate check and the add are performed atomically on the FX thread. Duplicate IDs are silently ignored (idempotent). * @@ -68,4 +73,24 @@ public ObservableList getTasks() { return taskListReadOnly; } + + public void addStep(GuiStep step) { + + FxHelper.runFxSafe(() -> this.steps.add(step)); + } + + public void removeStep(GuiStep step) { + + FxHelper.runFxSafe(() -> this.steps.remove(step)); + } + + public void clearSteps() { + + FxHelper.runFxSafe(steps::clear); + } + + public ObservableList getSteps() { + + return stepListReadOnly; + } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java b/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java new file mode 100644 index 0000000000..d4f09d57f0 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java @@ -0,0 +1,35 @@ +package com.devonfw.ide.gui.progress.step; + +import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.tools.ide.context.AbstractIdeContext; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.step.StepImpl; + +/// Implementation of {@link Step} for the GUI. +public class GuiStep extends StepImpl { + + private final TaskManager taskManager; + + /** + * Creates and starts a new {@link StepImpl}. + * + * @param context the {@link IdeContext}. + * @param parent the {@link #getParent() parent step}. + * @param name the {@link #getName() step name}. + * @param silent the {@link #isSilent() silent flag}. + * @param params the parameters. Should have reasonable {@link Object#toString() string representations}. + */ + public GuiStep(TaskManager taskManager, AbstractIdeContext context, StepImpl parent, String name, boolean silent, Object... params) { + + this.taskManager = taskManager; + super(context, parent, name, silent, params); + } + + @Override + public void close() { + + taskManager.removeStep(this); + super.close(); + } +} From fcaf168812b46ffe0e00241598d7dd9657bd755d Mon Sep 17 00:00:00 2001 From: laim2003 Date: Tue, 18 Aug 2026 13:49:19 +0200 Subject: [PATCH 2/4] #2314: Moved task context creation to GuiStateManager --- .../com/devonfw/ide/gui/MainController.java | 163 +++++++++--------- .../ide/gui/context/GuiStateManager.java | 18 ++ 2 files changed, 103 insertions(+), 78 deletions(-) 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 199da9914a..ba1e5783cf 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/MainController.java +++ b/gui/src/main/java/com/devonfw/ide/gui/MainController.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; + import javafx.application.Platform; import javafx.collections.ListChangeListener; import javafx.concurrent.Task; @@ -21,11 +22,13 @@ import org.slf4j.LoggerFactory; import com.devonfw.ide.gui.context.GuiStateManager; +import com.devonfw.ide.gui.context.IdeGuiContext; 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.nls.NlsService; -import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.progress.GuiTask; +import com.devonfw.ide.gui.progress.TaskState; import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindow; /** @@ -99,28 +102,16 @@ public MainController(String ideRoot, GuiStateManager guiStateManager, NlsServic private void setUpTaskListListener() { - ListChangeListener taskListChangeListener = change -> { - List tasks = taskManager.getTasks(); - + // The task list is created with an extractor, so additions, removals and changes to a task's own properties all arrive here. + ListChangeListener taskListChangeListener = change -> { while (change.next()) { if (change.wasAdded()) { LOG.debug("Added: {}", change.getAddedSubList()); - - for (ProgressBarTask progressTask : change.getAddedSubList()) { - progressTask.currentProgressProperty().addListener((_, _, _) -> - updateStatusLabel(tasks) - ); - } - updateStatusLabel(tasks); } else if (change.wasRemoved()) { LOG.debug("Removed: {}", change.getRemoved()); - - updateStatusLabel(tasks); - } else if (change.wasUpdated()) { - - updateStatusLabel(tasks); } } + updateStatusLabel(taskManager.getTasks()); }; taskManager.getTasks().addListener(taskListChangeListener); } @@ -253,28 +244,23 @@ private void openIDE(String inIde) { 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(); - return null; - } - }; - - downloadTask.setOnFailed(_ -> Platform.runLater(() -> { - task.close(); - IdeDialog errorDialog = new IdeDialog(AlertType.ERROR, "Error occurred while launching " + inIde); - errorDialog.showAndWait(); - })); - downloadTask.setOnSucceeded(_ -> Platform.runLater(task::close)); - return downloadTask; - } + Task downloadTask = new Task<>() { + @Override + protected Void call() { + // Each execution gets its own context so that concurrently started commands keep independent step stacks. + IdeGuiContext runContext = guiStateManager.newRunContext(); + // Wrapping the commandlet in a root step gives the user a single task that reports on all steps the commandlet creates below it. + runContext.newStep("Starting " + inIde).run(() -> + runContext.getCommandletManager().getCommandlet(inIde).run()); + return null; + } + }; + + downloadTask.setOnFailed(_ -> Platform.runLater(() -> { + IdeDialog errorDialog = new IdeDialog(AlertType.ERROR, "Error occurred while launching " + inIde); + errorDialog.showAndWait(); + })); + return downloadTask; } private void updateContext(String selectedProjectName, String selectedWorkspaceName) { @@ -287,48 +273,69 @@ private void updateContext(String selectedProjectName, String selectedWorkspaceN } } - 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); + private void updateStatusLabel(List taskList) { - statusLabel.setUnderline(false); - statusLabel.setStyle(""); + // runFxSafe rather than runLater: the task list notifies us on the FX thread already, so queueing another hop per progress update only adds churn. + FxHelper.runFxSafe(() -> { + // Finished steps stay in the list until the user dismisses them, so the status bar reports on the running ones. + List runningTasks = taskList.stream().filter(GuiTask::isRunning).toList(); + + if (runningTasks.size() > 1) { + showLinkStatus(runningTasks.size() + " tasks running..."); + } else if (runningTasks.size() == 1) { + showSingleTaskStatus(runningTasks.getFirst()); + } else if (!taskList.isEmpty()) { + showLinkStatus(buildFinishedSummary(taskList)); + } else { + showIdleStatus(); } }); } + + private String buildFinishedSummary(List taskList) { + + long failed = taskList.stream().filter(task -> task.getState() == TaskState.FAILED).count(); + if (failed > 0) { + return String.format("%d of %d tasks failed", failed, taskList.size()); + } + return String.format("%d tasks finished", taskList.size()); + } + + /** + * Shows a clickable status that opens the {@link TaskOverviewWindow}, used whenever a single task cannot represent the state. + */ + private void showLinkStatus(String text) { + + statusLabel.setOnMouseClicked(_ -> TaskOverviewWindow.getInstance(taskManager).showRelativeToReferenceNode(statusLabel)); + statusLabel.setText(text); + statusLabel.setUnderline(true); + statusLabel.setStyle("-fx-text-fill: blue;-fx-cursor: hand"); + + statusProgressBar.setVisible(false); + statusProgressBar.setPrefWidth(0); + } + + private void showSingleTaskStatus(GuiTask task) { + + statusLabel.setOnMouseClicked(_ -> TaskOverviewWindow.getInstance(taskManager).showRelativeToReferenceNode(statusLabel)); + statusLabel.setText(task.displayTextProperty().get()); + statusLabel.setUnderline(true); + statusLabel.setStyle("-fx-text-fill: blue;-fx-cursor: hand"); + + statusProgressBar.setVisible(true); + statusProgressBar.setPrefWidth(PROGRESSBAR_VISIBLE_WIDTH); + // a value of GuiTaskModel.INDETERMINATE maps directly onto the indeterminate animation of the JavaFX progress bar. + statusProgressBar.setProgress(task.progressProperty().get()); + } + + private void showIdleStatus() { + + statusLabel.setOnMouseClicked(null); + statusLabel.setText("IDEasy is ready."); + statusLabel.setUnderline(false); + statusLabel.setStyle(""); + + statusProgressBar.setVisible(false); + statusProgressBar.setPrefWidth(0); + } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/GuiStateManager.java b/gui/src/main/java/com/devonfw/ide/gui/context/GuiStateManager.java index 91e5789e99..da60bf3d23 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/GuiStateManager.java +++ b/gui/src/main/java/com/devonfw/ide/gui/context/GuiStateManager.java @@ -77,6 +77,24 @@ public synchronized IdeGuiContext switchContext(String projectName, String works return this.currentContext; } + /** + * Creates a fresh {@link IdeGuiContext} for a single command execution. + *

+ * Each command runs on its own thread, while an {@link IdeGuiContext} keeps the currently running {@link com.devonfw.tools.ide.step.Step} in a field. Handing + * every execution its own context keeps those step stacks independent, so two commands started in parallel cannot corrupt each other's step hierarchy. + * + * @return a new {@link IdeGuiContext} for the currently selected project and workspace. + * @throws IllegalStateException if no project and workspace have been selected yet. + */ + public IdeGuiContext newRunContext() { + + Path workspacePath = this.currentContext.getWorkspacePath(); + if (workspacePath == null) { + throw new IllegalStateException("No project and workspace selected - call switchContext first."); + } + return new IdeGuiContext(this.startContext, workspacePath, this.taskManager); + } + /** * @return the current {@link IdeGuiContext} based on the selected project. is null, if no context has been set via switchContext. */ From 702492a34fb60775081363559b17449ec621d790 Mon Sep 17 00:00:00 2001 From: laim2003 Date: Tue, 18 Aug 2026 15:56:02 +0200 Subject: [PATCH 3/4] #2314: added GuiTask as a supertype to Progress bars and steps. --- .../com/devonfw/ide/gui/progress/GuiTask.java | 87 ++++++++ .../ide/gui/progress/GuiTaskModel.java | 182 ++++++++++++++++ .../ide/gui/progress/ProgressBarTask.java | 194 +++++++++++++----- .../devonfw/ide/gui/progress/TaskState.java | 27 +++ .../devonfw/ide/gui/progress/TaskStats.java | 30 +++ .../ide/gui/progress/step/GuiStep.java | 175 +++++++++++++++- 6 files changed, 631 insertions(+), 64 deletions(-) create mode 100644 gui/src/main/java/com/devonfw/ide/gui/progress/GuiTask.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/progress/GuiTaskModel.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/progress/TaskState.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/progress/TaskStats.java diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/GuiTask.java b/gui/src/main/java/com/devonfw/ide/gui/progress/GuiTask.java new file mode 100644 index 0000000000..1f86e123f8 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/GuiTask.java @@ -0,0 +1,87 @@ +package com.devonfw.ide.gui.progress; + +import javafx.beans.binding.StringExpression; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyStringProperty; + +/** + * A unit of work that is displayed to the end-user in the status bar and in the task overview window. + *

+ * This is the single abstraction the UI layer renders. It is implemented both by {@link ProgressBarTask} (an + * {@link com.devonfw.tools.ide.io.IdeProgressBar}) and by {@link com.devonfw.ide.gui.progress.step.GuiStep} (a + * {@link com.devonfw.tools.ide.step.Step}). Those two extend different CLI classes and can therefore never share a common base class, so the shared state lives + * in a {@link GuiTaskModel} that both of them own by composition and delegate to. + */ +public interface GuiTask { + + /** + * @return the unique id of this task. Used to distinguish multiple tasks that share the same {@link #titleProperty() title}. + */ + String getId(); + + /** + * @return the title of this task, e.g. "Downloading" or the name of a {@link com.devonfw.tools.ide.step.Step}. + */ + ReadOnlyStringProperty titleProperty(); + + /** + * @return the additional detail rendered next to the {@link #titleProperty() title}, e.g. "[12/40 MiB]" for a progress bar or "2 of 5 sub-steps failed" for a + * step. Empty if there is nothing to add. + */ + ReadOnlyStringProperty detailProperty(); + + /** + * @return the secondary line rendered below the title, naming what the task is doing right now (for a step: the innermost running sub-step). Empty if the + * task has nothing more specific to report. + */ + ReadOnlyStringProperty subtitleProperty(); + + /** + * @return the {@link #titleProperty() title} and the {@link #detailProperty() detail} as a single string to display. + */ + StringExpression displayTextProperty(); + + /** + * @return the progress as a fraction between {@code 0.0} and {@code 1.0}, or {@link GuiTaskModel#INDETERMINATE} if the progress cannot be quantified. The + * value maps directly onto {@link javafx.scene.control.ProgressBar#progressProperty()}. + */ + ReadOnlyDoubleProperty progressProperty(); + + /** + * @return the current {@link TaskState}. + */ + ReadOnlyObjectProperty stateProperty(); + + /** + * @return the outcome tally of the sub-tasks below this task, rendered as chips. {@link TaskStats#NONE} for a task that has no sub-tasks. + * + * @see com.devonfw.ide.gui.progress.step.GuiStep + */ + ReadOnlyObjectProperty statsProperty(); + + /** + * @return {@code true} if the end-user may remove this task from the task list once it is {@link TaskState#isTerminal() finished}, {@code false} if it + * disappears on its own. + */ + default boolean isDismissable() { + + return false; + } + + /** + * @return the current {@link TaskState}. + */ + default TaskState getState() { + + return stateProperty().get(); + } + + /** + * @return {@code true} if this task is still {@link TaskState#RUNNING running}, {@code false} otherwise. + */ + default boolean isRunning() { + + return getState() == TaskState.RUNNING; + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/GuiTaskModel.java b/gui/src/main/java/com/devonfw/ide/gui/progress/GuiTaskModel.java new file mode 100644 index 0000000000..f41ba18d3a --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/GuiTaskModel.java @@ -0,0 +1,182 @@ +package com.devonfw.ide.gui.progress; + +import java.util.Objects; + +import javafx.beans.binding.Bindings; +import javafx.beans.binding.StringExpression; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +import com.devonfw.ide.gui.FxHelper; +import com.devonfw.ide.gui.progress.step.GuiStep; + +/** + * Holds the observable state shared by every {@link GuiTask}. + *

+ * {@link ProgressBarTask} and {@link GuiStep} must extend different CLI classes, so they cannot inherit this state from a common base class. Instead both own + * an instance of this model and delegate their {@link GuiTask} methods to it. All mutations are routed through {@link FxHelper#runFxSafe(Runnable)} because + * tasks are progressed from background threads while the properties are observed by the UI. + */ +public class GuiTaskModel { + + /** Value of {@link #progressProperty()} for a task whose progress cannot be quantified. */ + public static final double INDETERMINATE = -1.0; + + /** Value of {@link #progressProperty()} for a completed task. */ + public static final double COMPLETE = 1.0; + + private final String id; + + private final StringProperty title; + + private final StringProperty detail; + + private final StringProperty subtitle; + + private final DoubleProperty progress; + + private final ObjectProperty state; + + private final ObjectProperty stats; + + private final StringExpression displayText; + + /** + * The constructor. + * + * @param id the {@link #getId() id}. + * @param title the initial {@link #titleProperty() title}. + * @param progress the initial {@link #progressProperty() progress}, e.g. {@link #INDETERMINATE}. + */ + public GuiTaskModel(String id, String title, double progress) { + + super(); + this.id = Objects.requireNonNull(id, "id"); + this.title = new SimpleStringProperty(title); + this.detail = new SimpleStringProperty(""); + this.subtitle = new SimpleStringProperty(""); + this.progress = new SimpleDoubleProperty(progress); + this.state = new SimpleObjectProperty<>(TaskState.RUNNING); + this.stats = new SimpleObjectProperty<>(TaskStats.NONE); + // computed once and shared, so that both the status bar and the task overview render the task identically. + this.displayText = Bindings.createStringBinding(this::getDisplayText, this.title, this.detail); + } + + private String getDisplayText() { + + String currentTitle = this.title.get(); + String currentDetail = this.detail.get(); + if ((currentDetail == null) || currentDetail.isEmpty()) { + return currentTitle; + } + return currentTitle + " " + currentDetail; + } + + /** + * @return the unique id of the task. + */ + public String getId() { + + return this.id; + } + + /** + * @return the title property. + */ + public StringProperty titleProperty() { + + return this.title; + } + + /** + * @return the detail property. + */ + public StringProperty detailProperty() { + + return this.detail; + } + + /** + * @return the subtitle property. + */ + public StringProperty subtitleProperty() { + + return this.subtitle; + } + + /** + * @return the combined title and detail. + */ + public StringExpression displayTextProperty() { + + return this.displayText; + } + + /** + * @return the progress property. + */ + public DoubleProperty progressProperty() { + + return this.progress; + } + + /** + * @return the state property. + */ + public ObjectProperty stateProperty() { + + return this.state; + } + + /** + * @return the stats property. + */ + public ObjectProperty statsProperty() { + + return this.stats; + } + + /** + * @param newStats the new value of {@link #statsProperty()}. + */ + public void setStats(TaskStats newStats) { + + FxHelper.runFxSafe(() -> this.stats.set(newStats)); + } + + /** + * @param newDetail the new value of {@link #detailProperty()}. + */ + public void setDetail(String newDetail) { + + FxHelper.runFxSafe(() -> this.detail.set((newDetail == null) ? "" : newDetail)); + } + + /** + * @param newSubtitle the new value of {@link #subtitleProperty()}. + */ + public void setSubtitle(String newSubtitle) { + + FxHelper.runFxSafe(() -> this.subtitle.set((newSubtitle == null) ? "" : newSubtitle)); + } + + /** + * @param newProgress the new value of {@link #progressProperty()}. + */ + public void setProgress(double newProgress) { + + FxHelper.runFxSafe(() -> this.progress.set(newProgress)); + } + + /** + * @param newState the new value of {@link #stateProperty()}. + */ + public void setState(TaskState newState) { + + FxHelper.runFxSafe(() -> this.state.set(newState)); + } +} 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 index 7f2ac00ba1..1cc1829180 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java @@ -1,10 +1,12 @@ package com.devonfw.ide.gui.progress; -import javafx.beans.property.BooleanProperty; +import java.util.concurrent.atomic.AtomicBoolean; + +import javafx.beans.binding.StringExpression; import javafx.beans.property.LongProperty; -import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleLongProperty; -import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import org.slf4j.Logger; @@ -15,27 +17,30 @@ import com.devonfw.tools.ide.io.AbstractIdeProgressBar; /** - * This is a handler for the progress bars in the GUI + * This is a handler for the progress bars in the GUI. + *

+ * A progress bar is a quantitative, short-lived {@link GuiTask}: it removes itself from the {@link TaskManager} as soon as it is {@link #close() closed}, so it + * is never {@link #isDismissable() dismissable}. */ -public class ProgressBarTask extends AbstractIdeProgressBar { +public class ProgressBarTask extends AbstractIdeProgressBar implements GuiTask { - 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]"; + /** Format of the {@link #detailProperty() detail}, following the scheme "[current/maximum unit]". */ + public static final String DETAIL_STRING_FORMAT = "[%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 TaskManager taskManager; + + private final GuiTaskModel model; - private final LongProperty progressProperty = new SimpleLongProperty(getCurrentProgress()); + /** The raw progress in the unit reported by the CLI. Kept separate from {@link #progressProperty()}, which is the normalized fraction the UI renders. */ + private final LongProperty currentProgressProperty = new SimpleLongProperty(0); - //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()); + /** Guards against queueing more than one UI update at a time. @see #publishProgress(long) */ + private final AtomicBoolean updateScheduled = new AtomicBoolean(); + + /** The most recent progress reported by the CLI, read by the scheduled UI update. */ + private volatile long pendingProgress; /** * @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 @@ -49,8 +54,9 @@ public class ProgressBarTask extends AbstractIdeProgressBar { 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.model = new GuiTaskModel(taskId, title, isIndeterminate() ? GuiTaskModel.INDETERMINATE : 0.0); + updateDetailText(0); } /** @@ -63,79 +69,161 @@ public ProgressBarTask(TaskManager taskManager, String taskId, String title, lon */ public ProgressBarTask(TaskManager taskManager, String taskId, String title) { - super(title, 100, "%", 1); - setIndeterminate(true); - this.taskId = taskId; - this.taskManager = taskManager; + // a maximum size of -1 is how IdeProgressBar expresses "the maximum is undefined". + this(taskManager, taskId, title, -1, "%", 1); } - //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); + public String getId() { - FxHelper.runFxSafe(() -> progressProperty.setValue(getCurrentProgress())); + return this.model.getId(); } @Override - protected void doStepTo(long stepPosition) { - - LOG.debug("Updating progress bar to {}", getCurrentProgress()); + public StringProperty titleProperty() { - FxHelper.runFxSafe(() -> progressProperty.setValue(stepPosition)); + return this.model.titleProperty(); } @Override - public void close() { + public StringProperty detailProperty() { - LOG.info("Closing progress bar"); - taskManager.removeTask(this); - super.close(); + return this.model.detailProperty(); } /** - * @return true if the progress bar is indeterminate, false otherwise + * @return the subtitle, which is always empty: a progress bar has no sub-tasks to report on. */ - public boolean isIndeterminate() { - return isIndeterminate; + @Override + public StringProperty subtitleProperty() { + + return this.model.subtitleProperty(); + } + + @Override + public StringExpression displayTextProperty() { + + return this.model.displayTextProperty(); + } + + @Override + public ReadOnlyDoubleProperty progressProperty() { + + return this.model.progressProperty(); + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + + return this.model.stateProperty(); } /** - * @param indeterminate set whether the progress bar is indeterminate or not + * @return the stats, which stay {@link TaskStats#NONE}: a progress bar has no sub-tasks to tally. */ - public void setIndeterminate(boolean indeterminate) { - isIndeterminate = indeterminate; - indeterminateProperty.set(indeterminate); + @Override + public ReadOnlyObjectProperty statsProperty() { + + return this.model.statsProperty(); } /** - * @return id of the current task + * @return {@code true} if the maximum size of this progress bar is undefined so that the progress cannot be quantified, {@code false} otherwise. */ - public String getTaskId() { - return taskId; + public boolean isIndeterminate() { + + return this.maxSize <= 0; } /** * Properties are relevant for dynamically updating the ui. * - * @return progress property of this task. + * @return the raw progress of this task in its {@link #getUnitName() unit}. */ public LongProperty currentProgressProperty() { - return progressProperty; + + return this.currentProgressProperty; + } + + // currentProgress is only for test purposes, see AbstractIdeProgressBar + @Override + protected void doStepBy(long stepSize, long currentProgress) { + + publishProgress(getCurrentProgress()); + } + + @Override + protected void doStepTo(long stepPosition) { + + publishProgress(stepPosition); } /** - * @return title property of this task. + * Hands the latest progress to the UI, coalescing bursts into at most one pending update. + *

+ * Progress is reported far faster than a UI can render it - copying reads in 1 KiB chunks, so a large archive produces hundreds of thousands of calls. + * Posting every one of them to the JavaFX Application Thread floods its event queue and freezes the UI. Instead only the latest value is kept and a single + * update is scheduled; while it is outstanding, further calls just overwrite that value. The UI therefore refreshes as fast as it can drain, and no faster, + * regardless of how quickly the background thread reports. + * + * @param progress the current progress. */ - public StringProperty titleProperty() { - return titleProperty; + private void publishProgress(long progress) { + + this.pendingProgress = progress; + if (this.updateScheduled.compareAndSet(false, true)) { + FxHelper.runFxSafe(this::applyPendingProgress); + } + } + + /** + * Applies the latest progress to every value the UI renders. Runs on the JavaFX Application Thread and, thanks to the coalescing in + * {@link #publishProgress(long)}, only as often as the UI can actually draw - so writing the values eagerly here costs nothing. + *

+ * They are written rather than derived by a binding on purpose: a bound property signals its listeners only on the transition from valid to invalid, which + * does not survive the round trip through the task list's extractor reliably. Writing all values within one FX turn keeps them consistent. + */ + private void applyPendingProgress() { + + // Released before applying, so that a value arriving while we render schedules a fresh update instead of being dropped. + this.updateScheduled.set(false); + long progress = this.pendingProgress; + LOG.debug("Updating progress bar {} to {}", getId(), progress); + this.currentProgressProperty.set(progress); + if (!isIndeterminate()) { + this.model.setProgress((double) progress / this.maxSize); + } + updateDetailText(progress); + } + + private void updateDetailText(long progress) { + + if (isIndeterminate()) { + // there is no meaningful "x of y" to show - the animated bar carries the information that something is happening. + this.model.setDetail(""); + } else { + this.model.setDetail(String.format(DETAIL_STRING_FORMAT, toUnits(progress), toUnits(this.maxSize), getUnitName())); + } } /** - * @return indeterminate property of this task. + * @param rawProgress the progress as counted by the CLI (e.g. bytes). + * @return the progress expressed in {@link #getUnitName() units} (e.g. MiB), so that the number matches the displayed unit. */ - public BooleanProperty indeterminateProperty() { - return indeterminateProperty; + private long toUnits(long rawProgress) { + + if (this.unitSize <= 1) { + return rawProgress; + } + return rawProgress / this.unitSize; + } + + @Override + public void close() { + + LOG.info("Closing progress bar"); + this.model.setState(TaskState.SUCCESS); + this.taskManager.removeTask(this); + super.close(); } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/TaskState.java b/gui/src/main/java/com/devonfw/ide/gui/progress/TaskState.java new file mode 100644 index 0000000000..00fce97b1d --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/TaskState.java @@ -0,0 +1,27 @@ +package com.devonfw.ide.gui.progress; + +/** + * The lifecycle state of a {@link GuiTask}. + */ +public enum TaskState { + + /** The task is still in progress. */ + RUNNING, + + /** The task completed successfully. */ + SUCCESS, + + /** + * The task ended without success. Note that a {@link com.devonfw.tools.ide.step.Step} that is closed without an explicit outcome is recorded as a failure by + * {@link com.devonfw.tools.ide.step.StepImpl}, so this state is also reached when a step was simply never completed. + */ + FAILED; + + /** + * @return {@code true} if this state is terminal (the task will not change anymore), {@code false} for {@link #RUNNING}. + */ + public boolean isTerminal() { + + return this != RUNNING; + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/TaskStats.java b/gui/src/main/java/com/devonfw/ide/gui/progress/TaskStats.java new file mode 100644 index 0000000000..006154e95f --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/TaskStats.java @@ -0,0 +1,30 @@ +package com.devonfw.ide.gui.progress; + +/** + * The outcome tally of the sub-tasks below a {@link GuiTask}, rendered as chips in the task overview. + * + * @param running the number of sub-tasks that are still running. + * @param succeeded the number of sub-tasks that ended successfully. + * @param failed the number of sub-tasks that ended without success. + */ +public record TaskStats(int running, int succeeded, int failed) { + + /** Tally of a task that has no sub-tasks at all. */ + public static final TaskStats NONE = new TaskStats(0, 0, 0); + + /** + * @return the total number of sub-tasks seen so far. + */ + public int total() { + + return this.running + this.succeeded + this.failed; + } + + /** + * @return {@code true} if there is nothing to report, {@code false} otherwise. + */ + public boolean isEmpty() { + + return total() == 0; + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java b/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java index d4f09d57f0..06970a0d3c 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java @@ -1,35 +1,188 @@ package com.devonfw.ide.gui.progress.step; -import com.devonfw.ide.gui.context.TaskManager; +import java.util.UUID; + +import javafx.beans.binding.StringExpression; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.StringProperty; + +import com.devonfw.ide.gui.progress.GuiTask; +import com.devonfw.ide.gui.progress.GuiTaskModel; +import com.devonfw.ide.gui.progress.TaskState; +import com.devonfw.ide.gui.progress.TaskStats; import com.devonfw.tools.ide.context.AbstractIdeContext; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.step.StepImpl; -/// Implementation of {@link Step} for the GUI. -public class GuiStep extends StepImpl { +/** + * Implementation of {@link Step} for the GUI, displayed as a {@link GuiTask}. + *

+ * Unlike a {@link com.devonfw.ide.gui.progress.ProgressBarTask}, a step carries an outcome that the end-user needs to see, so it is not removed from + * the {@link com.devonfw.ide.gui.context.TaskManager} when it ends. It stays until the user dismisses it. + *

+ * Only root steps become their own task; nested steps instead contribute to the report of their {@link #getRoot() root}. The counters below are therefore only + * meaningful on a root step. + */ +public class GuiStep extends StepImpl implements GuiTask { + + private final GuiStep guiParentStep; + + private final GuiTaskModel model; + + private int childrenRunning; - private final TaskManager taskManager; + private int childrenSucceeded; + + private int childrenFailed; /** - * Creates and starts a new {@link StepImpl}. + * Creates and starts a new {@link GuiStep}. * * @param context the {@link IdeContext}. - * @param parent the {@link #getParent() parent step}. + * @param parent the {@link #getParent() parent step} or {@code null} for a root step. * @param name the {@link #getName() step name}. * @param silent the {@link #isSilent() silent flag}. * @param params the parameters. Should have reasonable {@link Object#toString() string representations}. */ - public GuiStep(TaskManager taskManager, AbstractIdeContext context, StepImpl parent, String name, boolean silent, Object... params) { + public GuiStep(AbstractIdeContext context, GuiStep parent, String name, boolean silent, Object... params) { - this.taskManager = taskManager; super(context, parent, name, silent, params); + this.guiParentStep = parent; + // a step has no quantifiable progress - it is either running or it has ended. + this.model = new GuiTaskModel(UUID.randomUUID().toString(), name, GuiTaskModel.INDETERMINATE); + } + + @Override + public String getId() { + + return this.model.getId(); + } + + @Override + public StringProperty titleProperty() { + + return this.model.titleProperty(); + } + + @Override + public StringProperty detailProperty() { + + return this.model.detailProperty(); + } + + @Override + public StringProperty subtitleProperty() { + + return this.model.subtitleProperty(); + } + + /** + * @param subtitle the name of the sub-step that is currently running below this root step, or the empty string if there is none. + */ + public void setSubtitle(String subtitle) { + + this.model.setSubtitle(subtitle); + } + + @Override + public StringExpression displayTextProperty() { + + return this.model.displayTextProperty(); + } + + @Override + public ReadOnlyDoubleProperty progressProperty() { + + return this.model.progressProperty(); + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + + return this.model.stateProperty(); } @Override - public void close() { + public ReadOnlyObjectProperty statsProperty() { + + return this.model.statsProperty(); + } + + @Override + public boolean isDismissable() { + + return true; + } + + /** + * @return the {@link #getParent() parent} as {@link GuiStep} or {@code null} if this is a root step. + */ + public GuiStep getGuiParentStep() { + + return this.guiParentStep; + } + + /** + * @return the top-most {@link GuiStep} of this step hierarchy, which is the one displayed as a task. Returns {@code this} for a root step. + */ + public GuiStep getRoot() { + + GuiStep root = this; + while (root.guiParentStep != null) { + root = root.guiParentStep; + } + return root; + } + + /** + * Records that a nested step below this root has been started. + */ + public synchronized void recordChildStart() { + + this.childrenRunning++; + updateStepStats(); + } + + /** + * Records that a nested step below this root has ended. + * + * @param success {@code true} if the nested step {@link #isSuccess() succeeded}, {@code false} otherwise. + */ + public synchronized void recordChildEnd(boolean success) { + + this.childrenRunning--; + if (success) { + this.childrenSucceeded++; + } else { + this.childrenFailed++; + } + updateStepStats(); + } + + /** + * Called exactly once when this step has ended, from {@link com.devonfw.ide.gui.context.IdeGuiContext#endStep(StepImpl)}. That is the only reliable hook: a + * step may end via {@link #success()}, {@link #error(Throwable)} or {@link #close()}, and {@link StepImpl} notifies the context on whichever path ended it, + * after the outcome has been recorded. + */ + public void onEnd() { + + boolean success = isSuccess(); + this.model.setProgress(GuiTaskModel.COMPLETE); + this.model.setState(success ? TaskState.SUCCESS : TaskState.FAILED); + GuiStep root = getRoot(); + if (root == this) { + // the whole task is done, so there is no sub-step left to name. + this.model.setSubtitle(""); + updateStepStats(); + } else { + root.recordChildEnd(success); + } + } + + private synchronized void updateStepStats() { - taskManager.removeStep(this); - super.close(); + this.model.setStats(new TaskStats(this.childrenRunning, this.childrenSucceeded, this.childrenFailed)); } } From 37e35915aa3d970ac4a32716cd1bafd1879ebef0 Mon Sep 17 00:00:00 2001 From: laim2003 Date: Wed, 19 Aug 2026 10:57:12 +0200 Subject: [PATCH 4/4] #2314: psuhed all changes to allow transparency (some changes are not yet fully verified) --- documentation/contributing/README.adoc | 5 + .../contributing/gui-task-model.adoc | 89 +++++++ .../main/java/com/devonfw/ide/gui/App.java | 2 +- .../com/devonfw/ide/gui/MainController.java | 152 ++++++----- .../ide/gui/context/IdeGuiContext.java | 84 ++++-- .../devonfw/ide/gui/context/TaskManager.java | 69 ++--- .../com/devonfw/ide/gui/progress/GuiTask.java | 7 + .../ide/gui/progress/ProgressBarTask.java | 11 + .../ide/gui/progress/step/GuiStep.java | 22 +- .../gui/progress/taskwindow/CellLayout.java | 47 ++++ .../progress/taskwindow/SubStepCellView.java | 55 ++++ .../gui/progress/taskwindow/TaskCellView.java | 111 ++++++++ .../taskwindow/TaskOverviewWindow.java | 6 +- .../TaskOverviewWindowController.java | 76 ++++-- .../taskwindow/TaskWindowCellFactory.java | 208 ++++++++++----- .../gui/{ => layout/mainview}/main-view.fxml | 35 ++- .../taskOverviewWindow/sub_step_cell.fxml | 23 ++ .../layout/taskOverviewWindow/task_cell.fxml | 32 +++ .../task_overview_window.fxml | 12 + .../devonfw/ide/gui/task_overview_window.fxml | 9 - .../java/com/devonfw/ide/gui/AppBaseTest.java | 54 +++- .../gui/context/IdeGuiContextStepTest.java | 250 ++++++++++++++++++ .../ide/gui/progress/TaskWindowTest.java | 246 ++++++++++++++++- 23 files changed, 1334 insertions(+), 271 deletions(-) create mode 100644 documentation/contributing/gui-task-model.adoc create mode 100644 gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/CellLayout.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/SubStepCellView.java create mode 100644 gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskCellView.java rename gui/src/main/resources/com/devonfw/ide/gui/{ => layout/mainview}/main-view.fxml (90%) create mode 100644 gui/src/main/resources/com/devonfw/ide/gui/layout/taskOverviewWindow/sub_step_cell.fxml create mode 100644 gui/src/main/resources/com/devonfw/ide/gui/layout/taskOverviewWindow/task_cell.fxml create mode 100644 gui/src/main/resources/com/devonfw/ide/gui/layout/taskOverviewWindow/task_overview_window.fxml delete mode 100644 gui/src/main/resources/com/devonfw/ide/gui/task_overview_window.fxml create mode 100644 gui/src/test/java/com/devonfw/ide/gui/context/IdeGuiContextStepTest.java diff --git a/documentation/contributing/README.adoc b/documentation/contributing/README.adoc index 3389ed19bd..e4dc5ae27e 100644 --- a/documentation/contributing/README.adoc +++ b/documentation/contributing/README.adoc @@ -21,3 +21,8 @@ Further, we documented link:junit-testing.adoc[JUnit-testing] and link:integrati For security mapping in URL updater development, see link:cpe-url-updater.adoc[CPE integration for URL updaters]. +== Design + +If you work on the dashboard, read link:gui-task-model.adoc[GUI task model] first. +It explains how the GUI renders progress bars and steps through a single abstraction, why the CLI is left untouched, and which parts of the design are load-bearing. + diff --git a/documentation/contributing/gui-task-model.adoc b/documentation/contributing/gui-task-model.adoc new file mode 100644 index 0000000000..b49feec6ce --- /dev/null +++ b/documentation/contributing/gui-task-model.adoc @@ -0,0 +1,89 @@ += GUI task model + +The dashboard (`gui` module) shows long-running work in two places: the status bar of the main window, and the task overview window. +This page explains how that is modelled and why, so the design is not accidentally undone by a later change. + +== One abstraction for two CLI concepts + +The CLI reports progress through two unrelated mechanisms, both owned by `IdeContext`: + +* `IdeProgressBar` -- quantitative, bounded, short-lived. Created via `newProgressBar(...)`. +* `Step` -- qualitative, nested, and carrying an outcome (success or failure). Created via `newStep(...)`. + +The GUI has to render both as "something is happening", so it defines a single UI-facing interface, `GuiTask`, with a title, a detail line, a subtitle, a +progress fraction, a `TaskState` and a `TaskStats` tally. +`ProgressBarTask` and `GuiStep` both implement it. + +They cannot share a base class: `ProgressBarTask` must extend `AbstractIdeProgressBar` and `GuiStep` must extend `StepImpl`, and Java has single inheritance. +The shared state therefore lives in `GuiTaskModel`, which both own by composition and delegate to. +This is the reason for the delegation boilerplate in those two classes -- it is not accidental, and replacing it with a common superclass is not possible. + +`TaskManager` holds exactly one `ObservableList`, so the status bar and the task overview need only one rendering path. + +== The CLI is not modified + +`IdeGuiContext` maintains its own `Step` stack rather than the one inherited from `AbstractIdeContext`, whose field is private. +It can do so because all three access points -- `newStep(boolean, String, Object...)`, `getCurrentStep()` and `endStep(StepImpl)` -- are public and overridable, +and because `StepImpl.end()` calls `context.endStep(this)` virtually and exactly once. +That call is the only reliable "step has ended" hook: a step may end through `success()`, `error()` or `close()`, and `run()` calls `close()` after `success()`, +so overriding `close()` alone is neither sufficient nor unambiguous. +Note that a step closed without an explicit outcome is recorded as a *failure*, and the GUI renders that honestly. + +== Steps and progress bars behave differently on purpose + +A progress bar removes itself from the task list when it closes. +A step does not: its outcome is the point, so it stays until the user dismisses it with the `x` button. + +Only *root* steps become their own task. +Nested steps feed their root: they contribute to its `TaskStats` chips, they name its subtitle while they run, and they are appended to its sub-task list. +Because every descendant resolves the same root via `getRoot()`, a grandchild is flattened onto the root rather than nested under its own parent -- the model is +deliberately only one level deep. + +== Why the task overview is a `TreeView` + +A root step can be expanded to reveal its sub-steps. +That could have been done by building sub-rows inside a `ListCell`, but `TreeView` avoids three problems rather than solving them: + +[cols="1,2,2"] +|=== +|Concern |Sub-rows in a `ListCell` |`TreeView` + +|Expansion state +|Must be stored on the task, because cells are recycled; a flag on the cell makes scrolling expand the wrong row +|`TreeItem.expandedProperty()` -- tree items are never recycled, only cells are + +|Observing the sub-step list +|Every cell attaches and detaches a `ListChangeListener`, with the same discipline as the binding cleanup in `updateItem` +|Cells never observe lists; the sync happens once per task in `TaskOverviewWindowController` + +|Row height +|The parent cell grows when expanded, so the list has to re-measure +|Expanding adds rows, not height +|=== + +The tree is only ever two levels deep: a hidden root holds the tasks, and each task holds its sub-steps as a flat list. +Keeping the tree in sync is cheap because the sub-step list is *append-only* -- a sub-step is never removed once started -- so no diffing is required. + +== Updates reach the UI through bindings + +Everything the UI shows is bound to the properties of the task itself: the cells in `TaskWindowCellFactory` and the status bar in `MainController`. +The task list reports which tasks exist; what happens *within* a task is observed by binding, not by reading values out of a list listener. + +There is exactly one place where a value is pushed instead of derived, and it cannot be otherwise. +`AbstractIdeProgressBar` holds its progress in a plain `long` field that is mutated on a background worker thread, so there is no observable to bind to, and +JavaFX properties may only be written on the JavaFX Application Thread. +`ProgressBarTask.publishProgress` performs that hand-off -- and *coalesces* it: copying reads in 1 KiB chunks, so a large archive reports progress hundreds of +thousands of times, and posting every one of those to the FX thread floods its event queue and freezes the UI. +Only the latest value is kept and a single update is scheduled; while it is outstanding, further reports just overwrite that value. +The UI therefore refreshes as fast as it can drain and no faster. +This is the same technique `javafx.concurrent.Task.updateProgress()` uses internally. + +`TaskWindowTest.rapidProgressUpdatesAreCoalescedWithoutLosingTheFinalValue` pins both halves of that behaviour: the number of FX events stays far below the +number of reported steps, and the final value is never dropped. + +== Concurrency + +`GuiStateManager.newRunContext()` hands every command execution its own `IdeGuiContext`. +Commands run on their own threads, and a context keeps the current step in a field, so a shared context would let two concurrent commands corrupt each other's +step hierarchy. +A per-run context makes that field safe without any locking. 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 125c37ecdd..75e55a5f0d 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/App.java +++ b/gui/src/main/java/com/devonfw/ide/gui/App.java @@ -126,7 +126,7 @@ private void reloadMainView() { private Parent loadMainView() throws IOException { - FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource("main-view.fxml")); + FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource("layout/mainview/main-view.fxml")); fxmlLoader.setResources(this.nlsService.getResourceBundle()); fxmlLoader.setController(new MainController(System.getenv(IdeVariables.IDE_ROOT.getName()), guiStateManager, this.nlsService)); return fxmlLoader.load(); 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 ba1e5783cf..1bff50cc1e 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/MainController.java +++ b/gui/src/main/java/com/devonfw/ide/gui/MainController.java @@ -7,9 +7,14 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.stream.Stream; import javafx.application.Platform; +import javafx.beans.Observable; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; import javafx.concurrent.Task; import javafx.fxml.FXML; import javafx.scene.control.Alert.AlertType; @@ -72,6 +77,9 @@ public class MainController { private ProgressBar statusProgressBar; private final double PROGRESSBAR_VISIBLE_WIDTH = 150.0; + /** Styling of the status label while it links to the {@link TaskOverviewWindow}. */ + private static final String STATUS_LINK_STYLE = "-fx-text-fill: blue;-fx-cursor: hand"; + private final String ideRootPath; private Path projectValue; private Path workspaceValue; @@ -97,30 +105,88 @@ public MainController(String ideRoot, GuiStateManager guiStateManager, NlsServic this.languageMap = new LinkedHashMap<>(); this.nlsService = nlsService; - setUpTaskListListener(); } - private void setUpTaskListListener() { + @FXML + private void initialize() { - // The task list is created with an extractor, so additions, removals and changes to a task's own properties all arrive here. - ListChangeListener taskListChangeListener = change -> { + setProjectsComboBox(); + initLanguageComboBox(); + // Rebuild only when the set of tasks changes. Everything that changes within a task reaches the UI through the bindings built below, so reacting to + // anything else here would rebuild them for no reason on every progress tick. + taskManager.getTasks().addListener((ListChangeListener) change -> { while (change.next()) { - if (change.wasAdded()) { - LOG.debug("Added: {}", change.getAddedSubList()); - } else if (change.wasRemoved()) { - LOG.debug("Removed: {}", change.getRemoved()); + if (change.wasAdded() || change.wasRemoved()) { + bindStatusBar(); + return; } } - updateStatusLabel(taskManager.getTasks()); - }; - taskManager.getTasks().addListener(taskListChangeListener); + }); + bindStatusBar(); } - @FXML - private void initialize() { + /** + * Binds the status bar to the tasks it reports on. + *

+ * The bindings depend on the properties of the tasks themselves, not on the task list, so a task progressing or finishing updates the status bar directly. + * Only adding or removing a task changes which properties matter, which is why this is rebuilt on structural changes of the list. + */ + private void bindStatusBar() { + + statusLabel.textProperty().unbind(); + statusLabel.underlineProperty().unbind(); + statusLabel.styleProperty().unbind(); + statusProgressBar.progressProperty().unbind(); + statusProgressBar.visibleProperty().unbind(); + statusProgressBar.prefWidthProperty().unbind(); + + ObservableList tasks = taskManager.getTasks(); + Observable[] dependencies = Stream.concat( + tasks.stream().flatMap(task -> Stream.of(task.stateProperty(), task.displayTextProperty(), task.progressProperty())), + Stream.of(tasks)).toArray(Observable[]::new); + + BooleanBinding singleTaskRunning = Bindings.createBooleanBinding(() -> getRunningTasks().size() == 1, dependencies); + // Whenever there is any task at all the label links to the overview, so finished ones can always be reached and dismissed. + BooleanBinding isLink = Bindings.createBooleanBinding(() -> !tasks.isEmpty(), dependencies); + + statusLabel.textProperty().bind(Bindings.createStringBinding(this::buildStatusText, dependencies)); + statusLabel.underlineProperty().bind(isLink); + statusLabel.styleProperty().bind(Bindings.when(isLink).then(STATUS_LINK_STYLE).otherwise("")); + statusLabel.setOnMouseClicked(_ -> TaskOverviewWindow.getInstance(taskManager).showRelativeToReferenceNode(statusLabel)); - setProjectsComboBox(); - initLanguageComboBox(); + // a value of GuiTaskModel.INDETERMINATE maps directly onto the indeterminate animation of the JavaFX progress bar. + statusProgressBar.progressProperty().bind(Bindings.createDoubleBinding(this::buildStatusProgress, dependencies)); + statusProgressBar.visibleProperty().bind(singleTaskRunning); + statusProgressBar.prefWidthProperty().bind(Bindings.when(singleTaskRunning).then(PROGRESSBAR_VISIBLE_WIDTH).otherwise(0.0)); + } + + /** + * @return the tasks that are still running. Finished steps stay in the list until dismissed, so the status bar reports on the running ones. + */ + private List getRunningTasks() { + + return taskManager.getTasks().stream().filter(GuiTask::isRunning).toList(); + } + + private String buildStatusText() { + + List runningTasks = getRunningTasks(); + if (runningTasks.size() > 1) { + return runningTasks.size() + " tasks running..."; + } else if (runningTasks.size() == 1) { + return runningTasks.getFirst().displayTextProperty().get(); + } + List tasks = taskManager.getTasks(); + if (!tasks.isEmpty()) { + return buildFinishedSummary(tasks); + } + return "IDEasy is ready."; + } + + private double buildStatusProgress() { + + List runningTasks = getRunningTasks(); + return (runningTasks.size() == 1) ? runningTasks.getFirst().progressProperty().get() : 0.0; } private void initLanguageComboBox() { @@ -273,25 +339,6 @@ private void updateContext(String selectedProjectName, String selectedWorkspaceN } } - private void updateStatusLabel(List taskList) { - - // runFxSafe rather than runLater: the task list notifies us on the FX thread already, so queueing another hop per progress update only adds churn. - FxHelper.runFxSafe(() -> { - // Finished steps stay in the list until the user dismisses them, so the status bar reports on the running ones. - List runningTasks = taskList.stream().filter(GuiTask::isRunning).toList(); - - if (runningTasks.size() > 1) { - showLinkStatus(runningTasks.size() + " tasks running..."); - } else if (runningTasks.size() == 1) { - showSingleTaskStatus(runningTasks.getFirst()); - } else if (!taskList.isEmpty()) { - showLinkStatus(buildFinishedSummary(taskList)); - } else { - showIdleStatus(); - } - }); - } - private String buildFinishedSummary(List taskList) { long failed = taskList.stream().filter(task -> task.getState() == TaskState.FAILED).count(); @@ -301,41 +348,4 @@ private String buildFinishedSummary(List taskList) { return String.format("%d tasks finished", taskList.size()); } - /** - * Shows a clickable status that opens the {@link TaskOverviewWindow}, used whenever a single task cannot represent the state. - */ - private void showLinkStatus(String text) { - - statusLabel.setOnMouseClicked(_ -> TaskOverviewWindow.getInstance(taskManager).showRelativeToReferenceNode(statusLabel)); - statusLabel.setText(text); - statusLabel.setUnderline(true); - statusLabel.setStyle("-fx-text-fill: blue;-fx-cursor: hand"); - - statusProgressBar.setVisible(false); - statusProgressBar.setPrefWidth(0); - } - - private void showSingleTaskStatus(GuiTask task) { - - statusLabel.setOnMouseClicked(_ -> TaskOverviewWindow.getInstance(taskManager).showRelativeToReferenceNode(statusLabel)); - statusLabel.setText(task.displayTextProperty().get()); - statusLabel.setUnderline(true); - statusLabel.setStyle("-fx-text-fill: blue;-fx-cursor: hand"); - - statusProgressBar.setVisible(true); - statusProgressBar.setPrefWidth(PROGRESSBAR_VISIBLE_WIDTH); - // a value of GuiTaskModel.INDETERMINATE maps directly onto the indeterminate animation of the JavaFX progress bar. - statusProgressBar.setProgress(task.progressProperty().get()); - } - - private void showIdleStatus() { - - statusLabel.setOnMouseClicked(null); - statusLabel.setText("IDEasy is ready."); - statusLabel.setUnderline(false); - statusLabel.setStyle(""); - - statusProgressBar.setVisible(false); - statusProgressBar.setPrefWidth(0); - } } 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 76fa22682f..30c2996060 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 @@ -3,12 +3,14 @@ import java.nio.file.Path; import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.devonfw.ide.gui.progress.ProgressBarTask; import com.devonfw.ide.gui.progress.step.GuiStep; 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.step.Step; import com.devonfw.tools.ide.step.StepImpl; /** @@ -16,8 +18,18 @@ */ public class IdeGuiContext extends AbstractIdeContext { + private static final Logger LOG = LoggerFactory.getLogger(IdeGuiContext.class); + private final TaskManager taskManager; + /** + * The innermost currently running {@link GuiStep}, or {@code null} if no step is running. The GUI creates one context per command execution, so the currently + * running step is never shared between concurrently running commands. + * + * @see GuiStateManager#newRunContext() + */ + private GuiStep currentGuiStep; + /** * The constructor. * @@ -40,45 +52,63 @@ protected String readLine() { @Override public IdeProgressBar newProgressBar(String title, long size, String unitName, long unitSize) { - ProgressBarTask newTask = new ProgressBarTask(taskManager, UUID.randomUUID().toString(), title, size, unitName, unitSize); - taskManager.addTask(newTask); + ProgressBarTask newTask = new ProgressBarTask(this.taskManager, UUID.randomUUID().toString(), title, size, unitName, unitSize); + this.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); + @Override + public GuiStep getCurrentStep() { - return newTask; + return this.currentGuiStep; } @Override - public Step newStep(String name) { - GuiStep step = new GuiStep(taskManager, this, null, name, false); - taskManager.addStep(step); - + public GuiStep newStep(boolean silent, String name, Object... parameters) { + + GuiStep parent = this.currentGuiStep; + GuiStep step = new GuiStep(this, parent, name, silent, parameters); + this.currentGuiStep = step; + if (parent == null) { + // only root steps become their own task, nested steps feed the report of their root. + this.taskManager.addTask(step); + } else { + // every descendant resolves the same root, so grandchildren land in the root's flat list rather than nesting. + step.getRoot().recordChildStart(step); + } + updateRootSubtitle(); return step; } - @Override - public Step newStep(String name, Object... parameters) { - GuiStep step = new GuiStep(taskManager, this, null, name, false, parameters); - taskManager.addStep(step); - - return step; + /** + * Names the innermost running step as the subtitle of its root, so the user sees what the task is doing right now. Only this class can do it, because the + * step stack is what identifies the innermost step - a step itself knows its parent but not which of its descendants is currently active. + */ + private void updateRootSubtitle() { + + GuiStep current = this.currentGuiStep; + if (current == null) { + return; // the root step ended and cleared its own subtitle. + } + GuiStep root = current.getRoot(); + root.setSubtitle((current == root) ? "" : current.getName()); } @Override - public StepImpl newStep(boolean silent, String name, Object... parameters) { - GuiStep step = new GuiStep(taskManager, this, null, name, silent, parameters); - taskManager.addStep(step); - - return step; + public void endStep(StepImpl step) { + + if (!(step instanceof GuiStep guiStep)) { + super.endStep(step); + return; + } + guiStep.onEnd(); + if (guiStep == this.currentGuiStep) { + this.currentGuiStep = guiStep.getGuiParentStep(); + updateRootSubtitle(); + } else { + String currentStepName = (this.currentGuiStep == null) ? "null" : this.currentGuiStep.getName(); + LOG.warn("endStep called with wrong step '{}' but expected '{}'", step.getName(), currentStepName); + } } } 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 index 9d8bf8b733..5c15f48a7d 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/TaskManager.java +++ b/gui/src/main/java/com/devonfw/ide/gui/context/TaskManager.java @@ -2,8 +2,7 @@ import java.util.Objects; -import com.devonfw.ide.gui.progress.step.GuiStep; - +import javafx.beans.Observable; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -11,39 +10,42 @@ import org.slf4j.LoggerFactory; import com.devonfw.ide.gui.FxHelper; -import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.progress.GuiTask; /** - * 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. + * Manages all tasks currently shown to the end-user and provides them as an {@link ObservableList} that UI components can observe. + *

+ * Both progress bars and steps are held in this single list as {@link GuiTask}s, so that the status bar and the task overview window need only one rendering + * path. The list is created with an extractor, so a change to a task's own properties also notifies list observers. * - * @see ProgressBarTask + * @see com.devonfw.ide.gui.progress.ProgressBarTask + * @see com.devonfw.ide.gui.progress.step.GuiStep */ public class TaskManager { - private final Logger LOG = LoggerFactory.getLogger(TaskManager.class); + private static final Logger LOG = LoggerFactory.getLogger(TaskManager.class); - private final ObservableList tasks = FXCollections.observableArrayList(); - private final ObservableList taskListReadOnly = FXCollections.unmodifiableObservableList(tasks); + private final ObservableList tasks = FXCollections.observableArrayList( + task -> new Observable[] { task.progressProperty(), task.detailProperty(), task.stateProperty() }); - private final ObservableList steps = FXCollections.observableArrayList(); - private final ObservableList stepListReadOnly = FXCollections.unmodifiableObservableList(steps); + private final ObservableList taskListReadOnly = FXCollections.unmodifiableObservableList(this.tasks); /** * Adds a task to the task list. The duplicate check and the add are performed atomically on the FX thread. Duplicate IDs are silently ignored (idempotent). * * @param task the task to be added to the list of tasks. */ - public void addTask(ProgressBarTask task) { - assert task != null; + public void addTask(GuiTask task) { + + Objects.requireNonNull(task, "task"); // Both the duplicate check and the add happen atomically on the FX thread to avoid race conditions. FxHelper.runFxSafe(() -> { - if (tasks.stream().anyMatch(t -> Objects.equals(t.getTaskId(), task.getTaskId()))) { - LOG.error("Task with ID {} already exists.", task.getTaskId()); + if (this.tasks.stream().anyMatch(t -> Objects.equals(t.getId(), task.getId()))) { + LOG.error("Task with ID {} already exists.", task.getId()); return; } - tasks.add(task); + this.tasks.add(task); }); } @@ -52,10 +54,11 @@ public void addTask(ProgressBarTask task) { * * @param task the task to be removed. */ - public void removeTask(ProgressBarTask task) { - assert task != null; + public void removeTask(GuiTask task) { - FxHelper.runFxSafe(() -> tasks.remove(task)); + Objects.requireNonNull(task, "task"); + + FxHelper.runFxSafe(() -> this.tasks.remove(task)); } /** @@ -63,34 +66,14 @@ public void removeTask(ProgressBarTask task) { */ public void clearTasks() { - FxHelper.runFxSafe(tasks::clear); + FxHelper.runFxSafe(this.tasks::clear); } /** - * @return the {@link ObservableList} of currently running tasks (read-only). + * @return the {@link ObservableList} of tasks (read-only). Contains both running tasks and finished ones that the user has not dismissed yet. */ - public ObservableList getTasks() { - - return taskListReadOnly; - } - - public void addStep(GuiStep step) { - - FxHelper.runFxSafe(() -> this.steps.add(step)); - } - - public void removeStep(GuiStep step) { - - FxHelper.runFxSafe(() -> this.steps.remove(step)); - } - - public void clearSteps() { - - FxHelper.runFxSafe(steps::clear); - } - - public ObservableList getSteps() { + public ObservableList getTasks() { - return stepListReadOnly; + return this.taskListReadOnly; } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/GuiTask.java b/gui/src/main/java/com/devonfw/ide/gui/progress/GuiTask.java index 1f86e123f8..4f74ea19e0 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/GuiTask.java +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/GuiTask.java @@ -4,6 +4,7 @@ import javafx.beans.property.ReadOnlyDoubleProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyStringProperty; +import javafx.collections.ObservableList; /** * A unit of work that is displayed to the end-user in the status bar and in the task overview window. @@ -60,6 +61,12 @@ public interface GuiTask { */ ReadOnlyObjectProperty statsProperty(); + /** + * @return the sub-tasks of this task in the order they were started, so the most recently started one is last. Empty for a task that cannot have sub-tasks. + * The list is append-only: a sub-task stays in it once it has ended, because its outcome is what the user wants to see. + */ + ObservableList getSubTasks(); + /** * @return {@code true} if the end-user may remove this task from the task list once it is {@link TaskState#isTerminal() finished}, {@code false} if it * disappears on its own. 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 index 1cc1829180..bdea5f3d04 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java @@ -8,6 +8,8 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleLongProperty; import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -127,6 +129,15 @@ public ReadOnlyObjectProperty statsProperty() { return this.model.statsProperty(); } + /** + * @return always empty: a progress bar reports a single quantity and has no sub-tasks, so it never becomes expandable. + */ + @Override + public ObservableList getSubTasks() { + + return FXCollections.emptyObservableList(); + } + /** * @return {@code true} if the maximum size of this progress bar is undefined so that the progress cannot be quantified, {@code false} otherwise. */ diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java b/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java index 06970a0d3c..58ffcac76b 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/step/GuiStep.java @@ -6,7 +6,10 @@ import javafx.beans.property.ReadOnlyDoubleProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import com.devonfw.ide.gui.FxHelper; import com.devonfw.ide.gui.progress.GuiTask; import com.devonfw.ide.gui.progress.GuiTaskModel; import com.devonfw.ide.gui.progress.TaskState; @@ -37,6 +40,14 @@ public class GuiStep extends StepImpl implements GuiTask { private int childrenFailed; + /** + * All descendants of this root, flattened to a single level and in the order they started. Like the counters above this is only populated on a root step. + * Append-only - a sub-step stays here once it has ended, because its outcome is exactly what the user expands the task to see. + */ + private final ObservableList subSteps = FXCollections.observableArrayList(); + + private final ObservableList subStepsReadOnly = FXCollections.unmodifiableObservableList(this.subSteps); + /** * Creates and starts a new {@link GuiStep}. * @@ -136,12 +147,21 @@ public GuiStep getRoot() { return root; } + @Override + public ObservableList getSubTasks() { + + return this.subStepsReadOnly; + } + /** * Records that a nested step below this root has been started. + * + * @param child the nested step. Appended to {@link #getSubTasks()}, which is what puts the most recently started step at the bottom of the list. */ - public synchronized void recordChildStart() { + public synchronized void recordChildStart(GuiStep child) { this.childrenRunning++; + FxHelper.runFxSafe(() -> this.subSteps.add(child)); updateStepStats(); } diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/CellLayout.java b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/CellLayout.java new file mode 100644 index 0000000000..5379bf9a37 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/CellLayout.java @@ -0,0 +1,47 @@ +package com.devonfw.ide.gui.progress.taskwindow; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URL; + +import javafx.fxml.FXMLLoader; +import javafx.scene.Node; + +import com.devonfw.ide.gui.App; + +/** + * Loads the FXML layout of a cell in the {@link TaskOverviewWindow}. + */ +final class CellLayout { + + /** Folder of the layouts belonging to the {@link TaskOverviewWindow}, relative to {@link App}. */ + private static final String LAYOUT_FOLDER = "layout/taskOverviewWindow/"; + + private CellLayout() { + + // static usage only + } + + /** + * Loads the given layout into the given node, which acts as both the {@code fx:root} and the controller so that the {@code @FXML} fields of the node get + * injected. + * + * @param view the node to load the layout into. + * @param fxmlName the file name of the layout, relative to the task overview window layout folder. + */ + static void load(Node view, String fxmlName) { + + URL layout = App.class.getResource(LAYOUT_FOLDER + fxmlName); + if (layout == null) { + throw new IllegalStateException("Cannot resolve layout " + LAYOUT_FOLDER + fxmlName); + } + FXMLLoader fxmlLoader = new FXMLLoader(layout); + fxmlLoader.setRoot(view); + fxmlLoader.setController(view); + try { + fxmlLoader.load(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to load layout " + layout, e); + } + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/SubStepCellView.java b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/SubStepCellView.java new file mode 100644 index 0000000000..697c7b1a75 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/SubStepCellView.java @@ -0,0 +1,55 @@ +package com.devonfw.ide.gui.progress.taskwindow; + +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.layout.HBox; + +/** + * The row of a single sub-step below an expanded task, loaded from {@code sub_step_cell.fxml}. + *

+ * This class only exposes the nodes; what they show is bound to the sub-step by {@link TaskWindowCellFactory}. + */ +public class SubStepCellView extends HBox { + + @FXML + private ProgressIndicator spinner; + + @FXML + private Label mark; + + @FXML + private Label titleLabel; + + /** + * The constructor. + */ + public SubStepCellView() { + + CellLayout.load(this, "sub_step_cell.fxml"); + } + + /** + * @return the spinner shown while the sub-step is running. + */ + public ProgressIndicator getSpinner() { + + return this.spinner; + } + + /** + * @return the label showing the ✓ or ✗ once the sub-step has ended, drawn in the same spot as the {@link #getSpinner() spinner}. + */ + public Label getMark() { + + return this.mark; + } + + /** + * @return the label showing the name of the sub-step. + */ + public Label getTitleLabel() { + + return this.titleLabel; + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskCellView.java b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskCellView.java new file mode 100644 index 0000000000..92ab8987ec --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskCellView.java @@ -0,0 +1,111 @@ +package com.devonfw.ide.gui.progress.taskwindow; + +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.scene.layout.HBox; + +/** + * The row of a top-level task in the {@link TaskOverviewWindow}, loaded from {@code task_cell.fxml}. + *

+ * This class only exposes the nodes; what they show is bound to the task by {@link TaskWindowCellFactory}. + */ +public class TaskCellView extends HBox { + + @FXML + private Label stateLabel; + + @FXML + private Label titleLabel; + + @FXML + private Label subtitleLabel; + + @FXML + private ProgressBar progressBar; + + @FXML + private Label succeededChip; + + @FXML + private Label failedChip; + + @FXML + private HBox chipBox; + + @FXML + private Button dismissButton; + + /** + * The constructor. + */ + public TaskCellView() { + + CellLayout.load(this, "task_cell.fxml"); + } + + /** + * @return the label showing the ✓ or ✗ of a finished task. + */ + public Label getStateLabel() { + + return this.stateLabel; + } + + /** + * @return the label showing the title and detail of the task. + */ + public Label getTitleLabel() { + + return this.titleLabel; + } + + /** + * @return the label naming the sub-step that is currently running. + */ + public Label getSubtitleLabel() { + + return this.subtitleLabel; + } + + /** + * @return the progress bar of the task. + */ + public ProgressBar getProgressBar() { + + return this.progressBar; + } + + /** + * @return the chip counting the successful sub-steps. + */ + public Label getSucceededChip() { + + return this.succeededChip; + } + + /** + * @return the chip counting the failed sub-steps. + */ + public Label getFailedChip() { + + return this.failedChip; + } + + /** + * @return the container of both chips. + */ + public HBox getChipBox() { + + return this.chipBox; + } + + /** + * @return the button removing a finished task from the list. + */ + public Button getDismissButton() { + + return this.dismissButton; + } +} 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 index 39474f2467..1c2a5d058c 100644 --- 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 @@ -41,7 +41,7 @@ public static TaskOverviewWindow getInstance(TaskManager taskManager) { */ private TaskOverviewWindow(TaskManager taskManager) { - FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource("task_overview_window.fxml")); + FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource("layout/taskOverviewWindow/task_overview_window.fxml")); fxmlLoader.setController(new TaskOverviewWindowController(taskManager)); Parent root; @@ -51,7 +51,9 @@ private TaskOverviewWindow(TaskManager taskManager) { throw new RuntimeException(e); } - stage.setResizable(false); + // resizable, because an expanded task with many sub-steps does not fit the default size. + stage.setMinWidth(300); + stage.setMinHeight(200); stage.setAlwaysOnTop(true); stage.setTitle("Running tasks"); stage.setScene(new Scene(root)); 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 index 19776bf04a..84eb7c4968 100644 --- 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 @@ -1,23 +1,36 @@ package com.devonfw.ide.gui.progress.taskwindow; -import javafx.beans.Observable; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; +import java.util.HashMap; +import java.util.Map; + +import javafx.collections.ListChangeListener; import javafx.fxml.FXML; -import javafx.scene.control.ListView; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; import com.devonfw.ide.gui.context.TaskManager; -import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.progress.GuiTask; /** - * Controller for the task overview window, which shows all currently running tasks and their progressbar. + * Controller for the task overview window, which shows all tasks and their progress. + *

+ * Tasks are shown in a {@link TreeView} so that a step can be expanded to reveal its sub-steps. The tree is only ever two levels deep: the hidden root holds + * the tasks, and each task holds its sub-steps as a flat list. Whether a task is expanded lives on its {@link TreeItem}, which - unlike a cell - is never + * recycled, so scrolling cannot move the expansion to a different row. */ public class TaskOverviewWindowController { @FXML - private ListView taskList; + private TreeView taskList; + private final TaskManager taskManager; + /** Never shown ({@link TreeView#setShowRoot(boolean)}), it only holds the tasks as its children. */ + private final TreeItem treeRoot = new TreeItem<>(null); + + /** Lets a task keep its {@link TreeItem}, and with it its expanded state, when the task list changes around it. */ + private final Map> itemsByTask = new HashMap<>(); + /** * @param taskManager the {@link TaskManager} to link to this TaskOverviewWindow. */ @@ -29,20 +42,41 @@ public TaskOverviewWindowController(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() - } - ); + this.taskList.setShowRoot(false); + this.taskList.setRoot(this.treeRoot); + this.taskList.setCellFactory(new TaskWindowCellFactory(this.taskManager)); - taskList.setItems(tasks); + this.taskManager.getTasks().addListener((ListChangeListener) _ -> syncTasks()); + syncTasks(); + } + + /** + * Brings the top level of the tree in line with the task list. Existing items are reused so that an expanded task stays expanded when another task is added + * or removed next to it. + */ + private void syncTasks() { + + this.itemsByTask.keySet().removeIf(task -> !this.taskManager.getTasks().contains(task)); + this.treeRoot.getChildren().setAll(this.taskManager.getTasks().stream().map(this::itemFor).toList()); + } + + private TreeItem itemFor(GuiTask task) { + + return this.itemsByTask.computeIfAbsent(task, this::createItem); + } + + private TreeItem createItem(GuiTask task) { + + TreeItem item = new TreeItem<>(task); + // Sub-tasks are append-only, so keeping the item in sync needs no diffing - and the cells never have to observe a list themselves. + task.getSubTasks().forEach(subTask -> item.getChildren().add(new TreeItem(subTask))); + task.getSubTasks().addListener((ListChangeListener) change -> { + while (change.next()) { + if (change.wasAdded()) { + change.getAddedSubList().forEach(subTask -> item.getChildren().add(new TreeItem(subTask))); + } + } + }); + return item; } } 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 index c7b943dad8..594402dbe3 100644 --- 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 @@ -1,81 +1,165 @@ 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.beans.property.ReadOnlyObjectProperty; +import javafx.scene.control.TreeCell; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; import javafx.util.Callback; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.ide.gui.progress.GuiTask; +import com.devonfw.ide.gui.progress.TaskState; +import com.devonfw.ide.gui.progress.TaskStats; /** - * Cell factory for displaying a list of tasks in the {@link TaskOverviewWindow} + * Cell factory for displaying the tasks in the {@link TaskOverviewWindow}. + *

+ * A cell renders one of two layouts, both declared as FXML. A top-level task gets {@link TaskCellView}, shared by progress bars and steps so that they look + * and behave alike; a sub-step of an expanded task gets the compact {@link SubStepCellView}. This class only binds those nodes to the task - the layout itself + * lives in the FXML. */ -public class TaskWindowCellFactory implements Callback, ListCell> { +public class TaskWindowCellFactory implements Callback, TreeCell> { + + /** Colour of a successful outcome. */ + static final String SUCCESS_COLOR = "#1e7e34"; + + /** Colour of a failed outcome. */ + static final String FAILURE_COLOR = "#c5221f"; + + private final TaskManager taskManager; - private static final Logger LOG = LoggerFactory.getLogger(TaskWindowCellFactory.class); + /** + * @param taskManager the {@link TaskManager} used to dismiss finished tasks. + */ + public TaskWindowCellFactory(TaskManager taskManager) { + + this.taskManager = taskManager; + } + + /** + * @param state the {@link TaskState}. + * @return the symbol to display for the given {@code state}. + */ + static String stateSymbol(TaskState state) { + + return switch (state) { + case SUCCESS -> "✓"; + case FAILED -> "✗"; + case RUNNING -> ""; + }; + } @Override - public ListCell call(ListView param) { - return new ListCell<>() { + public TreeCell call(TreeView param) { - final ProgressBar progressBar = new ProgressBar(); - final Label titleLabel = new Label(); - final VBox contentBox = new VBox(5, titleLabel, progressBar); + return new TreeCell<>() { - final HBox root = new HBox(10, contentBox); + private final TaskCellView taskView = new TaskCellView(); - { - HBox.setHgrow(contentBox, Priority.ALWAYS); - root.setAlignment(Pos.CENTER_LEFT); - } + private final SubStepCellView subStepView = new SubStepCellView(); @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); - } - }); + public void updateItem(GuiTask task, boolean empty) { + + super.updateItem(task, empty); + + // Cells get recycled, so previous bindings must always be released first. This has to happen synchronously - deferring it to + // Platform.runLater() would bind a recycled cell to a task it no longer displays. + unbindAll(); + + if (empty || (task == null)) { + setText(null); + setGraphic(null); + return; + } + + if (isSubStep()) { + bindSubStepRow(task); + setGraphic(this.subStepView); + } else { + bindTaskRow(task); + setGraphic(this.taskView); + } + } + + /** + * @return {@code true} if this cell shows a sub-step rather than a top-level task. The hidden root carries a {@code null} value, so a task sits directly + * below it while a sub-step sits below a task. + */ + private boolean isSubStep() { + + TreeItem item = getTreeItem(); + TreeItem parent = (item == null) ? null : item.getParent(); + return (parent != null) && (parent.getValue() != null); + } + + private void bindTaskRow(GuiTask task) { + + this.taskView.getTitleLabel().textProperty().bind(task.displayTextProperty()); + this.taskView.getSubtitleLabel().textProperty().bind(task.subtitleProperty()); + this.taskView.getSubtitleLabel().visibleProperty().bind(task.subtitleProperty().isNotEmpty()); + this.taskView.getProgressBar().progressProperty().bind(task.progressProperty()); + this.taskView.getProgressBar().visibleProperty().bind(task.stateProperty().isEqualTo(TaskState.RUNNING)); + this.taskView.getStateLabel().textProperty() + .bind(Bindings.createStringBinding(() -> stateSymbol(task.getState()), task.stateProperty())); + bindChips(task); + if (task.isDismissable()) { + this.taskView.getDismissButton().visibleProperty().bind(task.stateProperty().isNotEqualTo(TaskState.RUNNING)); + this.taskView.getDismissButton().setOnAction(_ -> TaskWindowCellFactory.this.taskManager.removeTask(task)); + } else { + this.taskView.getDismissButton().setVisible(false); + } + } + + /** + * A sub-step shows a spinner while it runs and a coloured mark once it has ended, in the same fixed-size box either way. + */ + private void bindSubStepRow(GuiTask task) { + + ReadOnlyObjectProperty state = task.stateProperty(); + this.subStepView.getTitleLabel().textProperty().bind(task.displayTextProperty()); + this.subStepView.getSpinner().visibleProperty().bind(state.isEqualTo(TaskState.RUNNING)); + this.subStepView.getMark().visibleProperty().bind(state.isNotEqualTo(TaskState.RUNNING)); + this.subStepView.getMark().textProperty().bind(Bindings.createStringBinding(() -> stateSymbol(task.getState()), state)); + this.subStepView.getMark().styleProperty().bind(Bindings.createStringBinding( + () -> "-fx-text-fill: " + ((task.getState() == TaskState.FAILED) ? FAILURE_COLOR : SUCCESS_COLOR) + ";", state)); + } + + /** + * Binds the sub-step tally to a chip per outcome. A chip only appears once its count is non-zero, so a task without sub-steps shows none at all. + */ + private void bindChips(GuiTask task) { + + ReadOnlyObjectProperty stats = task.statsProperty(); + this.taskView.getSucceededChip().textProperty().bind(Bindings.createStringBinding(() -> "✓ " + stats.get().succeeded(), stats)); + this.taskView.getSucceededChip().visibleProperty().bind(Bindings.createBooleanBinding(() -> stats.get().succeeded() > 0, stats)); + this.taskView.getFailedChip().textProperty().bind(Bindings.createStringBinding(() -> "✗ " + stats.get().failed(), stats)); + this.taskView.getFailedChip().visibleProperty().bind(Bindings.createBooleanBinding(() -> stats.get().failed() > 0, stats)); + this.taskView.getChipBox().visibleProperty() + .bind(Bindings.createBooleanBinding(() -> (stats.get().succeeded() > 0) || (stats.get().failed() > 0), stats)); + } + + private void unbindAll() { + + this.taskView.getTitleLabel().textProperty().unbind(); + this.taskView.getSubtitleLabel().textProperty().unbind(); + this.taskView.getSubtitleLabel().visibleProperty().unbind(); + this.taskView.getProgressBar().progressProperty().unbind(); + this.taskView.getProgressBar().visibleProperty().unbind(); + this.taskView.getStateLabel().textProperty().unbind(); + this.taskView.getDismissButton().visibleProperty().unbind(); + this.taskView.getDismissButton().setOnAction(null); + this.taskView.getSucceededChip().textProperty().unbind(); + this.taskView.getSucceededChip().visibleProperty().unbind(); + this.taskView.getFailedChip().textProperty().unbind(); + this.taskView.getFailedChip().visibleProperty().unbind(); + this.taskView.getChipBox().visibleProperty().unbind(); + this.subStepView.getTitleLabel().textProperty().unbind(); + this.subStepView.getSpinner().visibleProperty().unbind(); + this.subStepView.getMark().visibleProperty().unbind(); + this.subStepView.getMark().textProperty().unbind(); + this.subStepView.getMark().styleProperty().unbind(); } }; } diff --git a/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml b/gui/src/main/resources/com/devonfw/ide/gui/layout/mainview/main-view.fxml similarity index 90% rename from gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml rename to gui/src/main/resources/com/devonfw/ide/gui/layout/mainview/main-view.fxml index 320f7abadb..935ab17f9b 100644 --- a/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml +++ b/gui/src/main/resources/com/devonfw/ide/gui/layout/mainview/main-view.fxml @@ -1,9 +1,16 @@ - - - - + + + + + + + + + + + @@ -30,7 +37,7 @@ fitWidth="200.0" pickOnBounds="true" preserveRatio="true"> - + @@ -116,7 +123,7 @@ prefHeight="100.0" prefWidth="150.0" spacing="20.0" - stylesheets="@style/center.css" + stylesheets="@../../style/center.css" styleClass="ideElement"> - +