diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 7f1feb7481..4d12cf6aef 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -42,6 +42,8 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1904[#1904]: Add Inso CLI to IDEasy commandlets * https://github.com/devonfw/IDEasy/issues/1952[#1952]: Ability for platform specific dependencies * https://github.com/devonfw/IDEasy/issues/1950[#1950]: Fix exit autocompletion +* https://github.com/devonfw/IDEasy/issues/1936[#1936]: Improve localization of GUI + * https://github.com/devonfw/IDEasy/issues/1958[#1958]: Fix git pull on settings with local branch without remote The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/44?closed=1[milestone 2026.05.001]. diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/IdeContextTest.java b/cli/src/test/java/com/devonfw/tools/ide/context/IdeContextTest.java index c167895a49..c1ac76e39c 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/context/IdeContextTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/context/IdeContextTest.java @@ -5,13 +5,6 @@ import java.util.Properties; import org.junit.jupiter.api.Test; -import com.devonfw.tools.ide.security.ToolVersionChoice; -import com.devonfw.tools.ide.tool.ToolEditionAndVersion; -import com.devonfw.tools.ide.security.ToolVulnerabilities; -import com.devonfw.tools.ide.version.VersionIdentifier; -import com.devonfw.tools.ide.version.VersionRange; -import com.devonfw.tools.ide.url.model.file.json.Cve; -import com.devonfw.tools.ide.tool.ToolEdition; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,13 +17,20 @@ import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.os.SystemInfo; import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.security.ToolVersionChoice; +import com.devonfw.tools.ide.security.ToolVulnerabilities; +import com.devonfw.tools.ide.tool.ToolEdition; +import com.devonfw.tools.ide.tool.ToolEditionAndVersion; +import com.devonfw.tools.ide.url.model.file.json.Cve; import com.devonfw.tools.ide.variable.IdeVariables; import com.devonfw.tools.ide.version.IdeVersion; +import com.devonfw.tools.ide.version.VersionIdentifier; +import com.devonfw.tools.ide.version.VersionRange; /** * Test of {@link IdeContext}. */ -class IdeContextTest extends AbstractIdeContextTest { +public class IdeContextTest extends AbstractIdeContextTest { private static final Logger LOG = LoggerFactory.getLogger(IdeContextTest.class); @@ -284,7 +284,7 @@ void testFindBashOnSystemPathOnWindowsWithInvalidBashPathSet() { @Test void testQuestionWithMultipleOptions() { IdeTestContext context = newContext(PROJECT_BASIC, null, false); - String[] options = {"option1", "option2"}; + String[] options = { "option1", "option2" }; context.setAnswers("option1"); String result = context.question(options, "Which option?"); assertThat(result).isEqualTo("option1"); @@ -293,7 +293,7 @@ void testQuestionWithMultipleOptions() { @Test void testQuestionWithSingleOptionAndEmptyAnswer() { IdeTestContext context = newContext(PROJECT_BASIC, null, false); - String[] options = {"onlyOption"}; + String[] options = { "onlyOption" }; context.setAnswers(""); // Empty answer (Enter) String result = context.question(options, "Which option?"); assertThat(result).isEqualTo("onlyOption"); @@ -306,7 +306,7 @@ void testQuestionWithSingleToolVersionChoiceAndEmptyAnswer() { VersionIdentifier version = VersionIdentifier.of("17"); Cve cve = new Cve("CVE-2023-XXXX", 9.8, List.of(VersionRange.of("[17,18)"))); ToolVersionChoice choice = new ToolVersionChoice(new ToolEditionAndVersion(edition, version), "current", ToolVulnerabilities.of(List.of(cve))); - ToolVersionChoice[] options = {choice}; + ToolVersionChoice[] options = { choice }; context.setAnswers(""); // Empty answer (Enter) ToolVersionChoice result = context.question(options, "Which version?"); diff --git a/gui/pom.xml b/gui/pom.xml index d7701efece..2d30fe2059 100644 --- a/gui/pom.xml +++ b/gui/pom.xml @@ -30,7 +30,6 @@ ${project.groupId} ide-cli - tests ${project.version} test-jar test @@ -68,6 +67,14 @@ openjfx-monocle test + + + + org.wiremock + wiremock + 3.13.2 + test + 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 3094e5185c..418b588362 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/App.java +++ b/gui/src/main/java/com/devonfw/ide/gui/App.java @@ -2,6 +2,7 @@ import java.io.IOException; import javafx.application.Application; +import javafx.application.Platform; import javafx.fxml.FXMLLoader; import javafx.geometry.Rectangle2D; import javafx.scene.Parent; @@ -10,6 +11,11 @@ import javafx.stage.Screen; import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.i18n.I18nService; +import com.devonfw.ide.gui.modal.IdeDialog; import com.devonfw.tools.ide.variable.IdeVariables; import com.devonfw.tools.ide.version.IdeVersion; @@ -20,10 +26,22 @@ public class App extends Application { Parent root; + private static final Logger LOG = LoggerFactory.getLogger(App.class); + @Override public void start(Stage primaryStage) throws IOException { + // Initialize localization with system default locale + I18nService.getInstance(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()); + } + ); + FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource("main-view.fxml")); + fxmlLoader.setResources(I18nService.getInstance().getResourceBundle()); fxmlLoader.setController( new MainController(System.getenv(IdeVariables.IDE_ROOT.getName())) ); diff --git a/gui/src/main/java/com/devonfw/ide/gui/AppLauncher.java b/gui/src/main/java/com/devonfw/ide/gui/AppLauncher.java index aac16bfb9b..916f511b98 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/AppLauncher.java +++ b/gui/src/main/java/com/devonfw/ide/gui/AppLauncher.java @@ -2,10 +2,11 @@ /** * Launcher class for the App. Workaround for "Error: JavaFX runtime components are missing, and are required to run this application." Inspired by - * StackOverflow + * StackOverflow */ public class AppLauncher { + @SuppressWarnings("MissingJavadoc") public static void main(final String[] args) { App.main(args); 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 e153149107..66e2ee34d3 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/MainController.java +++ b/gui/src/main/java/com/devonfw/ide/gui/MainController.java @@ -1,27 +1,32 @@ package com.devonfw.ide.gui; -import java.io.IOException; -import java.nio.file.Files; +import java.io.FileNotFoundException; +import java.nio.file.NotDirectoryException; import java.nio.file.Path; -import java.util.stream.Stream; +import java.util.List; +import java.util.Locale; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.context.IdeStartContextImpl; -import com.devonfw.tools.ide.log.IdeLogLevel; -import com.devonfw.tools.ide.log.IdeLogListenerBuffer; +import com.devonfw.ide.gui.context.IdeGuiStateManager; +import com.devonfw.ide.gui.context.ProjectManager; +import com.devonfw.ide.gui.i18n.I18nService; +import com.devonfw.ide.gui.modal.IdeDialog; /** * Controller of the main screen of the dashboard GUI. */ +@SuppressWarnings("unused") public class MainController { - private static Logger LOG = LoggerFactory.getLogger(MainController.class); + private static final Logger LOG = LoggerFactory.getLogger(MainController.class); + + private final ProjectManager projectManager; @FXML private ComboBox selectedProject; @@ -29,6 +34,18 @@ public class MainController { @FXML private ComboBox selectedWorkspace; + @FXML + private Label labelProject; + + @FXML + private Label labelWorkspace; + + @FXML + private Label labelLanguage; + + @FXML + private ComboBox selectedLanguage; + @FXML private Button androidStudioOpen; @@ -42,23 +59,77 @@ public class MainController { private Button vsCodeOpen; private final String directoryPath; - private Path projectValue; - private Path workspaceValue; + /** * Constructor */ public MainController(String directoryPath) { + LOG.debug("IDE_ROOT path={}", directoryPath); this.directoryPath = directoryPath; + + this.projectManager = IdeGuiStateManager.getInstance().getProjectManager(); } @FXML private void initialize() { - setProjectsComboBox(); + initLanguageComboBox(); + updateTexts(); + I18nService.getInstance().addLocaleChangeListener(this::updateTexts); + } + + @FXML + private void dispose() { + I18nService.getInstance().removeLocaleChangeListener(this::updateTexts); + } + + private void initLanguageComboBox() { + + // Initialize language choices + selectedLanguage.getItems().clear(); + selectedLanguage.getItems().addAll("English", "Deutsch"); + + // Select current locale + Locale current = I18nService.getInstance().getLocale(); + if (current != null && "de".equals(current.getLanguage())) { + selectedLanguage.setValue("Deutsch"); + } else { + selectedLanguage.setValue("English"); + } + + selectedLanguage.setOnAction(ev -> { + String selection = selectedLanguage.getValue(); + Locale newLocale = "Deutsch".equals(selection) ? Locale.GERMAN : Locale.ENGLISH; + I18nService.getInstance().setLocale(newLocale); + }); + } + + /** + * Use this method to update UI texts to change locale when adding new UI Elements. It uses a simple naming convention for the keys in the resource bundle. + * Found in message.properties and message_de.properties + */ + private void updateTexts() { + I18nService i18n = I18nService.getInstance(); + // Set Labels + labelProject.setText(i18n.get("label.project")); + labelWorkspace.setText(i18n.get("label.workspace")); + labelLanguage.setText(i18n.get("label.language")); + + // Set ComboBox prompts + selectedProject.setPromptText(i18n.get("prompt.chooseProject")); + selectedWorkspace.setPromptText(i18n.get("prompt.chooseWorkspace")); + selectedLanguage.setPromptText(i18n.get("prompt.chooseLanguage")); + + // Set Button texts + androidStudioOpen.setText(i18n.get("button.open")); + eclipseOpen.setText(i18n.get("button.open")); + intellijOpen.setText(i18n.get("button.open")); + vsCodeOpen.setText(i18n.get("button.open")); } + @FXML private void openAndroidStudio() { @@ -83,66 +154,61 @@ private void openVsCode() { openIDE("vscode"); } - private void setProjectsComboBox() { assert (directoryPath != null) : "directoryPath is null! Please check the setup of your environment variables (IDE_ROOT)"; + List projects = projectManager.getProjectNames(); + selectedProject.getItems().clear(); - Path directory = Path.of(directoryPath); - - if (Files.exists(directory) && Files.isDirectory(directory)) { - try (Stream subPaths = Files.list(directory)) { - subPaths - .filter(Files::isDirectory) - .map(Path::getFileName) - .map(Path::toString) - .filter(name -> !name.startsWith("_")) - .forEach(name -> selectedProject.getItems().add(name)); - } catch (IOException e) { - throw new IllegalStateException("Failed to list projects!", e); - } - } + selectedProject.getItems().addAll(projects); selectedProject.setOnAction(actionEvent -> { - projectValue = Path.of(selectedProject.getValue()).resolve(IdeContext.FOLDER_WORKSPACES); + setWorkspaceComboBox(); + selectedWorkspace.setDisable(false); + }); + } + + private void setWorkspaceComboBox() { + + List workspaces = null; + try { + workspaces = projectManager.getWorkspaceNames(selectedProject.getValue()); + } catch (NotDirectoryException e) { + throw new RuntimeException(e); + } + + selectedWorkspace.getItems().clear(); + selectedWorkspace.getItems().addAll(workspaces); + + selectedWorkspace.setOnAction(actionEvent -> { + updateContext(selectedProject.getValue(), selectedWorkspace.getValue()); + androidStudioOpen.setDisable(false); eclipseOpen.setDisable(false); intellijOpen.setDisable(false); vsCodeOpen.setDisable(false); - selectedWorkspace.setValue("main"); - this.workspaceValue = Path.of("main"); }); } - @FXML - private void setWorkspaceValue() { + private void openIDE(String inIde) { - selectedWorkspace.getItems().clear(); - Path directory = Path.of(directoryPath).resolve(projectValue); - if (Files.exists(directory) && Files.isDirectory(directory)) { - try (Stream subPaths = Files.list(directory)) { - subPaths - .filter(Files::isDirectory) - .map(Path::getFileName) - .map(Path::toString) - .forEach(name -> selectedWorkspace.getItems().add(name)); - - } catch (IOException e) { - throw new RuntimeException("Error occurred while fetching workspace names.", e); - } - } - this.workspaceValue = Path.of(selectedWorkspace.getValue()); + IdeGuiStateManager + .getInstance() + .getCurrentContext() + .getCommandletManager() + .getCommandlet(inIde) + .run(); } - private void openIDE(String inIde) { - - final IdeLogListenerBuffer buffer = new IdeLogListenerBuffer(); - IdeLogLevel logLevel = IdeLogLevel.INFO; - IdeStartContextImpl startContext = new IdeStartContextImpl(logLevel, buffer); - IdeGuiContext context = new IdeGuiContext(startContext, Path.of(this.directoryPath).resolve(this.projectValue).resolve(this.workspaceValue)); - context.getCommandletManager().getCommandlet(inIde).run(); + private void updateContext(String selectedProjectName, String selectedWorkspaceName) { + try { + IdeGuiStateManager.getInstance().switchContext(selectedProjectName, selectedWorkspaceName); + } catch (FileNotFoundException e) { + IdeDialog errorDialog = new IdeDialog(IdeDialog.AlertType.ERROR, e.getMessage()); + errorDialog.showAndWait(); + } } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/GuiContextChangeListener.java b/gui/src/main/java/com/devonfw/ide/gui/context/GuiContextChangeListener.java new file mode 100644 index 0000000000..53972265f6 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/context/GuiContextChangeListener.java @@ -0,0 +1,15 @@ +package com.devonfw.ide.gui.context; + +/** + * Interface that notifies listeners of context changes. + */ +public interface GuiContextChangeListener { + + /** + * This method is called when the context changes. It can be used to update the GUI based on the new context. + * + * @param newContext the new {@link IdeGuiContext}. + */ + void onContextChange(IdeGuiContext newContext); + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/IdeGuiContext.java b/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiContext.java similarity index 96% rename from gui/src/main/java/com/devonfw/ide/gui/IdeGuiContext.java rename to gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiContext.java index c16732d70a..221e9325c3 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/IdeGuiContext.java +++ b/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiContext.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui; +package com.devonfw.ide.gui.context; import java.nio.file.Path; @@ -12,7 +12,6 @@ */ public class IdeGuiContext extends AbstractIdeContext { - /** * The constructor. * @@ -20,17 +19,19 @@ public class IdeGuiContext extends AbstractIdeContext { * @param workingDirectory the optional {@link Path} to current working directory. */ public IdeGuiContext(IdeStartContextImpl startContext, Path workingDirectory) { + super(startContext, workingDirectory); } @Override protected String readLine() { + return ""; } @Override public IdeProgressBar newProgressBar(String title, long size, String unitName, long unitSize) { + return new IdeProgressBarNone(title, 0, unitName, unitSize); } - } 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 new file mode 100644 index 0000000000..070b0e90a0 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/context/IdeGuiStateManager.java @@ -0,0 +1,153 @@ +package com.devonfw.ide.gui.context; + +import java.io.FileNotFoundException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.context.IdeStartContextImpl; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.log.IdeLogListenerBuffer; + +/** + * This class has the purpose of enabling the context state management for the IDEasy GUI. It is a thread-safe singleton implementation (Bill Pugh Singleton). + */ +public class IdeGuiStateManager { + + private static final Logger LOG = LoggerFactory.getLogger(IdeGuiStateManager.class); + + private Path ideRootDir; + private ProjectManager projectManager; + + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + + /** + * Project context based on which project the user works in. + */ + private volatile IdeGuiContext currentContext; + + /** + * The {@link IdeStartContextImpl} for the GUI, this stays the same for the whole GUI session, only the {@link IdeGuiContext} changes. + */ + private final IdeStartContextImpl startContext; + + private IdeGuiStateManager() { + + final IdeLogListenerBuffer buffer = new IdeLogListenerBuffer(); + IdeLogLevel logLevel = IdeLogLevel.DEBUG; + startContext = new IdeStartContextImpl(logLevel, buffer); + } + + /** + * @return the singleton instance of the {@link IdeGuiStateManager}. + */ + public static IdeGuiStateManager getInstance() { + + IdeGuiStateManager instance = Holder.INSTANCE; + if (instance.ideRootDir == null) { + String ideRoot = System.getenv("IDE_ROOT"); + if (ideRoot == null) { + throw new IllegalStateException("IDE_ROOT environment variable is not set!"); + } + instance.ideRootDir = Path.of(ideRoot); + instance.projectManager = new ProjectManager(instance.ideRootDir); + } + return Holder.INSTANCE; + } + + /** + * USE WITH CARE. + * This method is used in cases where the IDE_ROOT environment variable is not set, e.g. in test contexts on GitHub actions. This method will retrieve the + * current instance, set the project directory manually an then return the updated instance. + * + * @param ideRoot root directory for the ide projects. + * @return the singleton instance of the {@link IdeGuiStateManager}. + */ + //TODO: remove this method once we have a better solution for the test context, after implementing CLi's test jar. + public static IdeGuiStateManager getInstanceOverrideRootDir(String ideRoot) { + LOG.warn("Using unsafe getInstanceOverrideRootDir method."); + + IdeGuiStateManager instance = Holder.INSTANCE; + if (ideRoot == null) { + throw new IllegalArgumentException("ideRoot must not be null!"); + } else if (instance.ideRootDir != null) { + LOG.warn("ideRootDir is already set. You are overriding it."); + } + + instance.ideRootDir = Path.of(ideRoot); + instance.projectManager = new ProjectManager(instance.ideRootDir); + return instance; + } + + /** + * @param projectName name of the project folder + * @param workspaceName name of the workspace folder + * @return the new {@link IdeGuiContext} for the selected project and workspace. + * @throws FileNotFoundException if workspace or project does not exist + */ + public synchronized IdeGuiContext switchContext(String projectName, String workspaceName) throws FileNotFoundException { + + LOG.debug("Trying to switch context to project {} and workspace {}", projectName, workspaceName); + + Path projectPath = ideRootDir.resolve(projectName); + Path workspacePath = projectPath.resolve("workspaces").resolve(workspaceName); + + if (!Files.exists(projectPath)) { + throw new FileNotFoundException("Project " + projectPath + " does not exist!"); + } else if (!Files.exists(workspacePath)) { + throw new FileNotFoundException("Workspace " + workspacePath + " does not exist!"); + } + + this.currentContext = new IdeGuiContext(startContext, workspacePath); + listeners.forEach(listener -> listener.onContextChange(this.currentContext)); + + return this.currentContext; + } + + /** + * @return the current {@link IdeGuiContext} based on the selected project. is null, if no context has been set via switchContext. + */ + public IdeGuiContext getCurrentContext() { + + return this.currentContext; + } + + /** + * @return instance of {@link ProjectManager} + */ + public ProjectManager getProjectManager() { + + return projectManager; + } + + /** + * Add a listener to the context change events. + * + * @param listener the {@link GuiContextChangeListener} to attach to context updates. + */ + public void addGuiContextChangeListener(GuiContextChangeListener listener) { + listeners.add(listener); + } + + /** + * Remove a listener from the context change events. + * + * @param listener the {@link GuiContextChangeListener} to remove from context updates. + */ + public void removeGuiContextChangeListener(GuiContextChangeListener listener) { + listeners.remove(listener); + } + + /** + * Holder class for the singleton instance. The static keyword ensures the thread-safety of the singleton. + * + * @see More info + */ + private static class Holder { + + private static final IdeGuiStateManager INSTANCE = new IdeGuiStateManager(); + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/ProjectManager.java b/gui/src/main/java/com/devonfw/ide/gui/context/ProjectManager.java new file mode 100644 index 0000000000..0bce752366 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/context/ProjectManager.java @@ -0,0 +1,79 @@ +package com.devonfw.ide.gui.context; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NotDirectoryException; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Service class that allows to access the list of projects + */ +public class ProjectManager { + + private static final Logger LOG = LoggerFactory.getLogger(ProjectManager.class); + + private final Path ideRootDirectory; + + /** + * Service class that reads the list of projects/workspaces + * + * @param ideRootDirectory IDE_ROOT ENV variable value + */ + /* + * Protected: Class should only be accessed by the code via the {@link IdeGuiStateManager} + * Why not in the IdeGuiContext? Reasoning is, that you might want to read the list of projects without being already in the project context + */ + protected ProjectManager(Path ideRootDirectory) { + + this.ideRootDirectory = ideRootDirectory; + + if (ideRootDirectory == null) { + throw new IllegalArgumentException("Root directory is null"); + } else if (!Files.exists(ideRootDirectory)) { + throw new IllegalArgumentException("Root directory does not exist"); + } else if (!Files.isDirectory(ideRootDirectory)) { + throw new IllegalArgumentException("Root directory is not a directory"); + } + } + + /** + * @return the list of project (names) in the project directory + */ + public List getProjectNames() { + + try (Stream subPaths = Files.list(ideRootDirectory)) { + return subPaths + .filter(Files::isDirectory) + .map(Path::getFileName) + .map(Path::toString) + .filter(name -> !name.startsWith("_") && Files.exists(ideRootDirectory.resolve(name).resolve("workspaces"))) + .toList(); + + } catch (IOException e) { + throw new RuntimeException("Failed to read project list!", e); + } + } + + /** + * @param projectName name of the project for which the workspace names should be returned + * @return the list of workspace (names) for the given project name + */ + public List getWorkspaceNames(String projectName) throws NotDirectoryException { + + Path workspacesDir = ideRootDirectory.resolve(projectName).resolve("workspaces"); + if (!Files.isDirectory(workspacesDir)) { + throw new NotDirectoryException("invalid workspaces directory for project: " + projectName); + } + try (Stream subPaths = Files.list(workspacesDir)) { + return subPaths.filter(Files::isDirectory) + .map(Path::getFileName).map(Path::toString).toList(); + } catch (IOException e) { + throw new RuntimeException("Failed to read workspaces for " + projectName, e); + } + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/i18n/I18nService.java b/gui/src/main/java/com/devonfw/ide/gui/i18n/I18nService.java new file mode 100644 index 0000000000..a2501ebd42 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/i18n/I18nService.java @@ -0,0 +1,140 @@ +package com.devonfw.ide.gui.i18n; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.PropertyResourceBundle; +import java.util.ResourceBundle; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Service for managing localization (i18n) in the JavaFX GUI. + */ +public class I18nService { + + private static final Logger LOG = LoggerFactory.getLogger(I18nService.class); + + private static final String BUNDLE_NAME = "i18n.messages"; + + private static final String MISSING_KEY_PREFIX = "!"; + + private static final String MISSING_KEY_SUFFIX = "!"; + + private static I18nService instance; + + private final List localeListeners = new CopyOnWriteArrayList<>(); + + private Locale locale; + + private ResourceBundle resourceBundle; + + private I18nService() { + // singleton + } + + public static synchronized I18nService getInstance(Locale locale) { + + if (instance == null) { + instance = new I18nService(); + instance.setLocale(locale == null ? Locale.getDefault() : locale); + } + return instance; + } + + public static synchronized I18nService getInstance() { + + if (instance == null) { + return getInstance(null); + } + return instance; + } + + public static synchronized void resetInstance() { + + instance = null; + } + + public void setLocale(Locale locale) { + + this.locale = locale; + loadBundle(); + LOG.info("Locale set to: {}", locale.getLanguage()); + for (Runnable listener : this.localeListeners) { + try { + listener.run(); + } catch (Exception e) { + LOG.warn("Locale change listener threw: {}", e.getMessage()); + } + } + } + + public void addLocaleChangeListener(Runnable listener) { + + if (listener != null) { + this.localeListeners.add(listener); + } + } + + public void removeLocaleChangeListener(Runnable listener) { + if (listener != null) { + this.localeListeners.remove(listener); + } + } + + public Locale getLocale() { + + return this.locale; + } + + public ResourceBundle getResourceBundle() { + + return this.resourceBundle; + } + + public String get(String key) { + + try { + return this.resourceBundle.getString(key); + } catch (MissingResourceException e) { + LOG.warn("Missing translation key: {} for locale: {}", key, this.locale); + return MISSING_KEY_PREFIX + key + MISSING_KEY_SUFFIX; + } + } + + private void loadBundle() { + + try { + this.resourceBundle = ResourceBundle.getBundle(BUNDLE_NAME, this.locale, new UTF8Control()); + LOG.debug("ResourceBundle loaded for locale: {}", this.locale); + } catch (MissingResourceException e) { + LOG.error("Failed to load ResourceBundle for locale: {}", this.locale, e); + this.resourceBundle = ResourceBundle.getBundle(BUNDLE_NAME, Locale.ENGLISH, new UTF8Control()); + } + } + + /** + * UTF-8 ResourceBundle.Control to read .properties files correctly. + */ + private static final class UTF8Control extends ResourceBundle.Control { + + @Override + public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, + boolean reload) throws IOException { + + String resourceName = toResourceName(toBundleName(baseName, locale), "properties"); + try (InputStream inputStream = loader.getResourceAsStream(resourceName)) { + if (inputStream == null) { + return null; + } + return new PropertyResourceBundle(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + } + } + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java b/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java new file mode 100644 index 0000000000..2ee467ae23 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java @@ -0,0 +1,42 @@ +package com.devonfw.ide.gui.modal; + +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.image.Image; +import javafx.stage.Stage; + +/** + * Custom Alert class for IDEasy to allow interaction via the CLIs questions/modals/selections. + */ +public class IdeDialog extends Alert { + + /** + * @param alertType the {@link AlertType} of the alert (e.g. INFORMATION, CONFIRMATION, etc). + */ + public IdeDialog(AlertType alertType) { + + super(alertType); + setupDefaultProperties(); + } + + /** + * @param alertType the {@link AlertType} of the alert (e.g. INFORMATION, CONFIRMATION, etc). + * @param message main message displayed in the dialoge + * @param buttonTypes defines the different buttons that the alert displays. + */ + public IdeDialog(AlertType alertType, String message, ButtonType... buttonTypes) { + + super(alertType, message, buttonTypes); + setupDefaultProperties(); + } + + private void setupDefaultProperties() { + + setTitle("IDEasy"); + + setOnShowing(event -> { + Stage stage = (Stage) getDialogPane().getScene().getWindow(); + stage.getIcons().add(new Image("com/devonfw/ide/gui/assets/devonfw.png")); + }); + } +} 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 0ce3546a95..2fd46dc32a 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 @@ -5,7 +5,8 @@ - @@ -24,9 +25,9 @@ - @@ -34,9 +35,20 @@ - + + + + + + + + @@ -73,7 +85,7 @@ spacing="10.0" HBox.hgrow="ALWAYS">