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 e4c7d092c0..8f37278c41 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/App.java +++ b/gui/src/main/java/com/devonfw/ide/gui/App.java @@ -34,31 +34,37 @@ public class App extends Application { @Override public void start(Stage primaryStage) throws IOException { + try { + + TestGuiConfiguration.applyConfigOverrides(); - this.primaryStage = primaryStage; + this.primaryStage = primaryStage; - this.nlsService = new NlsService(null); + this.nlsService = new NlsService(null); - Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { - LOG.error("Uncaught exception in thread {}: {}", thread.getName(), throwable.getMessage(), throwable); - Platform.runLater(() -> new IdeDialog(IdeDialog.AlertType.ERROR, throwable.getMessage()).showAndWait()); - } - ); + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + LOG.error("Uncaught exception in thread {}: {}", thread.getName(), throwable.getMessage(), throwable); + Platform.runLater(() -> new IdeDialog(IdeDialog.AlertType.ERROR, throwable.getMessage()).showAndWait()); + } + ); - root = loadMainView(); + root = loadMainView(); - this.nlsService.addLocaleChangeListener(this::reloadMainView); + this.nlsService.addLocaleChangeListener(this::reloadMainView); - Rectangle2D bounds = Screen.getPrimary().getVisualBounds(); - Scene scene = new Scene(root, bounds.getWidth() / 2, bounds.getHeight() / 2); + Rectangle2D bounds = Screen.getPrimary().getVisualBounds(); + Scene scene = new Scene(root, bounds.getWidth() / 2, bounds.getHeight() / 2); - Image icon = new Image("com/devonfw/ide/gui/assets/devonfw.png"); - primaryStage.getIcons().add(icon); - primaryStage.setTitle("IDEasy - version " + IdeVersion.getVersionString()); - primaryStage.setScene(scene); - primaryStage.setMinWidth(scene.getWidth()); - primaryStage.setMinHeight(scene.getHeight()); - primaryStage.show(); + Image icon = new Image("com/devonfw/ide/gui/assets/devonfw.png"); + primaryStage.getIcons().add(icon); + primaryStage.setTitle("IDEasy - version " + IdeVersion.getVersionString()); + primaryStage.setScene(scene); + primaryStage.setMinWidth(scene.getWidth()); + primaryStage.setMinHeight(scene.getHeight()); + primaryStage.show(); + } catch (Throwable t) { + new IdeDialog(IdeDialog.AlertType.ERROR, "Failed to start IDEasy GUI: " + t.getMessage()).showAndWait(); + } } @Override 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 11e0f43993..d35328760b 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/MainController.java +++ b/gui/src/main/java/com/devonfw/ide/gui/MainController.java @@ -9,6 +9,8 @@ import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.layout.StackPane; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,6 +19,8 @@ import com.devonfw.ide.gui.context.ProjectManager; import com.devonfw.ide.gui.modal.IdeDialog; import com.devonfw.ide.gui.nls.NlsService; +import com.devonfw.ide.gui.update.UpdateController; +import com.devonfw.ide.gui.update.UpgradeController; /** * Controller of the main screen of the dashboard GUI. @@ -31,6 +35,9 @@ public class MainController { @FXML private ComboBox selectedProject; + @FXML + private Label projectHeaderLabel; + @FXML private ComboBox selectedWorkspace; @@ -49,30 +56,75 @@ public class MainController { @FXML private Button vsCodeOpen; - private final String directoryPath; + @FXML + private StackPane updateIndicator; - private final Map languageMap; + @FXML + private StackPane upgradeIndicator; + + private final UpdateController updateController; + + private final UpgradeController upgradeController; private final NlsService nlsService; + private final String directoryPath; + + private final Map languageMap; /** * Constructor */ public MainController(String directoryPath, NlsService nlsService) { + this(directoryPath, IdeGuiStateManager.getInstance().getProjectManager(), new UpdateController(IdeGuiStateManager.getInstance(), nlsService), + new UpgradeController(IdeGuiStateManager.getInstance(), nlsService), nlsService); + } + + /** + * Constructor with injected dependencies. + * + * @param directoryPath IDE root path + * @param projectManager the project manager to use + * @param updateController update controller to use for project related update actions + * @param upgradeController upgrade controller to use for IDEasy upgrade actions + */ + public MainController(String directoryPath, ProjectManager projectManager, UpdateController updateController, UpgradeController upgradeController, + NlsService nlsService) { LOG.debug("IDE_ROOT path={}", directoryPath); this.directoryPath = directoryPath; this.languageMap = new LinkedHashMap<>(); this.nlsService = nlsService; + this.projectManager = projectManager; + this.updateController = updateController; + this.upgradeController = upgradeController; - this.projectManager = IdeGuiStateManager.getInstance().getProjectManager(); } @FXML private void initialize() { setProjectsComboBox(); initLanguageComboBox(); + initUpgradeAndUpdateCheck(); + } + + private void initUpgradeAndUpdateCheck() { + try { + this.updateController.start(this.updateIndicator); + if (this.upgradeController != null) { + // Pass the indicator to the upgrade controller which will manage its visibility and dialog. + this.upgradeController.start(this.upgradeIndicator); + } + } catch (Exception e) { + LOG.debug("Failed to start update controller", e); + if (this.updateIndicator != null) { + this.updateIndicator.setVisible(false); + } + if (this.upgradeController != null && this.upgradeIndicator != null) { + // if upgrade controller failed to start, ensure indicator hidden + this.upgradeIndicator.setVisible(false); + } + } } private void initLanguageComboBox() { @@ -148,7 +200,6 @@ private void openVsCode() { openIDE("vscode"); } - private void setProjectsComboBox() { assert (directoryPath != null) : "directoryPath is null! Please check the setup of your environment variables (IDE_ROOT)"; @@ -164,11 +215,14 @@ private void setProjectsComboBox() { selectedWorkspace.setDisable(false); }); + selectedProject.getSelectionModel().selectedItemProperty().addListener((obs, oldVal, newVal) -> { + projectHeaderLabel.setText(newVal != null ? newVal : ""); + }); } private void setWorkspaceComboBox() { - List workspaces = null; + List workspaces; try { workspaces = projectManager.getWorkspaceNames(selectedProject.getValue()); } catch (NotDirectoryException e) { @@ -179,12 +233,18 @@ private void setWorkspaceComboBox() { selectedWorkspace.getItems().addAll(workspaces); selectedWorkspace.setOnAction(actionEvent -> { - updateContext(selectedProject.getValue(), selectedWorkspace.getValue()); - androidStudioOpen.setDisable(false); - eclipseOpen.setDisable(false); - intellijOpen.setDisable(false); - vsCodeOpen.setDisable(false); + if (updateContext(selectedProject.getValue(), selectedWorkspace.getValue())) { + androidStudioOpen.setDisable(false); + eclipseOpen.setDisable(false); + intellijOpen.setDisable(false); + vsCodeOpen.setDisable(false); + + if (this.updateController != null) { + this.updateController.onContextChanged(IdeGuiStateManager.getInstance().getCurrentContext()); + } + // no-op: manual check button removed + } }); } @@ -198,12 +258,16 @@ private void openIDE(String inIde) { .run(); } - private void updateContext(String selectedProjectName, String selectedWorkspaceName) { + private boolean updateContext(String selectedProjectName, String selectedWorkspaceName) { try { IdeGuiStateManager.getInstance().switchContext(selectedProjectName, selectedWorkspaceName); + return true; } catch (FileNotFoundException e) { + IdeGuiStateManager.getInstance().clearCurrentContext(); IdeDialog errorDialog = new IdeDialog(IdeDialog.AlertType.ERROR, e.getMessage()); errorDialog.showAndWait(); + // no-op: manual check button removed + return false; } } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/TestGuiConfiguration.java b/gui/src/main/java/com/devonfw/ide/gui/TestGuiConfiguration.java new file mode 100644 index 0000000000..3f9670fd23 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/TestGuiConfiguration.java @@ -0,0 +1,61 @@ +package com.devonfw.ide.gui; + +import com.devonfw.ide.gui.update.UpdateService; +import com.devonfw.tools.ide.version.IdeVersion; + +/** + * Centralizes test/development configuration overrides via environment variables and system properties. + *

+ * Supported overrides: + *

    + *
  • {@code IDE_VERSION}: Override the IDEasy version for testing version checks/upgrades
  • + *
  • {@code IDE_UPDATE_AVAILABLE}: Set to "true" to force update availability (for testing update flows)
  • + *
+ *

+ * Usage: {@code IDE_UPDATE_AVAILABLE=true IDE_VERSION=1.0.0 java -jar ideasy-gui.jar} + * Or: {@code java -DIDE_UPDATE_AVAILABLE=true -DIDE_VERSION=1.0.0 -jar ideasy-gui.jar} + */ +public class TestGuiConfiguration { + + private TestGuiConfiguration() { + // Utility class, no instances + } + + /** + * Applies all test/development configuration overrides from environment variables and system properties. + * Call this early in the application lifecycle (before creating controllers). + */ + public static void applyConfigOverrides() { + applyVersionOverride(); + applyUpdateAvailabilityOverride(); + } + + /** + * Applies IDE_VERSION override. If set, causes version checks and upgrades to use the mocked version. + */ + private static void applyVersionOverride() { + String versionOverride = System.getenv("IDE_VERSION"); + if ((versionOverride == null) || versionOverride.isBlank()) { + versionOverride = System.getProperty("IDE_VERSION"); + } + if ((versionOverride != null) && !versionOverride.isBlank()) { + IdeVersion.setMockVersionForTesting(versionOverride.trim()); + } + } + + /** + * Applies IDE_UPDATE_AVAILABLE override. If set to "true", update checks will report that an update is available + * (useful for testing the update flow without requiring actual updates to be present). + */ + private static void applyUpdateAvailabilityOverride() { + String updateOverride = System.getenv("IDE_UPDATE_AVAILABLE"); + if ((updateOverride == null) || updateOverride.isBlank()) { + updateOverride = System.getProperty("IDE_UPDATE_AVAILABLE"); + } + if ((updateOverride != null) && !updateOverride.isBlank()) { + boolean isAvailable = "true".equalsIgnoreCase(updateOverride.trim()); + UpdateService.setMockUpdateAvailable(isAvailable); + } + } +} + diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiStateManager.java b/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiStateManager.java index 070b0e90a0..cbe7c4a3b7 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiStateManager.java +++ b/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiStateManager.java @@ -58,6 +58,17 @@ public static IdeGuiStateManager getInstance() { return Holder.INSTANCE; } + /** + * Expose the internal start context used to create {@link com.devonfw.ide.gui.context.IdeGuiContext}s. This is needed by the GUI to perform global checks + * (like checking for IDEasy upgrades) without a selected project context. + * + * @return the {@link com.devonfw.tools.ide.context.IdeStartContextImpl} used by the GUI. + */ + public IdeStartContextImpl getStartContext() { + + return this.startContext; + } + /** * USE WITH CARE. * This method is used in cases where the IDE_ROOT environment variable is not set, e.g. in test contexts on GitHub actions. This method will retrieve the @@ -107,6 +118,14 @@ public synchronized IdeGuiContext switchContext(String projectName, String works return this.currentContext; } + /** + * Clears the currently selected project context. + */ + public synchronized void clearCurrentContext() { + + this.currentContext = null; + } + /** * @return the current {@link IdeGuiContext} based on the selected project. is null, if no context has been set via switchContext. */ diff --git a/gui/src/main/java/com/devonfw/ide/gui/tray/TrayNotificationService.java b/gui/src/main/java/com/devonfw/ide/gui/tray/TrayNotificationService.java new file mode 100644 index 0000000000..02050ca552 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/tray/TrayNotificationService.java @@ -0,0 +1,83 @@ +package com.devonfw.ide.gui.tray; + +import java.awt.GraphicsEnvironment; +import java.awt.Image; +import java.awt.SystemTray; +import java.awt.Toolkit; +import java.awt.TrayIcon; +import java.io.InputStream; + +/** + * Utility service to show tray notifications. Extracted to avoid duplicated logic. + */ +public final class TrayNotificationService { + + private static final String TRAY_ICON_RESOURCE = "/com/devonfw/ide/gui/assets/devonfw.png"; + + private TrayNotificationService() { + // utility + } + + public static void show(String caption, String text, Runnable onClick) { + try { + if (GraphicsEnvironment.isHeadless()) { + return; + } + if (!SystemTray.isSupported()) { + return; + } + + SystemTray tray = SystemTray.getSystemTray(); + Image image = loadTrayImage(); + TrayIcon trayIcon = new TrayIcon(image, "IDEasy"); + trayIcon.setImageAutoSize(true); + + if (onClick != null) { + trayIcon.addActionListener(_e -> { + try { + onClick.run(); + } catch (Throwable t) { + // ignore listener exceptions + } + }); + } + + tray.add(trayIcon); + trayIcon.displayMessage(caption, text, TrayIcon.MessageType.INFO); + + // cleanup after delay + Thread cleanup = new Thread(() -> { + try { + Thread.sleep(8000L); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + try { + tray.remove(trayIcon); + } catch (Exception ignored) { + } + }, "ide-gui-tray-cleanup"); + cleanup.setDaemon(true); + cleanup.start(); + } catch (Throwable t) { + // best-effort only; swallow errors + } + } + + private static Image loadTrayImage() { + Image image = null; + try (InputStream in = TrayNotificationService.class.getResourceAsStream(TRAY_ICON_RESOURCE)) { + if (in != null) { + image = Toolkit.getDefaultToolkit().createImage(in.readAllBytes()); + } + } catch (Exception e) { + // ignore + } + if (image == null) { + image = Toolkit.getDefaultToolkit().createImage(new byte[0]); + } + return image; + } +} + + diff --git a/gui/src/main/java/com/devonfw/ide/gui/update/TasksHelper.java b/gui/src/main/java/com/devonfw/ide/gui/update/TasksHelper.java new file mode 100644 index 0000000000..1025ded838 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/update/TasksHelper.java @@ -0,0 +1,42 @@ +package com.devonfw.ide.gui.update; + +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.concurrent.Task; + +/** + * Shared helper to run blocking work off the JavaFX Application Thread and marshal the result (or failure) back onto it. + */ +final class TasksHelper { + + private TasksHelper() { + + } + + /** + * Runs {@code work} on a new daemon thread and invokes {@code onSuccess} or {@code onFailure} on the JavaFX Application Thread once it completes. + * + * @param the result type of the work. + * @param work the blocking work to perform off the FX thread. + * @param onSuccess callback invoked on the FX thread with the result if {@code work} completes normally. + * @param onFailure callback invoked on the FX thread with the exception if {@code work} throws. + * @param threadName the name of the background thread (for diagnostics). + */ + static void run(Supplier work, Consumer onSuccess, Consumer onFailure, String threadName) { + + Task task = new Task<>() { + + @Override + protected T call() { + return work.get(); + } + }; + + task.setOnSucceeded(ignored -> onSuccess.accept(task.getValue())); + task.setOnFailed(ignored -> onFailure.accept(task.getException())); + + Thread thread = new Thread(task, threadName); + thread.setDaemon(true); + thread.start(); + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/update/UpdateController.java b/gui/src/main/java/com/devonfw/ide/gui/update/UpdateController.java new file mode 100644 index 0000000000..5c7c11a8e3 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/update/UpdateController.java @@ -0,0 +1,334 @@ +package com.devonfw.ide.gui.update; + +import javafx.animation.PauseTransition; +import javafx.application.Platform; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.StackPane; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.util.Duration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.context.IdeGuiContext; +import com.devonfw.ide.gui.context.IdeGuiStateManager; +import com.devonfw.ide.gui.modal.IdeDialog; +import com.devonfw.ide.gui.nls.NlsService; +import com.devonfw.ide.gui.tray.TrayNotificationService; + +/** + * Handles project-specific update logic: checking whether the selected project needs an update and running the project update commandlet. + */ +public class UpdateController { + + private static final Logger LOG = LoggerFactory.getLogger(UpdateController.class); + + private static final String THREAD_UPDATE_CHECKER = "ide-gui-update-checker"; + private static final String THREAD_UPDATE_RUNNER = "ide-gui-update-runner"; + + private static final String STATUS_KEY_SELECT_PROJECT = "status.update.selectProject"; // "Select a project to check for updates" + private static final String STATUS_KEY_CHECKING = "status.update.checking"; // "Update status: checking..." + private static final String STATUS_KEY_AVAILABLE = "status.update.available"; // "Update available" + private static final String STATUS_KEY_UP_TO_DATE = "status.update.upToDate"; // "Up to date" + private static final String STATUS_KEY_UPDATING = "status.update.updating"; // "Updating..." + private static final String STATUS_KEY_COMPLETED = "status.update.completed"; // "Update completed" + private static final String STATUS_KEY_UNAVAILABLE = "status.update.unavailable"; // "Update status unavailable" + private static final String STATUS_KEY_FAILED_PREFIX = "status.update.failedPrefix"; // "Update failed: " (prefix, detail text appended) + + private static final String DIALOG_KEY_COMPLETED = "dialog.update.completed"; // "Update completed" + + private static final String TRAY_KEY_CAPTION = "tray.update.caption"; // "IDEasy updates available" + private static final String TRAY_KEY_TEXT = "tray.update.text"; // "Click the IDEasy dashboard to apply updates." + private static final String TOOLTIP_KEY_AVAILABLE = "tooltip.update.available"; // "Update available - click the icon to view details" + private static final String BUTTON_KEY_UPDATE = "button.update"; // "Update" + + private static final double POST_UPDATE_RECHECK_DELAY_MILLIS = 500d; + + private final IdeGuiStateManager manager; + private final NlsService nlsService; + private final UpdateService updateService; + + private StackPane updateIndicator; + private Stage dialogStage; + + @FXML + private Label statusLabel; + + @FXML + private Button upgradeButton; + + private IdeGuiContext currentContext; + + /** Whether the last known status is "update available" (drives the dialog's button state on open). */ + private boolean updateAvailable; + + /** The last rendered status text, kept so {@link #showDialog()} can paint it once the label becomes available (it is null until the dialog first loads). */ + private String currentStatusText = ""; + + /** + * The constructor. + * + * @param manager the {@link IdeGuiStateManager} to use. + * @param nlsService the {@link NlsService} to use. + */ + public UpdateController(IdeGuiStateManager manager, NlsService nlsService) { + this(manager, nlsService, new UpdateService()); + } + + /** + * The constructor with an injectable {@link UpdateService}. + * + * @param manager the {@link IdeGuiStateManager} to use. + * @param nlsService the {@link NlsService} to use. + * @param updateService the {@link UpdateService} encapsulating the update business logic. + */ + public UpdateController(IdeGuiStateManager manager, NlsService nlsService, UpdateService updateService) { + this.manager = manager; + this.nlsService = nlsService; + this.updateService = updateService; + } + + /** + * Start the update controller: wire the indicator and initialize the status based on the current project context. + * + * @param updateIndicator the indicator shown next to the project combo box + */ + public void start(StackPane updateIndicator) { + this.updateIndicator = updateIndicator; + try { + if (this.updateIndicator != null) { + this.updateIndicator.setVisible(false); + Tooltip.install(this.updateIndicator, new Tooltip(nlsService.get(TOOLTIP_KEY_AVAILABLE))); + this.updateIndicator.setOnMouseClicked(ev -> { + ev.consume(); + Platform.runLater(this::showDialog); + }); + } + } catch (Throwable t) { + LOG.debug("Failed to initialize update indicator", t); + } + onContextChanged(this.manager.getCurrentContext()); + } + + /** + * Called when the user clicks the update button in the shared dialog. + */ + @FXML + private void onUpgradeClicked() { + IdeGuiContext context = this.currentContext; + if (context == null) { + showStatus(STATUS_KEY_SELECT_PROJECT); + setUpdateButtonDisabled(true); + return; + } + + setUpdatingState(); + + TasksHelper.run(() -> { + this.updateService.runUpdate(context); + return null; + }, ignored -> { + showUpdateCompleted(); + + try { + // show a notification dialog but do not allow failures here to prevent + // breaking the update flow if the dialog fails to show + new IdeDialog(IdeDialog.AlertType.INFORMATION, this.nlsService.get(DIALOG_KEY_COMPLETED)).show(); + } catch (Throwable t) { + LOG.debug("Failed to show completion dialog", t); + } + // Delay the post-update re-check slightly so the UI shows "Update completed" + // for a brief moment before switching to the final status. + scheduleDelayedRecheck(); + }, throwable -> { + if (throwable == null) { + throwable = new RuntimeException("Update failed"); + } + + String detail = throwable.getMessage(); + if ((detail == null) || detail.isBlank()) { + detail = throwable.getClass().getSimpleName(); + } + showUpdateFailed(detail); + + try { + new IdeDialog(IdeDialog.AlertType.ERROR, this.nlsService.get(STATUS_KEY_FAILED_PREFIX) + detail).show(); + } catch (Throwable ex) { + LOG.debug("Failed to show failure dialog", ex); + } + }, THREAD_UPDATE_RUNNER); + } + + /** + * Called by the GUI when the selected project/workspace context changes. + * + * @param currentContext the new current project context, or {@code null} if no project is selected yet. + */ + public void onContextChanged(IdeGuiContext currentContext) { + + this.currentContext = currentContext; + if (currentContext == null) { + showStatus(STATUS_KEY_SELECT_PROJECT); + setUpdateButtonDisabled(true); + if (this.updateIndicator != null) { + this.updateIndicator.setVisible(false); + } + return; + } + + showStatus(STATUS_KEY_CHECKING); + setUpdateButtonDisabled(true); + // perform an initial check automatically + startUpdateCheck(currentContext); + } + + /** + * Perform the project update check asynchronously and notify via the JavaFX thread. + */ + private void startUpdateCheck(IdeGuiContext context) { + + TasksHelper.run(() -> this.updateService.isUpdateAvailable(context), available -> { + if (this.currentContext == context) { + applyCheckResult(available); + } + }, throwable -> { + LOG.warn("Update check failed", throwable); + showUpdateFailed(nlsService.get(STATUS_KEY_UNAVAILABLE)); + }, THREAD_UPDATE_CHECKER); + } + + private void applyCheckResult(boolean updateAvailable) { + showStatus(updateAvailable ? STATUS_KEY_AVAILABLE : STATUS_KEY_UP_TO_DATE); + setUpdateButtonDisabled(!updateAvailable); + if (this.updateIndicator != null) { + this.updateIndicator.setVisible(updateAvailable); + } + + if (updateAvailable) { + showTrayNotification(); + } + } + + private void setUpdatingState() { + setUpdateButtonDisabled(true); + showStatus(STATUS_KEY_UPDATING); + } + + private void showUpdateCompleted() { + showStatus(STATUS_KEY_COMPLETED); + setUpdateButtonDisabled(true); + } + + private void showUpdateFailed(String detail) { + showStatusFailed(detail); + setUpdateButtonDisabled(false); + } + + /** + * Shows a plain localized status and records whether it represents "update available" (for the dialog's button state). + */ + private void showStatus(String key) { + this.updateAvailable = STATUS_KEY_AVAILABLE.equals(key); + setLabelText(this.nlsService.get(key)); + } + + /** + * Shows the failed-status prefix with an appended (not localized) detail, e.g. an exception message. + */ + private void showStatusFailed(String detail) { + this.updateAvailable = false; + String text = this.nlsService.get(STATUS_KEY_FAILED_PREFIX); + if (detail != null) { + text = text + detail; + } + setLabelText(text); + } + + private void setLabelText(String text) { + this.currentStatusText = text; + if (this.statusLabel != null) { + this.statusLabel.setText(text); + } + } + + private void setUpdateButtonDisabled(boolean disabled) { + if (this.upgradeButton != null) { + this.upgradeButton.setDisable(disabled); + } + } + + private void showTrayNotification() { + try { + TrayNotificationService.show(this.nlsService.get(TRAY_KEY_CAPTION), this.nlsService.get(TRAY_KEY_TEXT), () -> Platform.runLater(this::showDialog)); + } catch (Throwable t) { + LOG.debug("Failed to show tray notification", t); + } + } + + private void showDialog() { + try { + if (this.dialogStage == null) { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/devonfw/ide/gui/upgrade-dialog.fxml")); + loader.setResources(nlsService.getResourceBundle()); + loader.setController(this); + Parent root = loader.load(); + + this.dialogStage = new Stage(); + this.dialogStage.setTitle(nlsService.get(TRAY_KEY_CAPTION)); + this.dialogStage.initModality(Modality.APPLICATION_MODAL); + this.dialogStage.setScene(new Scene(root)); + this.dialogStage.setWidth(420); + this.dialogStage.setHeight(160); + this.dialogStage.setMinWidth(360); + this.dialogStage.setMinHeight(140); + this.dialogStage.setResizable(true); + } + + // repaint now that the label/button are bound: they may have missed status updates issued before the dialog was first loaded + setLabelText(this.currentStatusText); + if (this.upgradeButton != null) { + this.upgradeButton.setText(nlsService.get(BUTTON_KEY_UPDATE)); + this.upgradeButton.setDisable(!this.updateAvailable); + } + + if (this.dialogStage.isShowing()) { + this.dialogStage.toFront(); + } else { + this.dialogStage.show(); + } + } catch (Throwable t) { + LOG.debug("Failed to show update dialog", t); + } + } + + private void scheduleDelayedRecheck() { + try { + PauseTransition pause = new PauseTransition(Duration.millis(POST_UPDATE_RECHECK_DELAY_MILLIS)); + pause.setOnFinished(event -> { + LOG.debug("Pause finished, scheduling post-update recheck: {}", event); + try { + startUpdateCheck(this.currentContext); + } catch (Throwable t) { + LOG.debug("Failed to start delayed post-update update check", t); + } + }); + pause.play(); + } catch (Throwable t) { + LOG.debug("Failed to schedule delayed re-check", t); + + // fallback to immediate check + try { + onContextChanged(this.currentContext); + } catch (Throwable ex) { + LOG.debug("Fallback startUpdateCheck failed", ex); + } + } + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/update/UpdateService.java b/gui/src/main/java/com/devonfw/ide/gui/update/UpdateService.java new file mode 100644 index 0000000000..cfabe1196f --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/update/UpdateService.java @@ -0,0 +1,96 @@ +package com.devonfw.ide.gui.update; + +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.context.IdeGuiContext; +import com.devonfw.tools.ide.commandlet.UpdateCommandlet; +import com.devonfw.tools.ide.git.GitContext; +import com.devonfw.tools.ide.migration.IdeMigrator; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * Encapsulates the project-update business logic (checking for and running settings/migration updates for the selected project), independent of any UI + * framework. + */ +public class UpdateService { + + private static final Logger LOG = LoggerFactory.getLogger(UpdateService.class); + + // Mock override for testing/dev runs: if set (non-null), isUpdateAvailable will return this value instead of performing real checks. + private static Boolean mockUpdateAvailable = null; + + /** + * Sets a mock update availability for testing/dev runs. If set to a non-null value, {@link #isUpdateAvailable(IdeGuiContext)} will return that value + * instead of performing real update checks. Pass null to disable the mock and use real checks. + * + * @param available true to indicate update is available, false for no update, null to disable mock + */ + public static void setMockUpdateAvailable(Boolean available) { + mockUpdateAvailable = available; + } + + /** + * @param context the current project context, may be {@code null} if no project is selected. + * @return {@code true} if any project update (settings update or migration) is available. + */ + public boolean isUpdateAvailable(IdeGuiContext context) { + + if (mockUpdateAvailable != null) { + return mockUpdateAvailable; + } + + if (context == null) { + return false; + } + + boolean updateAvailable = false; + + try { + updateAvailable = checkSettingsUpdate(context); + } catch (Exception e) { + LOG.debug("Failed to check settings repository update", e); + } + + try { + updateAvailable = updateAvailable || checkProjectMigration(context); + } catch (Exception e) { + LOG.debug("Failed to check project migration status", e); + } + + return updateAvailable; + } + + /** + * Runs the project update commandlet against the given context. + * + * @param context the current project context. + */ + public void runUpdate(IdeGuiContext context) { + + context.getCommandletManager().getCommandlet(UpdateCommandlet.class).run(); + } + + private boolean checkSettingsUpdate(IdeGuiContext context) { + + Path settingsRepository = context.getSettingsGitRepository(); + if (settingsRepository == null) { + return false; + } + + GitContext gitContext = context.getGitContext(); + return gitContext.isRepositoryUpdateAvailable(settingsRepository, context.getSettingsCommitIdPath()) + || (gitContext.fetchIfNeeded(settingsRepository) + && gitContext.isRepositoryUpdateAvailable(settingsRepository, context.getSettingsCommitIdPath())); + } + + private boolean checkProjectMigration(IdeGuiContext context) { + + IdeMigrator migrator = new IdeMigrator(); + VersionIdentifier projectVersion = context.getProjectVersion(); + VersionIdentifier targetVersion = migrator.getTargetVersion(); + return projectVersion.isLess(targetVersion); + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/update/UpgradeController.java b/gui/src/main/java/com/devonfw/ide/gui/update/UpgradeController.java new file mode 100644 index 0000000000..92a8633b97 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/update/UpgradeController.java @@ -0,0 +1,280 @@ +package com.devonfw.ide.gui.update; + +import java.text.MessageFormat; +import javafx.application.Platform; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.StackPane; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.context.IdeGuiStateManager; +import com.devonfw.ide.gui.nls.NlsService; +import com.devonfw.ide.gui.tray.TrayNotificationService; + +/** + * Controller for tool-wide IDEasy upgrades. Keeps upgrade checks separated from project updates. + */ +public class UpgradeController { + + private static final Logger LOG = LoggerFactory.getLogger(UpgradeController.class); + + private static final String THREAD_CHECKER = "ide-gui-upgrade-checker"; + private static final String THREAD_RUNNER = "ide-gui-upgrade-runner"; + + private static final String STATUS_KEY_CHECKING = "status.upgrade.checking"; // "Upgrade status: checking..." + private static final String STATUS_KEY_AVAILABLE = "status.upgrade.available"; // "Version {0} needs to be updated to {1}." + private static final String STATUS_KEY_UP_TO_DATE = "status.upgrade.upToDate"; // "Version {0} is the latest version." + private static final String STATUS_KEY_UPDATED = "status.upgrade.updated"; // "IDEasy updated to latest version: {0}" + private static final String STATUS_KEY_UPDATING = "status.upgrade.updating"; // "Upgrading IDEasy..." + private static final String STATUS_KEY_UNAVAILABLE = "status.upgrade.unavailable"; // "Upgrade status unavailable" + private static final String STATUS_KEY_FAILED_PREFIX = "status.upgrade.failedPrefix"; // "Upgrade failed: " (prefix, detail text appended) + private static final String TOOLTIP_UPGRADE_AVAILABLE = "tooltip.upgrade.available"; // "Upgrade available - click the icon to view details" + + private static final String TRAY_KEY_CAPTION = "tray.upgrade.caption"; // "IDEasy upgrade available" + private static final String TRAY_KEY_TEXT = "tray.upgrade.text"; // "Click to start IDEasy upgrade." + + private final NlsService nlsService; + private final UpgradeService upgradeService; + + private StackPane upgradeIndicator; + private Stage dialogStage; + + // Dialog FXML fields + @FXML + private Label statusLabel; + + @FXML + private Button upgradeButton; + + /** Whether the last known status is "upgrade available" (drives the dialog's button state on open). */ + private boolean upgradeAvailable; + + /** The last rendered status text, kept so {@link #showDialog()} can paint it once the label becomes available (it is null until the dialog first loads). */ + private String currentStatusText = ""; + + private String installedVersionString = ""; + private String latestVersionString = ""; + private boolean justUpgraded = false; + + /** + * The constructor. + * + * @param manager the {@link IdeGuiStateManager} to use. + * @param nlsService the {@link NlsService} to use. + */ + public UpgradeController(IdeGuiStateManager manager, NlsService nlsService) { + this(nlsService, new UpgradeService(manager)); + } + + /** + * The constructor with an injectable {@link UpgradeService}. + * + * @param nlsService the {@link NlsService} to use. + * @param upgradeService the {@link UpgradeService} encapsulating the upgrade business logic. + */ + public UpgradeController(NlsService nlsService, UpgradeService upgradeService) { + this.nlsService = nlsService; + this.upgradeService = upgradeService; + } + + public void start(StackPane upgradeIndicator) { + this.upgradeIndicator = upgradeIndicator; + showStatus(STATUS_KEY_CHECKING); + // indicator initially hidden until check completes + try { + if (this.upgradeIndicator != null) { + this.upgradeIndicator.setVisible(false); + Tooltip.install(this.upgradeIndicator, new Tooltip(nlsService.get(TOOLTIP_UPGRADE_AVAILABLE))); + // click handled by this controller + this.upgradeIndicator.setOnMouseClicked(ev -> { + ev.consume(); + Platform.runLater(this::showDialog); + }); + } + } catch (Throwable t) { + LOG.debug("Failed to initialize upgrade indicator", t); + } + startCheck(); + } + + /** + * Starts upgrade invoked from the dialog. + */ + private void performUpgradeTask() { + showStatus(STATUS_KEY_UPDATING); + + TasksHelper.run(() -> { + this.upgradeService.runUpgrade(); + return null; + }, ignored -> { + // Mark as updated and prefer showing the updated message (with version) + if (this.latestVersionString != null && !this.latestVersionString.isEmpty()) { + this.installedVersionString = this.latestVersionString; + } + showStatus(STATUS_KEY_UPDATED); + if (upgradeButton != null) { + upgradeButton.setDisable(true); + } + // after upgrade, re-check availability so the controller fetches authoritative version info + this.justUpgraded = true; + startCheck(); + }, throwable -> { + if (throwable == null) { + showStatus(STATUS_KEY_UNAVAILABLE); + } else { + showStatusFailed(throwable.getMessage()); + } + if (upgradeButton != null) { + upgradeButton.setDisable(false); + } + }, THREAD_RUNNER); + } + + private void startCheck() { + + TasksHelper.run(this.upgradeService::checkForUpgrade, result -> { + boolean available = Boolean.TRUE.equals(result); + this.installedVersionString = this.upgradeService.getInstalledVersion(); + this.latestVersionString = this.upgradeService.getLatestVersion(); + + showStatus(available ? STATUS_KEY_AVAILABLE : STATUS_KEY_UP_TO_DATE); + try { + if (this.upgradeIndicator != null) { + this.upgradeIndicator.setVisible(available); + } + if (this.upgradeButton != null) { + // if dialog is open, update button state + this.upgradeButton.setDisable(!available); + } + } catch (Throwable t) { + LOG.debug("Failed to update UI on check result", t); + } + // If we just performed an upgrade and now there is no newer version available, + // show the explicit 'updated to' confirmation message + if (this.justUpgraded && !available) { + // Use the localized updated message as canonical status + if (this.latestVersionString != null && !this.latestVersionString.isEmpty()) { + this.installedVersionString = this.latestVersionString; + } + showStatus(STATUS_KEY_UPDATED); + this.justUpgraded = false; + } + if (available) { + showTrayNotification(); + } + }, throwable -> { + LOG.debug("Upgrade check failed", throwable); + showStatus(STATUS_KEY_UNAVAILABLE); + try { + if (this.upgradeIndicator != null) { + this.upgradeIndicator.setVisible(false); + } + if (this.upgradeButton != null) { + this.upgradeButton.setDisable(true); + } + } catch (Throwable t) { + LOG.debug("Failed to update UI on check failure", t); + } + }, THREAD_CHECKER); + } + + /** + * Shows a plain localized status (with version substitution for available/up-to-date/updated) and records whether it represents "upgrade available" (for the + * dialog's button state). + */ + private void showStatus(String key) { + this.upgradeAvailable = STATUS_KEY_AVAILABLE.equals(key); + setLabelText(formatStatusText(key)); + } + + /** + * Shows the failed-status prefix with an appended (not localized) detail, e.g. an exception message. + */ + private void showStatusFailed(String detail) { + this.upgradeAvailable = false; + String text = this.nlsService.get(STATUS_KEY_FAILED_PREFIX); + if (detail != null) { + text = text + detail; + } + setLabelText(text); + } + + private String formatStatusText(String key) { + String text = this.nlsService.get(key); + if (STATUS_KEY_AVAILABLE.equals(key) || STATUS_KEY_UP_TO_DATE.equals(key) || STATUS_KEY_UPDATED.equals(key)) { + try { + return MessageFormat.format(text, this.installedVersionString, this.latestVersionString); + } catch (IllegalArgumentException iae) { + LOG.debug("Failed to format status text with versions", iae); + return text; + } + } + return text; + } + + private void setLabelText(String text) { + this.currentStatusText = text; + if (this.statusLabel != null) { + this.statusLabel.setText(text); + } + } + + @FXML + private void onUpgradeClicked() { + performUpgradeTask(); + } + + private void showDialog() { + try { + // Load dialog FXML if not already loaded + if (this.dialogStage == null) { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/devonfw/ide/gui/upgrade-dialog.fxml")); + loader.setResources(nlsService.getResourceBundle()); + loader.setController(this); + Parent root = loader.load(); + + this.dialogStage = new Stage(); + this.dialogStage.setTitle(nlsService.get(TRAY_KEY_CAPTION)); + this.dialogStage.initModality(Modality.APPLICATION_MODAL); + this.dialogStage.setScene(new Scene(root)); + this.dialogStage.setWidth(420); + this.dialogStage.setHeight(160); + this.dialogStage.setMinWidth(360); + this.dialogStage.setMinHeight(140); + this.dialogStage.setResizable(true); + } + + // repaint now that the label/button are bound: they may have missed status updates issued before the dialog was first loaded + setLabelText(this.currentStatusText); + if (this.upgradeButton != null) { + this.upgradeButton.setDisable(!this.upgradeAvailable); + } + + if (this.dialogStage.isShowing()) { + this.dialogStage.toFront(); + } else { + this.dialogStage.show(); + } + } catch (Throwable t) { + LOG.debug("Failed to show upgrade dialog", t); + } + } + + private void showTrayNotification() { + try { + // attach click action that runs upgrade on FX thread + TrayNotificationService.show(nlsService.get(TRAY_KEY_CAPTION), nlsService.get(TRAY_KEY_TEXT), () -> Platform.runLater(this::showDialog)); + } catch (Throwable t) { + LOG.debug("Failed to show tray notification", t); + } + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/update/UpgradeService.java b/gui/src/main/java/com/devonfw/ide/gui/update/UpgradeService.java new file mode 100644 index 0000000000..b8b15625c9 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/update/UpgradeService.java @@ -0,0 +1,86 @@ +package com.devonfw.ide.gui.update; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.context.IdeGuiContext; +import com.devonfw.ide.gui.context.IdeGuiStateManager; +import com.devonfw.tools.ide.commandlet.UpgradeCommandlet; +import com.devonfw.tools.ide.tool.IdeasyCommandlet; + +/** + * Encapsulates the tool-wide IDEasy upgrade business logic (checking for and running upgrades), independent of any UI framework and independent of any + * selected project/workspace. + */ +public class UpgradeService { + + private static final Logger LOG = LoggerFactory.getLogger(UpgradeService.class); + + private final IdeGuiStateManager manager; + + private String installedVersion = ""; + private String latestVersion = ""; + + /** + * The constructor. + * + * @param manager the {@link IdeGuiStateManager} used to obtain the shared start context. + */ + public UpgradeService(IdeGuiStateManager manager) { + + this.manager = manager; + } + + /** + * Performs the actual upgrade availability check. Also resolves the installed/latest version strings as a side effect, retrievable via + * {@link #getInstalledVersion()} and {@link #getLatestVersion()}. + * + * @return true if an upgrade is available, false otherwise + */ + public boolean checkForUpgrade() { + + try { + IdeGuiContext ctx = new IdeGuiContext(this.manager.getStartContext(), null); + IdeasyCommandlet cmd = new IdeasyCommandlet(ctx, null); + try { + var installed = cmd.getInstalledVersion(); + var latest = cmd.getLatestVersion(); + this.installedVersion = installed == null ? "" : installed.toString(); + this.latestVersion = latest == null ? "" : latest.toString(); + } catch (Exception e) { + LOG.debug("Failed to resolve versions", e); + this.installedVersion = ""; + this.latestVersion = ""; + } + return cmd.checkIfUpdateIsAvailable(); + } catch (Exception e) { + LOG.debug("Upgrade check failed", e); + return false; + } + } + + /** + * Runs the IDEasy upgrade commandlet. + */ + public void runUpgrade() { + + IdeGuiContext ctx = new IdeGuiContext(this.manager.getStartContext(), null); + new UpgradeCommandlet(ctx).run(); + } + + /** + * @return the installed version resolved by the last {@link #checkForUpgrade()} call, or {@code ""} if unknown. + */ + public String getInstalledVersion() { + + return this.installedVersion; + } + + /** + * @return the latest available version resolved by the last {@link #checkForUpgrade()} call, or {@code ""} if unknown. + */ + public String getLatestVersion() { + + return this.latestVersion; + } +} diff --git a/gui/src/main/resources/com/devonfw/ide/gui/assets/update.png b/gui/src/main/resources/com/devonfw/ide/gui/assets/update.png new file mode 100644 index 0000000000..73072532d1 Binary files /dev/null and b/gui/src/main/resources/com/devonfw/ide/gui/assets/update.png differ diff --git a/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml b/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml index 348a398d29..a113c8e2eb 100644 --- a/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml +++ b/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml @@ -4,7 +4,7 @@ - + @@ -16,18 +16,49 @@ - - - - - + + + + + + + + + + + + + + + + + + + + @@ -61,116 +92,128 @@

- - - - - + - + + + + + + + + + + - + + - + - - - - - - + + + - + - - - - - - + + + - + - - - - - - + + + - + - - - - - + - - - - + - +
diff --git a/gui/src/main/resources/com/devonfw/ide/gui/style/navigation.css b/gui/src/main/resources/com/devonfw/ide/gui/style/navigation.css index 5e4d2ca25d..ad172e446b 100644 --- a/gui/src/main/resources/com/devonfw/ide/gui/style/navigation.css +++ b/gui/src/main/resources/com/devonfw/ide/gui/style/navigation.css @@ -1,18 +1,19 @@ VBox { -fx-background-color: #18151e; + -fx-padding: 15; } .sideNavigationElement { - -fx-border-radius: 0; - -fx-background-color: #CCCCCC; - -fx-padding: 10; - -fx-font-size: 16px; - -fx-text-fill: #000; - -fx-border-radius: 10px; - -fx-background-radius: 10px; - } + -fx-border-radius: 0; + -fx-background-color: #CCCCCC; + -fx-padding: 10; + -fx-font-size: 16px; + -fx-text-fill: #000; + -fx-border-radius: 10px; + -fx-background-radius: 10px; +} .chooseProject { diff --git a/gui/src/main/resources/com/devonfw/ide/gui/upgrade-dialog.fxml b/gui/src/main/resources/com/devonfw/ide/gui/upgrade-dialog.fxml new file mode 100644 index 0000000000..dae24d37bc --- /dev/null +++ b/gui/src/main/resources/com/devonfw/ide/gui/upgrade-dialog.fxml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + +
+ +
+ + + + +